apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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:
MSources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift | 8++++----
MSources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift | 32+++++++++++++++++++-------------
ASources/RadrootsKit/RadrootsBackgroundTaskQuery.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsBackgroundTaskQueryTests.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}