RadrootsNativeAdmissionTests.swift (14736B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import RadrootsKitTesting 5 import Testing 6 7 @Test func nativeAdmissionReservesBeforeAwaitAndPreservesQueuedOwnership() async throws { 8 let fixture = try NativeAdmissionFixture() 9 defer { fixture.remove() } 10 let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 11 let gate = NativeAdmissionGate() 12 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters()) 13 let request = try nativeAdmissionRequest() 14 let first = Task { try await transfer.enqueue(request) } 15 await gate.waitUntilEntered() 16 await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { 17 _ = try await transfer.enqueue(request) 18 } 19 let conflicting = try nativeAdmissionRequest(remote: "https://example.org/other") 20 await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { 21 _ = try await transfer.enqueue(conflicting) 22 } 23 let secondStore = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 24 let secondOwner = RadrootsAppleBackgroundTransfer(store: secondStore, adapters: gate.adapters()) 25 #expect(try await secondOwner.snapshots().first?.state == .queued) 26 await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { 27 _ = try await secondOwner.enqueue(request) 28 } 29 let queued = try #require(try await transfer.snapshots().first) 30 #expect(queued.state == .queued) 31 #expect(queued.request.headers.isEmpty && queued.request.metadata.isEmpty) 32 #expect(queued.executionID != nil) 33 await gate.finish() 34 #expect(try await first.value.identifier == request.identifier) 35 #expect(await gate.admittedCount == 1) 36 #expect(try await !secondStore.admissionIsActive(for: request.identifier)) 37 #expect(try await transfer.snapshot(for: request.identifier)?.state == .running) 38 } 39 40 @Test func nativeAdmissionDifferentIdentifiersCanProceedWhileAnotherAwaitIsHeld() async throws { 41 let store = RadrootsInMemoryBackgroundTransferStore() 42 let firstGate = NativeAdmissionGate() 43 let secondGate = NativeAdmissionGate() 44 let first = RadrootsAppleBackgroundTransfer(store: store, adapters: firstGate.adapters()) 45 let second = RadrootsAppleBackgroundTransfer(store: store, adapters: secondGate.adapters()) 46 let original = try nativeAdmissionRequest() 47 let other = try RadrootsBackgroundTransferRequest( 48 identifier: RadrootsBackgroundTransferIdentifier("native.admission.other"), 49 remoteURL: original.remoteURL, method: original.method, operation: original.operation 50 ) 51 let firstTask = Task { try await first.enqueue(original) } 52 await firstGate.waitUntilEntered() 53 let secondTask = Task { try await second.enqueue(other) } 54 await secondGate.waitUntilEntered() 55 #expect(try await store.loadSnapshots().count == 2) 56 await secondGate.finish() 57 #expect(try await secondTask.value.identifier == other.identifier) 58 await firstGate.finish() 59 #expect(try await firstTask.value.identifier == original.identifier) 60 #expect(await firstGate.admittedCount == 1) 61 #expect(await secondGate.admittedCount == 1) 62 } 63 64 @Test func nativeAdmissionCancellationWhileEnqueueSuspendsCancelsLateTask() async throws { 65 let store = RadrootsInMemoryBackgroundTransferStore() 66 let gate = NativeAdmissionGate() 67 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters()) 68 let request = try nativeAdmissionRequest() 69 let pending = Task { try await transfer.enqueue(request) } 70 await gate.waitUntilEntered() 71 try await transfer.cancel(request.identifier) 72 await gate.finish() 73 _ = try await pending.value 74 #expect(await gate.active.isEmpty) 75 let snapshot = try #require(try await store.loadSnapshots().first) 76 #expect(snapshot.state == .cancelled && snapshot.possibleRemoteOrphan) 77 } 78 79 @Test func nativeAdmissionErrorAfterEffectPreservesUnknownOutcomeAndExistingReceipt() async throws { 80 let store = RadrootsInMemoryBackgroundTransferStore() 81 let request = try nativeAdmissionRequest() 82 let adapters = RadrootsAppleBackgroundTransferAdapters( 83 enqueue: { _, executionID in 84 let existing = try #require(try await store.loadSnapshots().first) 85 #expect(existing.executionID == executionID) 86 let completed = try RadrootsBackgroundTransferSnapshot( 87 request: existing.request, state: .awaitingVerification, 88 response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil), 89 executionID: executionID 90 ) 91 #expect(try await store.compareExchangeSnapshot(expected: existing, desired: completed)) 92 throw RadrootsBackgroundTransferError.transferFailure 93 }, cancel: { _ in }, activeTransferIdentifiers: { [] }, 94 handleBackgroundEvents: { _, completion in completion() } 95 ) 96 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) 97 await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { _ = try await transfer.enqueue(request) } 98 #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) 99 let lost = try RadrootsBackgroundTransferSnapshot(request: request, state: .queued, executionID: UUID()) 100 let recoveredStore = RadrootsInMemoryBackgroundTransferStore(snapshots: [lost]) 101 let recovered = RadrootsAppleBackgroundTransfer(store: recoveredStore, adapters: adapters) 102 let interrupted = try #require(try await recovered.snapshots().first) 103 #expect(interrupted.state == .interrupted && interrupted.possibleRemoteOrphan) 104 } 105 106 @Test func nativeAdmissionStaleAndDuplicateCallbacksCannotOverwriteNewAttempt() async throws { 107 let fixture = try NativeAdmissionFixture() 108 defer { fixture.remove() } 109 let request = try nativeAdmissionRequest() 110 let generation = UUID() 111 let running = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: generation) 112 let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) 113 let coordinator = RadrootsTransferCoordinator( 114 sessionIdentifier: "tests", store: store, 115 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots) 116 ) 117 let response = RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) 118 await coordinator.complete( 119 identifier: request.identifier, 120 completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response, 121 bytesTransferred: 3, totalBytesExpected: 3), 122 executionID: UUID() 123 ) 124 #expect(try await store.loadSnapshots().first == running) 125 await coordinator.complete( 126 identifier: request.identifier, 127 completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response, 128 bytesTransferred: 3, totalBytesExpected: 3), 129 executionID: generation 130 ) 131 let completed = try #require(try await store.loadSnapshots().first) 132 #expect(completed.state == .awaitingVerification && completed.executionID == generation) 133 await coordinator.updateProgress(identifier: request.identifier, bytesTransferred: 1, totalBytesExpected: 3, 134 executionID: generation) 135 await coordinator.complete( 136 identifier: request.identifier, 137 completion: RadrootsTransferCompletion(platformError: RadrootsBackgroundTransferError.transferFailure, 138 stagedDownloadResult: nil, httpResult: response, bytesTransferred: 1, 139 totalBytesExpected: 3), 140 executionID: generation 141 ) 142 #expect(try await store.loadSnapshots().first == completed) 143 } 144 145 @Test func nativeAdmissionPersistsExactLeaseAndGenerationAcrossRestart() async throws { 146 let fixture = try NativeAdmissionFixture() 147 defer { fixture.remove() } 148 let file = RadrootsFileReference(scope: .cache, relativePath: "body") 149 let bytes = Data("exact upload bytes".utf8) 150 let fileOwner = RadrootsAppleFileAccess(roots: fixture.roots) 151 try fileOwner.write(.inline(bytes), to: file) 152 let request = try nativeAdmissionRequest(expectedSHA256: RadrootsAppleFileDigest.sha256(bytes)) 153 let generation = UUID() 154 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots) 155 let lease = try resolver.prepareUploadLease(for: request, executionID: generation, existing: nil) 156 let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 157 let queued = try RadrootsBackgroundTransferSnapshot( 158 request: request, 159 executionID: generation, 160 uploadLease: lease.blob 161 ) 162 #expect(try await store.compareExchangeSnapshot(expected: nil, desired: queued)) 163 try fileOwner.reset(scope: .cache) 164 let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 165 let saved = try #require(try await reopened.loadSnapshots().first) 166 #expect(saved.request.headers.isEmpty && saved.request.metadata.isEmpty) 167 #expect(saved.executionID == generation && saved.uploadLease == lease.blob) 168 let recovered = try resolver.prepareUploadLease( 169 for: saved.request, 170 executionID: generation, 171 existing: saved.uploadLease 172 ) 173 #expect(recovered == lease) 174 #expect(try Data(contentsOf: recovered.fileURL) == bytes) 175 let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: generation) 176 #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor) 177 #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v2|bad|0|0|invalid") == nil) 178 try resolver.releaseUploadLease(executionID: generation) 179 #expect(!FileManager.default.fileExists(atPath: lease.fileURL.path)) 180 } 181 182 @Test func nativeAdmissionStoreClaimsOnceAndRejectsStaleTransition() async throws { 183 let fixture = try NativeAdmissionFixture() 184 defer { fixture.remove() } 185 let request = try nativeAdmissionRequest() 186 let results = await withTaskGroup(of: Bool.self) { group in 187 for _ in 0 ..< 8 { 188 group.addTask { 189 let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 190 let desired = try? RadrootsBackgroundTransferSnapshot(request: request, executionID: UUID()) 191 guard let desired else { return false } 192 return await (try? store.compareExchangeSnapshot(expected: nil, desired: desired)) ?? false 193 } 194 } 195 var results: [Bool] = [] 196 for await result in group { 197 results.append(result) 198 } 199 return results 200 } 201 #expect(results.filter(\.self).count == 1) 202 let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) 203 let original = try #require(try await store.loadSnapshots().first) 204 let cancelled = try original.transitioned(to: .cancelled, at: Date(), possibleRemoteOrphan: true) 205 #expect(try await store.compareExchangeSnapshot(expected: original, desired: cancelled)) 206 #expect(try await !store.compareExchangeSnapshot(expected: original, 207 desired: original.transitioned(to: .running, at: Date()))) 208 #expect(try await store.loadSnapshots().first == cancelled) 209 } 210 211 private actor NativeAdmissionGate { 212 private var entered = false 213 private var observer: CheckedContinuation<Void, Never>? 214 private var pending: CheckedContinuation<Void, Never>? 215 private(set) var active: Set<RadrootsBackgroundTransferIdentifier> = [] 216 private(set) var admittedCount = 0 217 218 nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { 219 RadrootsAppleBackgroundTransferAdapters(enqueue: { request, _ in await self.enqueue(request) }, 220 cancel: { identifier in await self.cancel(identifier) }, 221 activeTransferIdentifiers: { await self.active }, 222 handleBackgroundEvents: { _, completion in completion() }) 223 } 224 225 func waitUntilEntered() async { 226 if entered { 227 return 228 } 229 await withCheckedContinuation { observer = $0 } 230 } 231 232 func finish() { 233 pending?.resume(); pending = nil 234 } 235 236 func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { 237 active.remove(identifier) 238 } 239 240 private func enqueue(_ request: RadrootsBackgroundTransferRequest) async { 241 admittedCount += 1 242 entered = true 243 observer?.resume() 244 observer = nil 245 await withCheckedContinuation { pending = $0 } 246 active.insert(request.identifier) 247 } 248 } 249 250 private func nativeAdmissionRequest( 251 remote: String = "https://example.org/upload", expectedSHA256: String? = nil 252 ) throws -> RadrootsBackgroundTransferRequest { 253 try RadrootsBackgroundTransferRequest(identifier: RadrootsBackgroundTransferIdentifier("native.admission"), 254 remoteURL: #require(URL(string: remote)), method: .put, 255 operation: .upload(source: .file(RadrootsFileReference( 256 scope: .cache, 257 relativePath: "body" 258 ))), 259 headers: ["Authorization": "test-only-authority"], 260 metadata: ["label": "test-only-private-metadata"], 261 expectedSourceSHA256: expectedSHA256) 262 } 263 264 private struct NativeAdmissionFixture { 265 let base: URL 266 let roots: RadrootsAppleFileRoots 267 init() throws { 268 let raw = FileManager.default.temporaryDirectory.appendingPathComponent("native-admission-\(UUID().uuidString)") 269 try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true) 270 let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) }) 271 defer { Darwin.free(pointer) } 272 base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 273 roots = try RadrootsAppleFileRoots( 274 appIdentifier: "org.radroots.tests", 275 dataRoot: base.appendingPathComponent("data"), 276 cacheRoot: base.appendingPathComponent("cache"), 277 temporaryRoot: base.appendingPathComponent("tmp") 278 ) 279 } 280 281 func remove() { 282 try? FileManager.default.removeItem(at: base) 283 } 284 }