RadrootsReceiptBarrierTests.swift (8581B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import RadrootsKitTesting 5 import Testing 6 7 @Test func receiptBarrierRetriesProgressConflictAndStoreFailureBeforeAcknowledgement() async throws { 8 let roots = try appleTransferRoots() 9 defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } 10 let request = try appleUploadRequest(identifier: "receipt.barrier", responsePolicy: .boundedJSON()) 11 let generation = UUID() 12 let snapshot = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: generation) 13 let store = ReceiptBarrierStore(snapshot: snapshot) 14 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 15 let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "receipt.tests", store: store, 16 fileResolver: resolver) 17 let callbacks = RadrootsTransferCallbackQueue() 18 let acknowledgement = RadrootsCompletionProbe() 19 let body = Data(#"{"url":"https://example.org/blob"}"#.utf8) 20 callbacks.enqueue(receipt: request.identifier) { 21 await coordinator.complete(identifier: request.identifier, 22 completion: RadrootsTransferCompletion( 23 platformError: nil, 24 stagedDownloadResult: nil, 25 httpResult: RadrootsBackgroundHTTPResult(statusCode: 200, 26 mediaType: "application/json", 27 body: body, 28 bodyExceeded: false), 29 bytesTransferred: 10, 30 totalBytesExpected: 10 31 ), executionID: generation) 32 } 33 try await store.waitUntilEntered() 34 #expect(await store.entered) 35 #expect(callbacks.pendingIdentifiers == [request.identifier]) 36 for _ in 0 ..< 10 { 37 await coordinator.handleBackgroundEvents(identifier: "receipt.tests") { acknowledgement.markCompleted() } 38 } 39 callbacks.enqueue { await coordinator.finishBackgroundEvents(identifier: "receipt.tests") } 40 #expect(!acknowledgement.completed) 41 await store.release() 42 for _ in 0 ..< 500 { 43 if acknowledgement.completionCount == 10 { 44 break 45 } 46 try await Task.sleep(for: .milliseconds(10)) 47 } 48 #expect(acknowledgement.completionCount == 10) 49 #expect(await store.terminalAttempts == 4) 50 #expect(callbacks.pendingIdentifiers.isEmpty) 51 let receipt = try #require(try await store.loadSnapshots().first) 52 #expect(receipt.state == .awaitingVerification && receipt.executionID == generation) 53 #expect(receipt.response?.body == body && receipt.progress.bytesTransferred == 10) 54 } 55 56 @Test func receiptBarrierSurvivesActualStoreLockAndRecoversAfterPersistence() async throws { 57 let roots = try appleTransferRoots() 58 defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } 59 let store = RadrootsAppleBackgroundTransferStore(roots: roots) 60 let request = try appleUploadRequest(identifier: "receipt.lock") 61 try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .running)) 62 let lockURL = try roots.resolvedURL(for: RadrootsFileReference(scope: .data, 63 relativePath: "background_transfers/transfers.lock")) 64 let descriptor = try #require(try RadrootsAtomicFile.acquireExclusiveLock(at: lockURL)) 65 let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "locked.receipt", store: store, 66 fileResolver: RadrootsAppleBackgroundTransferFileResolver( 67 roots: roots 68 )) 69 let pending = Task { await coordinator.complete( 70 identifier: request.identifier, 71 completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, 72 httpResult: successfulHTTPResult(), 73 bytesTransferred: 10, 74 totalBytesExpected: 10) 75 ) } 76 for _ in 0 ..< 100 { 77 if await coordinator.hasPendingReceipts { 78 break 79 } 80 try await Task.sleep(for: .milliseconds(10)) 81 } 82 let acknowledgement = RadrootsCompletionProbe() 83 await coordinator.handleBackgroundEvents(identifier: "locked.receipt") { acknowledgement.markCompleted() } 84 await coordinator.finishBackgroundEvents(identifier: "locked.receipt") 85 #expect(!acknowledgement.completed) 86 pending.cancel() 87 Darwin.close(descriptor) 88 await pending.value 89 #expect(acknowledgement.completed) 90 let restarted = RadrootsAppleBackgroundTransferStore(roots: roots) 91 #expect(try await restarted.loadSnapshots().first?.state == .awaitingVerification) 92 } 93 94 private actor ReceiptBarrierStore: RadrootsBackgroundTransferStore { 95 private let storage: RadrootsInMemoryBackgroundTransferStore 96 private var gate: CheckedContinuation<Void, Never>? 97 private(set) var entered = false 98 private(set) var terminalAttempts = 0 99 100 func waitUntilEntered() async throws { 101 for _ in 0 ..< 100 { 102 if entered { 103 return 104 } 105 try await Task.sleep(for: .milliseconds(10)) 106 } 107 throw RadrootsBackgroundTransferError.transferFailure 108 } 109 110 init(snapshot: RadrootsBackgroundTransferSnapshot) { 111 storage = .init(snapshots: [snapshot]) 112 } 113 114 func release() { 115 gate?.resume(); gate = nil 116 } 117 118 func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, 119 desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool 120 { 121 if desired.state == .awaitingVerification { 122 terminalAttempts += 1 123 if terminalAttempts == 1 { 124 entered = true 125 await withCheckedContinuation { gate = $0 } 126 let current = try #require(expected) 127 let progress = try RadrootsBackgroundTransferSnapshot(request: current.request, state: .running, 128 progress: RadrootsBackgroundTransferProgress( 129 bytesTransferred: 5, 130 totalBytesExpected: 10 131 ), 132 executionID: current.executionID) 133 try await storage.saveSnapshot(progress) 134 } else if terminalAttempts <= 3 { 135 throw RadrootsBackgroundTransferError.persistenceFailure 136 } 137 } 138 let exchanged = try await storage.compareExchangeSnapshot(expected: expected, desired: desired) 139 if desired.state == .awaitingVerification, terminalAttempts == 4, exchanged { 140 // The write took effect, but its caller lost the successful return. 141 throw RadrootsBackgroundTransferError.persistenceFailure 142 } 143 return exchanged 144 } 145 146 func withAdmission<Result: Sendable>(for identifier: RadrootsBackgroundTransferIdentifier, 147 operation: @escaping @Sendable () async throws -> Result) 148 async throws -> Result 149 { 150 try await storage.withAdmission(for: identifier, operation: operation) 151 } 152 153 func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { 154 try await storage.admissionIsActive(for: identifier) 155 } 156 157 func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { 158 try await storage.loadSnapshots() 159 } 160 161 func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { 162 try await storage.saveSnapshot(snapshot) 163 } 164 165 func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { 166 try await storage.removeSnapshot(for: identifier) 167 } 168 169 func removeAllSnapshots() async throws { 170 try await storage.removeAllSnapshots() 171 } 172 }