field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit eb6c1a71d1dceba2a9672d3b6612f3047821fc40
parent e5d2c60526112c39480e83103791dbddcdf101cf
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 04:14:06 +0000

fix(ios): reconcile durable media uploads

Diffstat:
MRadroots/Runtime/RadrootsAddMediaCoordinator.swift | 299++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MRadroots/State/RadrootsAddStore.swift | 16++++++++++++++--
MRadrootsTests/RadrootsAddStoreTests.swift | 659++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 918 insertions(+), 56 deletions(-)

diff --git a/Radroots/Runtime/RadrootsAddMediaCoordinator.swift b/Radroots/Runtime/RadrootsAddMediaCoordinator.swift @@ -10,6 +10,8 @@ struct RadrootsAddMediaSupport: Sendable, Equatable { struct RadrootsAddBackgroundUploadReceipt: Sendable, Equatable { let identifier: String + let draftID: String + let expectedRevision: UInt64 let statusCode: UInt16 let mediaType: String? let contentEncoding: String? @@ -26,6 +28,7 @@ protocol RadrootsAddMediaHandling: Sendable { media: RadrootsPreparedMedia ) async throws -> RadrootsAddBackgroundUploadReceipt func settleBackgroundUpload(identifier: String, accepted: Bool) async throws + func reconcileBackgroundUploads(drafts: [RadrootsDraftStatus]) async throws } extension RadrootsAddMediaHandling { @@ -41,6 +44,8 @@ extension RadrootsAddMediaHandling { } func settleBackgroundUpload(identifier _: String, accepted _: Bool) async throws {} + + func reconcileBackgroundUploads(drafts _: [RadrootsDraftStatus]) async throws {} } final class RadrootsOpenedMedia: @unchecked Sendable { @@ -215,68 +220,47 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { ? .publicHTTPS : .simulatorLoopbackHTTP, identifier: identifier ) - _ = try await transfer.enqueue(request) - return try await withTaskCancellationHandler { - while true { - try Task.checkCancellation() - guard let snapshot = try await transfer.snapshot(for: identifier) else { - throw RadrootsRuntimeFailure.local( - operation: "add.media.background", - code: "ios.add.background_upload_missing", - safeMessage: "The background photo upload could not be recovered." - ) - } - switch snapshot.state { - case .awaitingVerification: - guard let response = snapshot.response, - let statusCode = UInt16(exactly: response.statusCode), - let body = response.body - else { - throw RadrootsRuntimeFailure.local( - operation: "add.media.background", - code: "ios.add.background_response_invalid", - safeMessage: "The photo service returned an invalid response." - ) - } - return RadrootsAddBackgroundUploadReceipt( - identifier: identifier.rawValue, - statusCode: statusCode, - mediaType: response.mediaType, - contentEncoding: response.contentEncoding, - body: body - ) - case .failed, .interrupted, .cancelled, .expired: - throw RadrootsRuntimeFailure.local( - operation: "add.media.background", - code: snapshot.errorMessage ?? "ios.add.background_upload_failed", - safeMessage: "The background photo upload did not complete." - ) - case .completed: - throw RadrootsRuntimeFailure.local( - operation: "add.media.background", - code: "ios.add.background_upload_already_settled", - safeMessage: "The background photo upload was already settled." - ) - case .queued, .running: - try await Task.sleep(for: .milliseconds(100)) - } - } - } onCancel: { - Task { - try? await self.transfer.cancel(identifier) + let persisted = try await matchingPersistedUpload( + draftID: job.draft.id, + expectedRevision: job.draft.revision, + request: request + ) + let activeIdentifier: RadrootsBackgroundTransferIdentifier + if let persisted { + activeIdentifier = persisted.identifier + if [.failed, .interrupted, .cancelled, .expired].contains(persisted.state) { + let retry = try Self.replacingIdentifier(in: request, with: activeIdentifier) + _ = try await transfer.retry(retry) } + } else { + try Task.checkCancellation() + _ = try await transfer.enqueue(request) + activeIdentifier = identifier } + return try await receipt( + for: activeIdentifier, + draftID: job.draft.id, + expectedRevision: job.draft.revision, + request: request + ) } func settleBackgroundUpload(identifier: String, accepted: Bool) async throws { do { let value = try RadrootsBackgroundTransferIdentifier(identifier) + if let snapshot = try await transfer.snapshot(for: value), + snapshot.state == .completed, accepted + { + return + } try await transfer.settle( value, verification: accepted ? .accepted : .rejected(code: "background_transfer_rust_verification_failed") ) + } catch is CancellationError { + throw CancellationError() } catch { throw RadrootsRuntimeFailure.local( operation: "add.media.background.settle", @@ -286,6 +270,40 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { } } + func reconcileBackgroundUploads(drafts: [RadrootsDraftStatus]) async throws { + var draftsByID: [String: RadrootsDraftStatus] = [:] + for draft in drafts { + guard draftsByID.updateValue(draft, forKey: draft.id) == nil else { + throw Self.failure( + code: "ios.add.background_draft_ambiguous", + message: "The persisted draft inventory is ambiguous." + ) + } + } + for snapshot in try await transfer.snapshots() + where snapshot.state == .awaitingVerification { + try Task.checkCancellation() + guard let identity = Self.transferIdentity(snapshot.identifier), + let draft = draftsByID[identity.draftID] + else { continue } + guard draft.revision > identity.revision, + let form = draft.form, + let media = form.media.first(where: { + $0.remoteURL == snapshot.request.remoteURL.absoluteString + && $0.sha256 == snapshot.request.expectedSourceSHA256 + }), + draft.media.contains(where: { $0.url == media.remoteURL && $0.stage == .verified }), + try Self.persistedRequestMatchesMedia(snapshot.request, media: media) + else { + throw Self.failure( + code: "ios.add.background_upload_mismatch", + message: "The persisted photo upload does not match its verified draft." + ) + } + try await transfer.settle(snapshot.identifier, verification: .accepted) + } + } + private func prepare(_ asset: RadrootsMediaAsset) async throws -> RadrootsPreparedMedia { let prepared = try await preparer.prepareImage( RadrootsAppleImagePreparationRequest(source: .file(asset.file)) @@ -310,6 +328,189 @@ actor RadrootsAddMediaCoordinator: RadrootsAddMediaHandling { "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)" ) } + + private func matchingPersistedUpload( + draftID: String, + expectedRevision: UInt64, + request: RadrootsBackgroundTransferRequest + ) async throws -> RadrootsBackgroundTransferSnapshot? { + try Task.checkCancellation() + let snapshots = try await transfer.snapshots() + let prefix = "radroots.add.\(draftID)." + let owned = snapshots.filter { $0.identifier.rawValue.hasPrefix(prefix) } + let parsed = try owned.map { snapshot in + guard let identity = Self.transferIdentity(snapshot.identifier), + identity.draftID == draftID, + identity.revision <= expectedRevision + else { + throw Self.failure( + code: "ios.add.background_upload_mismatch", + message: "The persisted photo upload identity is invalid." + ) + } + return snapshot + } + let candidates = parsed.filter { snapshot in + return snapshot.state != .completed + || Self.persistedRequestMatches(snapshot.request, request: request) + } + try Task.checkCancellation() + let active = candidates.filter { $0.state != .completed } + guard active.count <= 1 else { + throw Self.failure( + code: "ios.add.background_upload_ambiguous", + message: "The persisted photo upload state is ambiguous." + ) + } + if let candidate = active.first { + guard Self.persistedRequestMatches(candidate.request, request: request) else { + throw Self.failure( + code: "ios.add.background_upload_mismatch", + message: "The persisted photo upload does not match the authorized upload." + ) + } + return candidate + } + let completed = candidates.filter { + $0.state == .completed && Self.persistedRequestMatches($0.request, request: request) + } + guard completed.count <= 1 else { + throw Self.failure( + code: "ios.add.background_upload_ambiguous", + message: "The persisted photo upload state is ambiguous." + ) + } + return completed.first + } + + private func receipt( + for identifier: RadrootsBackgroundTransferIdentifier, + draftID: String, + expectedRevision: UInt64, + request: RadrootsBackgroundTransferRequest + ) async throws -> RadrootsAddBackgroundUploadReceipt { + while true { + try Task.checkCancellation() + guard let snapshot = try await transfer.snapshot(for: identifier) else { + throw Self.failure( + code: "ios.add.background_upload_missing", + message: "The background photo upload could not be recovered." + ) + } + guard Self.persistedRequestMatches(snapshot.request, request: request) else { + throw Self.failure( + code: "ios.add.background_upload_mismatch", + message: "The persisted photo upload no longer matches its request." + ) + } + switch snapshot.state { + case .awaitingVerification, .completed: + try Task.checkCancellation() + guard let response = snapshot.response, + let statusCode = UInt16(exactly: response.statusCode), + let body = response.body + else { + throw Self.failure( + code: "ios.add.background_response_invalid", + message: "The photo service returned an invalid response." + ) + } + return RadrootsAddBackgroundUploadReceipt( + identifier: identifier.rawValue, + draftID: draftID, + expectedRevision: expectedRevision, + statusCode: statusCode, + mediaType: response.mediaType, + contentEncoding: response.contentEncoding, + body: body + ) + case .failed, .interrupted, .cancelled, .expired: + throw Self.failure( + code: snapshot.errorMessage ?? "ios.add.background_upload_failed", + message: "The background photo upload did not complete." + ) + case .queued, .running: + try await Task.sleep(for: .milliseconds(100)) + } + } + } + + private static func persistedRequestMatches( + _ persisted: RadrootsBackgroundTransferRequest, + request: RadrootsBackgroundTransferRequest + ) -> Bool { + persisted.headers.isEmpty && persisted.metadata.isEmpty + && persisted.remoteURL == request.remoteURL + && persisted.method == request.method + && persisted.operation == request.operation + && persisted.networkPolicy == request.networkPolicy + && persisted.responsePolicy == request.responsePolicy + && persisted.expectedSourceSHA256 == request.expectedSourceSHA256 + && persisted.maximumTransferBytes == request.maximumTransferBytes + } + + private static func persistedRequestMatchesMedia( + _ persisted: RadrootsBackgroundTransferRequest, + media: RadrootsPreparedMedia + ) throws -> Bool { + guard let remoteURL = media.remoteURL.flatMap(URL.init(string:)), + let byteSize = Int(exactly: media.byteSize) + else { return false } + let blob = try RadrootsStagedBlobReference( + blobID: media.sha256, + sizeBytes: byteSize, + mediaType: media.mediaType, + filenameHint: "\(media.sha256).png" + ) + let responsePolicy = try RadrootsBackgroundTransferResponsePolicy.boundedJSON() + return persisted.headers.isEmpty && persisted.metadata.isEmpty + && persisted.remoteURL == remoteURL + && persisted.method == .put + && persisted.operation == .upload(source: .stagedBlob(blob)) + && persisted.networkPolicy + == (remoteURL.scheme?.lowercased() == "https" ? .publicHTTPS : .simulatorLoopbackHTTP) + && persisted.responsePolicy == responsePolicy + && persisted.expectedSourceSHA256 == media.sha256 + && persisted.maximumTransferBytes + == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes + } + + private static func replacingIdentifier( + in request: RadrootsBackgroundTransferRequest, + with identifier: RadrootsBackgroundTransferIdentifier + ) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: identifier, + remoteURL: request.remoteURL, + method: request.method, + operation: request.operation, + headers: request.headers, + metadata: request.metadata, + networkPolicy: request.networkPolicy, + responsePolicy: request.responsePolicy, + expectedSourceSHA256: request.expectedSourceSHA256, + maximumTransferBytes: request.maximumTransferBytes + ) + } + + private static func transferIdentity( + _ identifier: RadrootsBackgroundTransferIdentifier + ) -> (draftID: String, revision: UInt64)? { + let components = identifier.rawValue.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 5, + components[0] == "radroots", + components[1] == "add", + components[2].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil, + let revision = UInt64(components[3]), + String(revision) == components[3], + components[4].range(of: "^[0-9a-f]{32}$", options: .regularExpression) != nil + else { return nil } + return (String(components[2]), revision) + } + + private static func failure(code: String, message: String) -> RadrootsRuntimeFailure { + .local(operation: "add.media.background", code: code, safeMessage: message) + } } #if DEBUG diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -111,6 +111,9 @@ final class RadrootsAddStore: ObservableObject { supportResult ) guard requestedGeneration == generation else { return } + try await media?.reconcileBackgroundUploads(drafts: loadedDrafts) + try Task.checkCancellation() + guard requestedGeneration == generation else { return } schemas = loadedSchemas drafts = Self.sorted(loadedDrafts) mediaSupport = loadedSupport @@ -470,8 +473,8 @@ final class RadrootsAddStore: ObservableObject { do { status = try await runtimeClient.completeAddMediaBackground( input: RadrootsNativeUploadCompletion( - draftID: job.draft.id, - expectedRevision: job.draft.revision, + draftID: receipt.draftID, + expectedRevision: receipt.expectedRevision, media: handle, statusCode: receipt.statusCode, responseMediaType: receipt.mediaType, @@ -479,7 +482,16 @@ final class RadrootsAddStore: ObservableObject { responseBody: receipt.body ) ) + } catch is CancellationError { + // Rust completion may already be durable. Leave the receipt awaiting + // verification so relaunch can reconcile the unknown outcome. + throw CancellationError() } catch { + if Self.failure(for: error)?.code == "ios.runtime.cancelled" { + // The bounded runtime client cannot prove whether a cancelled FFI + // completion became durable. Preserve the receipt for reconciliation. + throw CancellationError() + } try? await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: false) throw error } diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -1,8 +1,226 @@ +import CryptoKit +import RadrootsKit import XCTest @testable import RadrootsApp final class RadrootsAddStoreTests: XCTestCase { + func testBackgroundUploadResumesReceiptRetriesAndCompletedStateWithoutDuplicateEnqueue() + async throws + { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness() + let coordinator = fixture.coordinator(transfer: transfer) + + let first = try await coordinator.uploadInBackground( + job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32)), + media: fixture.media + ) + XCTAssertEqual(first.expectedRevision, 2) + var counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + + let replayed = try await coordinator.uploadInBackground( + job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), + media: fixture.media + ) + XCTAssertEqual(replayed.identifier, first.identifier) + XCTAssertEqual(replayed.expectedRevision, 3) + counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + + try await coordinator.settleBackgroundUpload(identifier: replayed.identifier, accepted: true) + let completed = try await coordinator.uploadInBackground( + job: fixture.job(revision: 4, operation: String(repeating: "c", count: 32)), + media: fixture.media + ) + XCTAssertEqual(completed.identifier, first.identifier) + counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + try await coordinator.settleBackgroundUpload(identifier: completed.identifier, accepted: true) + counts = await transfer.counts + XCTAssertEqual(counts.acceptedSettlement, 1) + + try await transfer.setState(.interrupted) + let retried = try await coordinator.uploadInBackground( + job: fixture.job(revision: 5, operation: String(repeating: "d", count: 32)), + media: fixture.media + ) + XCTAssertEqual(retried.identifier, first.identifier) + counts = await transfer.counts + XCTAssertEqual(counts.retry, 1) + XCTAssertEqual(counts.enqueue, 1) + } + + func testBackgroundUploadRejectsMismatchedAndAmbiguousPersistedRequests() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness() + let coordinator = fixture.coordinator(transfer: transfer) + let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) + let request = try fixture.request(job: job) + let mismatched = try fixture.request( + job: job, + remoteURL: "http://127.0.0.1:3000/not-the-authorized-object.png" + ) + try await transfer.seed(request: mismatched, state: .running) + + do { + _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) + XCTFail("expected persisted-request mismatch") + } catch let failure as RadrootsRuntimeFailure { + XCTAssertEqual(failure.code, "ios.add.background_upload_mismatch") + } + var counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 0) + + await transfer.removeAll() + let future = try fixture.request( + job: fixture.job(revision: 3, operation: String(repeating: "f", count: 32)) + ) + try await transfer.seed(request: future, state: .running) + do { + _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) + XCTFail("expected future transfer identity rejection") + } catch let failure as RadrootsRuntimeFailure { + XCTAssertEqual(failure.code, "ios.add.background_upload_mismatch") + } + + await transfer.removeAll() + try await transfer.seed(request: request, state: .running) + let second = try fixture.request( + job: fixture.job(revision: 1, operation: String(repeating: "e", count: 32)) + ) + try await transfer.seed(request: second, state: .running) + do { + _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) + XCTFail("expected ambiguous persisted state") + } catch let failure as RadrootsRuntimeFailure { + XCTAssertEqual(failure.code, "ios.add.background_upload_ambiguous") + } + counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 0) + } + + func testBackgroundUploadCancellationLeavesDurableWorkForRelaunch() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness(enqueueState: .running) + let coordinator = fixture.coordinator(transfer: transfer) + let firstJob = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) + + let task = Task { + try await coordinator.uploadInBackground(job: firstJob, media: fixture.media) + } + let reachedSnapshot = await Self.waitUntil { await transfer.snapshotCount > 0 } + XCTAssertTrue(reachedSnapshot) + task.cancel() + do { + _ = try await task.value + XCTFail("expected cancellation") + } catch is CancellationError {} + + var counts = await transfer.counts + XCTAssertEqual(counts.cancel, 0) + XCTAssertEqual(counts.enqueue, 1) + try await transfer.setState(.awaitingVerification) + let replayed = try await coordinator.uploadInBackground( + job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), + media: fixture.media + ) + XCTAssertEqual(replayed.identifier, firstJob.transferIdentifier) + counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + } + + func testBackgroundUploadCancellationBeforeEnqueueHasNoTransferSideEffect() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness(pause: .discovery) + let coordinator = fixture.coordinator(transfer: transfer) + let task = Task { + try await coordinator.uploadInBackground( + job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32)), + media: fixture.media + ) + } + let reachedDiscovery = await Self.waitUntil { await transfer.isPaused } + XCTAssertTrue(reachedDiscovery) + task.cancel() + do { + _ = try await task.value + XCTFail("expected cancellation") + } catch is CancellationError {} + + let counts = await transfer.counts + let state = await transfer.state + XCTAssertEqual(counts.enqueue, 0) + XCTAssertNil(state) + } + + func testBackgroundUploadCancellationAtReceiptBoundaryReplaysOnRelaunch() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness(pause: .snapshot) + let coordinator = fixture.coordinator(transfer: transfer) + let firstJob = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) + let task = Task { + try await coordinator.uploadInBackground(job: firstJob, media: fixture.media) + } + let reachedReceipt = await Self.waitUntil { await transfer.isPaused } + XCTAssertTrue(reachedReceipt) + task.cancel() + do { + _ = try await task.value + XCTFail("expected cancellation") + } catch is CancellationError {} + + var counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + XCTAssertEqual(counts.cancel, 0) + await transfer.releasePause() + let replayed = try await coordinator.uploadInBackground( + job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), + media: fixture.media + ) + XCTAssertEqual(replayed.identifier, firstJob.transferIdentifier) + counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 1) + } + + func testVerifiedRustDraftReconcilesAwaitingReceiptAfterRelaunch() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness() + let coordinator = fixture.coordinator(transfer: transfer) + let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) + try await transfer.seed(request: fixture.request(job: job), state: .awaitingVerification) + + try await coordinator.reconcileBackgroundUploads( + drafts: [fixture.draft(revision: 3, stage: .verified)] + ) + + let counts = await transfer.counts + let state = await transfer.state + XCTAssertEqual(counts.acceptedSettlement, 1) + XCTAssertEqual(state, .completed) + } + + func testBackgroundReconciliationRejectsDuplicateDraftInventory() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let coordinator = fixture.coordinator(transfer: BackgroundTransferHarness()) + let draft = fixture.draft(revision: 3, stage: .verified) + + do { + try await coordinator.reconcileBackgroundUploads(drafts: [draft, draft]) + XCTFail("expected duplicate draft rejection") + } catch let failure as RadrootsRuntimeFailure { + XCTAssertEqual(failure.code, "ios.add.background_draft_ambiguous") + } + } + @MainActor func testAllFiveFormsCompleteThroughRuntimeAndSubmittedSnapshotsFreeze() async throws { let backend = AddBackend() @@ -157,6 +375,65 @@ final class RadrootsAddStoreTests: XCTestCase { } @MainActor + func testCancellationAfterDurableRustVerificationDoesNotRejectReceipt() async throws { + let backend = AddBackend(delayAfterBackgroundCompletion: true) + let client = try await Self.startedClient(backend) + let media = AddMediaHarness() + let store = RadrootsAddStore(runtimeClient: client, media: media) + await store.configure(snapshot: backend.snapshot()) + await store.start() + store.selectType(.createPhotoUpdate) + store.updateForm(\.content, "Unknown Rust completion outcome") + await store.importPhotos() + + let submit = Task { await store.submit() } + let persistedCompletion = await Self.waitUntil { + await backend.didPersistBackgroundCompletion() + } + XCTAssertTrue(persistedCompletion) + store.stop() + await submit.value + + var settlements = await media.settlementValues() + XCTAssertEqual(settlements, []) + await store.start() + XCTAssertEqual(store.drafts.first?.state, .readyToSign) + XCTAssertEqual(store.drafts.first?.media.first?.stage, .verified) + settlements = await media.settlementValues() + let reconciliations = await media.reconciliationCount() + XCTAssertEqual(settlements, []) + XCTAssertEqual(reconciliations, 2) + _ = try await client.stop() + } + + @MainActor + func testCancellationDuringSettlementReconcilesVerifiedDraftOnRelaunch() async throws { + let backend = AddBackend() + let client = try await Self.startedClient(backend) + let media = AddMediaHarness(delaySettlement: true) + let store = RadrootsAddStore(runtimeClient: client, media: media) + await store.configure(snapshot: backend.snapshot()) + await store.start() + store.selectType(.createPhotoUpdate) + store.updateForm(\.content, "Unknown settlement outcome") + await store.importPhotos() + + let submit = Task { await store.submit() } + let reachedSettlement = await Self.waitUntil { await media.didBeginSettlement() } + XCTAssertTrue(reachedSettlement) + store.stop() + await submit.value + + let settlements = await media.settlementValues() + XCTAssertEqual(settlements, []) + await store.start() + XCTAssertEqual(store.drafts.first?.media.first?.stage, .verified) + let reconciliations = await media.reconciliationCount() + XCTAssertEqual(reconciliations, 2) + _ = try await client.stop() + } + + @MainActor func testPhotoServiceProbeSurfacesCanonicalEvidence() async throws { let backend = AddBackend() let client = try await Self.startedClient(backend) @@ -292,6 +569,16 @@ final class RadrootsAddStoreTests: XCTestCase { } } + private static func waitUntil( + _ predicate: @escaping @Sendable () async -> Bool + ) async -> Bool { + for _ in 0..<1_000 { + if await predicate() { return true } + try? await Task.sleep(nanoseconds: 1_000_000) + } + return false + } + private static func startedClient(_ backend: AddBackend) async throws -> RadrootsRuntimeClient { let client = RadrootsRuntimeClient { _ in await RadrootsRuntimeBackendStart(backend: backend, snapshot: backend.snapshot()) @@ -374,7 +661,11 @@ private struct AddSigner: RadrootsRuntimeSigner { private actor AddMediaHarness: RadrootsAddMediaHandling { private let delayFirstUpload: Bool + private let delaySettlement: Bool private var uploadAttempts = 0 + private var settlementStarted = false + private var settlements: [Bool] = [] + private var reconciliations = 0 private let item = RadrootsPreparedMedia( opaqueReference: "media:\(String(repeating: "0", count: 64))", remoteURL: nil, @@ -387,8 +678,9 @@ private actor AddMediaHarness: RadrootsAddMediaHandling { preparedAtUnixSeconds: 1_800_000_000 ) - init(delayFirstUpload: Bool = false) { + init(delayFirstUpload: Bool = false, delaySettlement: Bool = false) { self.delayFirstUpload = delayFirstUpload + self.delaySettlement = delaySettlement } func support() -> RadrootsAddMediaSupport { @@ -420,12 +712,38 @@ private actor AddMediaHarness: RadrootsAddMediaHandling { } return RadrootsAddBackgroundUploadReceipt( identifier: "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)", + draftID: job.draft.id, + expectedRevision: job.draft.revision, statusCode: 200, mediaType: "application/json", contentEncoding: nil, body: Data("{}".utf8) ) } + + func settleBackgroundUpload(identifier _: String, accepted: Bool) async throws { + settlementStarted = true + if delaySettlement { + try await Task.sleep(nanoseconds: 50_000_000) + } + settlements.append(accepted) + } + + func reconcileBackgroundUploads(drafts _: [RadrootsDraftStatus]) { + reconciliations += 1 + } + + func didBeginSettlement() -> Bool { + settlementStarted + } + + func settlementValues() -> [Bool] { + settlements + } + + func reconciliationCount() -> Int { + reconciliations + } } private enum AddDelayPhase: String, CaseIterable { @@ -439,22 +757,26 @@ private actor AddBackend: RadrootsRuntimeBackend { private let saveFailure: RadrootsRuntimeFailure? private let includeWritableRelay: Bool private let delayedPhase: AddDelayPhase? + private let delayAfterBackgroundCompletion: Bool private var values: [String: RadrootsDraftStatus] = [:] private var uploadedMedia = false private var revisionPlans = 0 private var delayConsumed = false + private var backgroundCompletionPersisted = false private var closed = false init( advanceOffline: Bool = false, saveFailure: RadrootsRuntimeFailure? = nil, includeWritableRelay: Bool = true, - delayedPhase: AddDelayPhase? = nil + delayedPhase: AddDelayPhase? = nil, + delayAfterBackgroundCompletion: Bool = false ) { self.advanceOffline = advanceOffline self.saveFailure = saveFailure self.includeWritableRelay = includeWritableRelay self.delayedPhase = delayedPhase + self.delayAfterBackgroundCompletion = delayAfterBackgroundCompletion } func snapshot() -> RadrootsRuntimeSnapshot { @@ -740,7 +1062,7 @@ private actor AddBackend: RadrootsRuntimeBackend { func uploadAddMediaIntent(input: RadrootsBlossomUploadIntent) throws -> RadrootsDraftStatus { uploadedMedia = true - let current = try draftStatus(id: input.draftID) + let current = try storedDraft(id: input.draftID) let verified = current.media.map { RadrootsDraftMediaStatus( url: $0.url, @@ -787,9 +1109,9 @@ private actor AddBackend: RadrootsRuntimeBackend { func completeAddMediaBackground( input: RadrootsNativeUploadCompletion - ) throws -> RadrootsDraftStatus { + ) async throws -> RadrootsDraftStatus { uploadedMedia = true - let current = try draftStatus(id: input.draftID) + let current = try storedDraft(id: input.draftID) let verified = current.media.map { RadrootsDraftMediaStatus( url: $0.url, @@ -809,9 +1131,17 @@ private actor AddBackend: RadrootsRuntimeBackend { media: verified ) values[current.id] = value + backgroundCompletionPersisted = true + if delayAfterBackgroundCompletion { + try await Task.sleep(nanoseconds: 50_000_000) + } return value } + func didPersistBackgroundCompletion() -> Bool { + backgroundCompletionPersisted + } + func didUploadMedia() -> Bool { uploadedMedia } @@ -961,3 +1291,322 @@ private actor AddBackend: RadrootsRuntimeBackend { private actor AddSubscriptionToken: RadrootsRuntimeSubscriptionToken { func cancel() {} } + +private final class BackgroundUploadFixture: @unchecked Sendable { + let draftID = String(repeating: "1", count: 32) + let media: RadrootsPreparedMedia + private let root: URL + private let roots: RadrootsAppleFileRoots + + init() throws { + let bytes = Data("radroots-background-upload".utf8) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-background-tests-\(UUID().uuidString)", isDirectory: true) + roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.background-tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) + ) + try FileManager.default.createDirectory( + at: roots.stagedBlobsRoot, + withIntermediateDirectories: true + ) + try bytes.write(to: roots.stagedBlobsRoot.appendingPathComponent(digest)) + media = RadrootsPreparedMedia( + opaqueReference: "media:\(digest)", + remoteURL: "http://127.0.0.1:3000/\(digest).png", + sha256: digest, + mediaType: "image/png", + byteSize: UInt64(bytes.count), + width: 2, + height: 2, + alt: "Background upload", + preparedAtUnixSeconds: 1_800_000_000 + ) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + func coordinator(transfer: any RadrootsBackgroundTransfer) -> RadrootsAddMediaCoordinator { + RadrootsAddMediaCoordinator( + roots: roots, + picker: BackgroundMediaPicker(), + preparer: RadrootsAppleMediaPreparer(roots: roots), + transfer: transfer + ) + } + + func job(revision: UInt64, operation: String) -> RadrootsNativeUploadJob { + RadrootsNativeUploadJob( + operationID: operation, + draft: draft(revision: revision, stage: .uploading), + remoteURL: media.remoteURL!, + authorizationHeader: "Nostr test-authorization", + expectedSHA256: media.sha256, + mediaType: media.mediaType, + byteSize: media.byteSize + ) + } + + func draft(revision: UInt64, stage: RadrootsDraftMediaStage) -> RadrootsDraftStatus { + var form = RadrootsAddForm.empty(.createPhotoUpdate) + form.content = "Background transfer" + form.media = [media] + return RadrootsDraftStatus( + id: draftID, + revision: revision, + authorPublicKey: String(repeating: "a", count: 64), + kind: .add, + commandType: .createPhotoUpdate, + form: form, + state: stage == .verified ? .readyToSign : .mediaUploading, + cardID: String(repeating: "c", count: 64), + operationID: String(repeating: "d", count: 32), + createdAtUnixMilliseconds: 1_800_000_000_000, + updatedAtUnixMilliseconds: 1_800_000_000_001, + media: [ + RadrootsDraftMediaStatus( + url: media.remoteURL!, + stage: stage, + uploadAttempts: stage == .verified ? 1 : 0, + verifiedAtUnixMilliseconds: stage == .verified ? 1_800_000_000_001 : nil, + possibleOrphan: false, + orphanReasonCode: nil, + orphanRecordedAtUnixMilliseconds: nil + ) + ], + settlement: nil, + isRevision: false + ) + } + + func request( + job: RadrootsNativeUploadJob, + remoteURL: String? = nil + ) throws -> RadrootsBackgroundTransferRequest { + let blob = try RadrootsStagedBlobReference( + blobID: media.sha256, + sizeBytes: Int(media.byteSize), + mediaType: media.mediaType, + filenameHint: "\(media.sha256).png" + ) + return try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier(job.transferIdentifier), + remoteURL: URL(string: remoteURL ?? job.remoteURL)!, + method: .put, + operation: .upload(source: .stagedBlob(blob)), + headers: [:], + metadata: [:], + networkPolicy: .simulatorLoopbackHTTP, + responsePolicy: .boundedJSON(), + expectedSourceSHA256: media.sha256 + ) + } +} + +extension RadrootsNativeUploadJob { + fileprivate var transferIdentifier: String { + "radroots.add.\(draft.id).\(draft.revision).\(operationID)" + } +} + +private struct BackgroundMediaPicker: RadrootsMediaPicker { + func currentSupport() async throws -> RadrootsMediaPickerSupport { + try RadrootsMediaPickerSupport( + importAvailable: false, + cameraCaptureAvailable: false, + supportedImportKinds: [], + supportedCaptureKinds: [], + multipleSelectionSupported: false + ) + } + + func importMedia(_: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { + throw RadrootsCaptureIntakeError.unavailable("not used") + } + + func captureMedia(_: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { + throw RadrootsCaptureIntakeError.unavailable("not used") + } +} + +private actor BackgroundTransferHarness: RadrootsBackgroundTransfer { + private var values: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] = + [:] + private let enqueueState: RadrootsBackgroundTransferState + private let pause: BackgroundTransferPause + private var pauseReleased: Bool + private(set) var isPaused = false + private(set) var enqueueCount = 0 + private(set) var retryCount = 0 + private(set) var cancelCount = 0 + private(set) var acceptedSettlementCount = 0 + private(set) var snapshotCount = 0 + + init( + enqueueState: RadrootsBackgroundTransferState = .awaitingVerification, + pause: BackgroundTransferPause = .none + ) { + self.enqueueState = enqueueState + self.pause = pause + pauseReleased = pause == .none + } + + var state: RadrootsBackgroundTransferState? { + values.values.first?.state + } + + var counts: (enqueue: Int, retry: Int, cancel: Int, acceptedSettlement: Int) { + (enqueueCount, retryCount, cancelCount, acceptedSettlementCount) + } + + func seed( + request: RadrootsBackgroundTransferRequest, + state: RadrootsBackgroundTransferState + ) throws { + values[request.identifier] = try snapshot(request: request, state: state) + } + + func setState(_ state: RadrootsBackgroundTransferState) throws { + for (identifier, value) in values { + values[identifier] = try snapshot(request: value.request, state: state) + } + } + + func removeAll() { + values.removeAll() + } + + func releasePause() { + pauseReleased = true + } + + func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + enqueueCount += 1 + values[request.identifier] = try snapshot( + request: persisted(request), + state: enqueueState + ) + return RadrootsBackgroundTransferHandle(request: request) + } + + func retry(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + retryCount += 1 + values[request.identifier] = try snapshot( + request: persisted(request), + state: .awaitingVerification + ) + return RadrootsBackgroundTransferHandle(request: request) + } + + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + cancelCount += 1 + if let value = values[identifier] { + values[identifier] = try snapshot(request: value.request, state: .cancelled) + } + } + + func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + if let value = values[identifier] { + values[identifier] = try snapshot(request: value.request, state: .expired) + } + } + + func settle( + _ identifier: RadrootsBackgroundTransferIdentifier, + verification: RadrootsBackgroundTransferVerification + ) async throws { + guard let value = values[identifier] else { + throw RadrootsBackgroundTransferError.transferFailure("missing snapshot") + } + switch verification { + case .accepted: + acceptedSettlementCount += 1 + values[identifier] = try snapshot(request: value.request, state: .completed) + case .rejected(let code): + values[identifier] = try RadrootsBackgroundTransferSnapshot( + request: value.request, + state: .failed, + errorMessage: code + ) + } + } + + func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? + { + snapshotCount += 1 + try await waitIfPaused(at: .snapshot) + return values[identifier] + } + + func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + try await waitIfPaused(at: .discovery) + return values.values.sorted { $0.identifier < $1.identifier } + } + + func handleEventsForBackgroundURLSession( + identifier _: String, + completionHandler: @escaping @Sendable () -> Void + ) async { + completionHandler() + } + + private func persisted( + _ request: RadrootsBackgroundTransferRequest + ) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: request.identifier, + remoteURL: request.remoteURL, + method: request.method, + operation: request.operation, + headers: [:], + metadata: [:], + networkPolicy: request.networkPolicy, + responsePolicy: request.responsePolicy, + expectedSourceSHA256: request.expectedSourceSHA256, + maximumTransferBytes: request.maximumTransferBytes + ) + } + + private func snapshot( + request: RadrootsBackgroundTransferRequest, + state: RadrootsBackgroundTransferState + ) throws -> RadrootsBackgroundTransferSnapshot { + try RadrootsBackgroundTransferSnapshot( + request: request, + state: state, + response: [.awaitingVerification, .completed].contains(state) + ? RadrootsBackgroundTransferResponse( + statusCode: 200, + mediaType: "application/json", + body: Data("{}".utf8) + ) : nil, + possibleRemoteOrphan: false, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + } + + private func waitIfPaused(at point: BackgroundTransferPause) async throws { + guard pause == point, !pauseReleased else { return } + isPaused = true + defer { isPaused = false } + while !pauseReleased { + try await Task.sleep(nanoseconds: 1_000_000) + } + } +} + +private enum BackgroundTransferPause: Sendable { + case none + case discovery + case snapshot +}