commit 75a4a63149adb55b6a07f94686ef74b5446f1136
parent bf6eff1ebb4c854983a651b333d00d622714199b
Author: triesap <tyson@radroots.org>
Date: Sun, 20 Sep 2026 14:31:15 +0000
transfer: bound native task inventory queries
- Preserve unknown outcomes when OS task callbacks fail to arrive
- Resolve cancellation and duplicate callbacks exactly once
- Reject malformed or excessive task inventories before retry
- Verify native receipt retention on macOS and hosted iOS
Diffstat:
4 files changed, 242 insertions(+), 17 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift
@@ -80,14 +80,14 @@ public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
},
cancel: { identifier in
#if targetEnvironment(simulator)
- await simulatorSession.cancel(identifier)
+ try await simulatorSession.cancel(identifier)
#endif
- await session.cancel(identifier)
+ try await session.cancel(identifier)
},
activeTransferIdentifiers: {
- var identifiers = await session.activeTransferIdentifiers()
+ var identifiers = try await session.activeTransferIdentifiers()
#if targetEnvironment(simulator)
- await identifiers.formUnion(simulatorSession.activeTransferIdentifiers())
+ try await identifiers.formUnion(simulatorSession.activeTransferIdentifiers())
#endif
return identifiers
},
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift
@@ -86,8 +86,9 @@ import Foundation
}
private func alreadyAdmitted(_ identifier: RadrootsBackgroundTransferIdentifier,
- executionID: UUID) async throws -> Bool {
- let tasks = await allTasks()
+ executionID: UUID) async throws -> Bool
+ {
+ let tasks = try await allTasks()
let existingTasks = tasks.filter {
RadrootsBackgroundURLTaskDescriptor(taskDescription: $0.taskDescription)?.identifier == identifier
}
@@ -101,23 +102,23 @@ import Foundation
return false
}
- func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
if admissions.contains(identifier) {
cancelledAdmissions.insert(identifier)
}
- let tasks = await allTasks()
+ let tasks = try await allTasks()
for task in tasks
where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
- == identifier {
+ == identifier
+ {
task.cancel()
}
}
- func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
- let tasks = await allTasks()
- let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
- RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier
- }
+ func activeTransferIdentifiers() async throws -> Set<RadrootsBackgroundTransferIdentifier> {
+ let tasks = try await allTasks()
+ let identifiers = try RadrootsBackgroundTaskQuery.descriptors(tasks.map(\.taskDescription))
+ .map(\.identifier)
return Set(identifiers).union(sessionDelegate?.callbacks.pendingIdentifiers ?? [])
}
@@ -130,10 +131,15 @@ import Foundation
)
}
- private func allTasks() async -> [URLSessionTask] {
- await withCheckedContinuation { continuation in
- backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) }
+ private func allTasks() async throws -> [URLSessionTask] {
+ let session = backgroundSession()
+ let tasks = try await RadrootsBackgroundTaskQuery.read { session.getAllTasks(completionHandler: $0) }
+ guard tasks.count <= RadrootsBackgroundTaskQuery.maximumTasks else {
+ throw RadrootsBackgroundTransferError.transferFailure
}
+ _ = try RadrootsBackgroundTaskQuery.descriptors(tasks.map(\.taskDescription))
+ try Task.checkCancellation()
+ return tasks
}
private func backgroundSession() -> URLSession {
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTaskQuery.swift b/Sources/RadrootsKit/RadrootsBackgroundTaskQuery.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+/// A failed local OS inventory is unknown, never an empty successful inventory.
+enum RadrootsBackgroundTaskQuery {
+ static let maximumTasks = 4096
+ static func read<Value: Sendable>(
+ timeoutNanoseconds: UInt64 = 5_000_000_000,
+ _ query: (@escaping @Sendable (Value) -> Void) -> Void
+ ) async throws -> Value {
+ guard timeoutNanoseconds > 0, timeoutNanoseconds <= 5_000_000_000 else {
+ throw RadrootsBackgroundTransferError.invalidRequest
+ }
+ let state = RadrootsBackgroundTaskQueryState<Value>()
+ let timer = Task {
+ do { try await Task.sleep(nanoseconds: timeoutNanoseconds) } catch { return }
+ state.resolve(.failure(.transferFailure))
+ }
+ defer { timer.cancel() }
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ guard state.install(continuation) else { return }
+ query { [weak state] value in state?.resolve(.success(value)) }
+ }
+ } onCancel: {
+ state.resolve(.failure(.transferFailure))
+ }
+ }
+
+ static func descriptors(_ descriptions: [String?]) throws -> [RadrootsBackgroundURLTaskDescriptor] {
+ // Bound local parsing while retaining large recovered inventories. Refusal
+ // preserves all tasks and receipts and grants no new enqueue authority.
+ guard descriptions.count <= maximumTasks else { throw RadrootsBackgroundTransferError.transferFailure }
+ return try descriptions.map { description in
+ guard let description, description.utf8.count <= 256,
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(taskDescription: description)
+ else { throw RadrootsBackgroundTransferError.transferFailure }
+ return descriptor
+ }
+ }
+}
+
+/// The lock protects every field. Only the first result wins; foreign callbacks
+/// and continuation resumption always execute after releasing the lock.
+private final class RadrootsBackgroundTaskQueryState<Value: Sendable>: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation<Value, any Error>?
+ private var resolved = false
+ private var earlyResult: Result<Value, RadrootsBackgroundTransferError>?
+
+ func install(_ continuation: CheckedContinuation<Value, any Error>) -> Bool {
+ lock.lock()
+ if let earlyResult {
+ self.earlyResult = nil
+ lock.unlock()
+ continuation.resume(with: earlyResult.mapError { $0 as any Error })
+ return false
+ }
+ self.continuation = continuation
+ lock.unlock()
+ return true
+ }
+
+ func resolve(_ result: Result<Value, RadrootsBackgroundTransferError>) {
+ lock.lock()
+ guard !resolved else { lock.unlock(); return }
+ resolved = true
+ let pending = continuation
+ continuation = nil
+ if pending == nil {
+ earlyResult = result
+ }
+ lock.unlock()
+ pending?.resume(with: result.mapError { $0 as any Error })
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTaskQueryTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTaskQueryTests.swift
@@ -0,0 +1,144 @@
+import Foundation
+@testable import RadrootsKit
+import RadrootsKitTesting
+import Testing
+
+@Test func backgroundTaskQueryReturnsFirstReplyAndIgnoresDuplicates() async throws {
+ let value: Int = try await RadrootsBackgroundTaskQuery.read { reply in
+ reply(7)
+ reply(9)
+ }
+ #expect(value == 7)
+}
+
+@Test func backgroundTaskQueryLostCallbackTimesOutAndIgnoresLateReply() async {
+ let probe = BackgroundTaskQueryProbe()
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) {
+ let _: Int = try await RadrootsBackgroundTaskQuery.read(timeoutNanoseconds: 10_000_000) {
+ probe.install($0)
+ }
+ }
+ probe.reply(8)
+ probe.reply(9)
+}
+
+@Test func backgroundTaskQueryCancellationReleasesWaitAndIgnoresLateReply() async {
+ let probe = BackgroundTaskQueryProbe()
+ let task = Task {
+ try await RadrootsBackgroundTaskQuery.read { probe.install($0) }
+ }
+ var registered = probe.registered.makeAsyncIterator()
+ _ = await registered.next()
+ task.cancel()
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { try await task.value }
+ probe.reply(8)
+ probe.reply(9)
+}
+
+@Test func backgroundTaskQueryAlreadyCancelledDoesNotStartQuery() async {
+ let probe = BackgroundTaskQueryProbe()
+ let task = Task {
+ withUnsafeCurrentTask { $0?.cancel() }
+ let _: Int = try await RadrootsBackgroundTaskQuery.read { probe.install($0) }
+ }
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { try await task.value }
+ #expect(!probe.wasInstalled)
+}
+
+@Test func backgroundTaskQueryRejectsInvalidBudgetsBeforeQuery() async {
+ for budget: UInt64 in [0, 5_000_000_001, .max] {
+ let probe = BackgroundTaskQueryProbe()
+ await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) {
+ let _: Int = try await RadrootsBackgroundTaskQuery.read(timeoutNanoseconds: budget) { probe.install($0) }
+ }
+ #expect(!probe.wasInstalled)
+ }
+}
+
+@Test func backgroundTaskQueryReplyCancellationRaceIsSingleResolution() async throws {
+ for _ in 0 ..< 100 {
+ let probe = BackgroundTaskQueryProbe()
+ let task = Task { try await RadrootsBackgroundTaskQuery.read { probe.install($0) } }
+ var registered = probe.registered.makeAsyncIterator()
+ _ = await registered.next()
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask { probe.reply(7) }
+ group.addTask { task.cancel() }
+ group.addTask { probe.reply(7) }
+ }
+ do { #expect(try await task.value == 7) } catch {
+ #expect(error as? RadrootsBackgroundTransferError == .transferFailure)
+ }
+ }
+}
+
+@Test func backgroundTaskQueryValidatesEveryOwnedTaskBeforeInferringAbsence() throws {
+ let request = try appleUploadRequest(identifier: "inventory.valid")
+ let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: UUID())
+ #expect(try RadrootsBackgroundTaskQuery.descriptors([descriptor.taskDescription]) == [descriptor])
+ #expect(try RadrootsBackgroundTaskQuery.descriptors([request.identifier.rawValue]).first?.identifier
+ == request.identifier)
+ #expect(try RadrootsBackgroundTaskQuery.descriptors([]).isEmpty)
+ #expect(try RadrootsBackgroundTaskQuery.descriptors(Array(repeating: descriptor.taskDescription, count: 4096))
+ .count == 4096)
+ for descriptions: [String?] in [
+ [nil], ["radroots-transfer-v2|invalid"], [String(repeating: "a", count: 257)],
+ [descriptor.taskDescription, nil], Array(repeating: descriptor.taskDescription, count: 4097),
+ ] {
+ #expect(throws: RadrootsBackgroundTransferError.transferFailure) {
+ try RadrootsBackgroundTaskQuery.descriptors(descriptions)
+ }
+ }
+}
+
+@Test func backgroundTaskQueryUnknownInventoryPreservesReceiptsAndRefusesRetry() async throws {
+ for state: RadrootsBackgroundTransferState in [.queued, .running, .interrupted, .expired] {
+ let request = try appleUploadRequest(identifier: "inventory.unknown")
+ let snapshot = try RadrootsBackgroundTransferSnapshot(request: request, state: state, executionID: UUID())
+ let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [snapshot])
+ let probe = RadrootsAppleBackgroundTransferProbe()
+ let original = probe.adapters()
+ let adapters = RadrootsAppleBackgroundTransferAdapters(
+ enqueue: original.enqueue, cancel: original.cancel,
+ activeTransferIdentifiers: {
+ try await RadrootsBackgroundTaskQuery.read(timeoutNanoseconds: 1_000_000) { _ in }
+ }, handleBackgroundEvents: original.handleBackgroundEvents
+ )
+ let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters)
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { try await transfer.snapshots() }
+ if state == .interrupted || state == .expired {
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { try await transfer.retry(request) }
+ }
+ #expect(try await store.loadSnapshots() == [snapshot])
+ #expect(await probe.enqueuedRequests.isEmpty)
+ #expect(await probe.cancelledIdentifiers.isEmpty)
+ }
+}
+
+/// All mutable callback state is synchronized; invoking the callback is outside
+/// the lock so the query's own resolution/cancellation can run independently.
+private final class BackgroundTaskQueryProbe: @unchecked Sendable {
+ let registered: AsyncStream<Void>
+ private let registration: AsyncStream<Void>.Continuation
+ private let lock = NSLock()
+ private var callback: (@Sendable (Int) -> Void)?
+
+ init() {
+ (registered, registration) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1))
+ }
+
+ var wasInstalled: Bool {
+ lock.withLock { callback != nil }
+ }
+
+ func install(_ callback: @escaping @Sendable (Int) -> Void) {
+ lock.withLock { self.callback = callback }
+ registration.yield(())
+ registration.finish()
+ }
+
+ func reply(_ value: Int) {
+ let pending = lock.withLock { callback }
+ pending?(value)
+ }
+}