commit 3a411118e41f4fece4d51a34328c2c6a92e9e478
parent 486fd1c1e92b40379e61c3bdb3a60e06316266fd
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 12:59:25 -0700
capture: harden presentation cleanup
- add exactly-once cleanup hooks to capture callback state
- dismiss media picker and scanner presentations on timeout or cancellation
- preserve coordinator completion idempotence across duplicate callbacks
- cover timeout, cancellation, and double-completion cleanup in tests
Diffstat:
3 files changed, 179 insertions(+), 21 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift b/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift
@@ -76,7 +76,7 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche
return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
timeout: callbackTimeout,
timeoutMessage: "timed out while presenting document scanner"
- ) { completion in
+ ) { completion, setCleanup in
let controller = VNDocumentCameraViewController()
let coordinator = RadrootsAppleDocumentScannerCoordinator(
writer: writer,
@@ -85,6 +85,9 @@ public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unche
)
coordinator.completion = completion
controller.delegate = coordinator
+ setCleanup {
+ coordinator.cancelPresentation(controller)
+ }
RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID)
presenter.present(controller, animated: true)
}
@@ -200,6 +203,12 @@ private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcu
)
}
+ func cancelPresentation(_ controller: VNDocumentCameraViewController) {
+ guard !didResolve else { return }
+ controller.dismiss(animated: true)
+ finish(.failure(.transientFailure("document scanner presentation was cancelled")))
+ }
+
private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) {
guard !didResolve else { return }
didResolve = true
diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift
@@ -94,7 +94,7 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen
return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
timeout: callbackTimeout,
timeoutMessage: "timed out while presenting media import"
- ) { completion in
+ ) { completion, setCleanup in
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = request.selectionLimit
configuration.filter = .images
@@ -106,6 +106,9 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen
)
coordinator.completion = completion
picker.delegate = coordinator
+ setCleanup {
+ coordinator.cancelPresentation(picker)
+ }
RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID)
presenter.present(picker, animated: true)
}
@@ -129,7 +132,7 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen
return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
timeout: callbackTimeout,
timeoutMessage: "timed out while presenting camera photo capture"
- ) { completion in
+ ) { completion, setCleanup in
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.mediaTypes = [Self.imageTypeIdentifier()]
@@ -141,6 +144,9 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen
)
coordinator.completion = completion
picker.delegate = coordinator
+ setCleanup {
+ coordinator.cancelPresentation(picker)
+ }
RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID)
presenter.present(picker, animated: true)
}
@@ -366,6 +372,12 @@ private final class RadrootsApplePhotoPickerCoordinator: NSObject, PHPickerViewC
#endif
}
+ func cancelPresentation(_ picker: PHPickerViewController) {
+ guard !didResolve else { return }
+ picker.dismiss(animated: true)
+ finish(.failure(.transientFailure("media import presentation was cancelled")))
+ }
+
private func finish(_ result: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) {
guard !didResolve else { return }
didResolve = true
@@ -434,6 +446,12 @@ private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePick
)
}
+ func cancelPresentation(_ picker: UIImagePickerController) {
+ guard !didResolve else { return }
+ picker.dismiss(animated: true)
+ finish(.failure(.transientFailure("camera photo capture presentation was cancelled")))
+ }
+
private func finish(_ result: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) {
guard !didResolve else { return }
didResolve = true
@@ -636,25 +654,38 @@ enum RadrootsAppleCaptureAsyncSupport {
static func awaitMainActorCallback<Value: Sendable>(
timeout: TimeInterval,
timeoutMessage: String,
- operation: @escaping @MainActor (@escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void) -> Void
+ operation: @escaping @MainActor (
+ @escaping @Sendable (Result<Value, RadrootsCaptureIntakeError>) -> Void,
+ @escaping @MainActor @Sendable (@escaping @MainActor @Sendable () -> Void) -> Void
+ ) -> Void
) async throws -> Value {
- try await withCheckedThrowingContinuation { continuation in
- let state = RadrootsAppleCaptureAsyncCallbackState(continuation: continuation)
- let timeoutTask = Task {
- let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000)
- do {
- try await Task.sleep(nanoseconds: nanoseconds)
- } catch {
- return
+ let state = RadrootsAppleCaptureAsyncCallbackState<Value>()
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ state.start(continuation: continuation)
+ let timeoutTask = Task {
+ let nanoseconds = UInt64(max(timeout, 0) * 1_000_000_000)
+ do {
+ try await Task.sleep(nanoseconds: nanoseconds)
+ } catch {
+ return
+ }
+ state.resolve(.failure(.transientFailure(timeoutMessage)))
}
- state.resolve(.failure(.transientFailure(timeoutMessage)))
- }
- Task { @MainActor in
- operation { result in
- timeoutTask.cancel()
- state.resolve(result)
+ Task { @MainActor in
+ operation(
+ { result in
+ timeoutTask.cancel()
+ state.resolve(result)
+ },
+ { cleanup in
+ state.setCleanup(cleanup)
+ }
+ )
}
}
+ } onCancel: {
+ state.resolve(.failure(.userCancelled("capture request was cancelled")))
}
}
}
@@ -662,24 +693,74 @@ enum RadrootsAppleCaptureAsyncSupport {
private final class RadrootsAppleCaptureAsyncCallbackState<Value: Sendable>: @unchecked Sendable {
private let lock: NSLock
private var continuation: CheckedContinuation<Value, any Error>?
+ private var cleanup: (@MainActor @Sendable () -> Void)?
+ private var resolvedResult: Result<Value, RadrootsCaptureIntakeError>?
private var didResolve: Bool
- init(continuation: CheckedContinuation<Value, any Error>) {
+ init() {
self.lock = NSLock()
- self.continuation = continuation
+ self.continuation = nil
+ self.cleanup = nil
+ self.resolvedResult = nil
self.didResolve = false
}
+ func start(continuation: CheckedContinuation<Value, any Error>) {
+ lock.lock()
+ if let resolvedResult {
+ lock.unlock()
+ resume(continuation, with: resolvedResult)
+ return
+ }
+ self.continuation = continuation
+ lock.unlock()
+ }
+
+ func setCleanup(_ cleanup: @escaping @MainActor @Sendable () -> Void) {
+ lock.lock()
+ let shouldRun = didResolve
+ if !didResolve {
+ self.cleanup = cleanup
+ }
+ lock.unlock()
+ if shouldRun {
+ Task { @MainActor in
+ cleanup()
+ }
+ }
+ }
+
func resolve(_ result: Result<Value, RadrootsCaptureIntakeError>) {
lock.lock()
- guard !didResolve, let continuation else {
+ guard !didResolve else {
lock.unlock()
return
}
didResolve = true
+ let continuation = self.continuation
self.continuation = nil
+ if continuation == nil {
+ self.resolvedResult = result
+ }
+ let cleanup = self.cleanup
+ self.cleanup = nil
lock.unlock()
+ if let cleanup {
+ Task { @MainActor in
+ cleanup()
+ }
+ }
+ guard let continuation else {
+ return
+ }
+ resume(continuation, with: result)
+ }
+
+ private func resume(
+ _ continuation: CheckedContinuation<Value, any Error>,
+ with result: Result<Value, RadrootsCaptureIntakeError>
+ ) {
switch result {
case .success(let value):
continuation.resume(returning: value)
diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPickerTests.swift
@@ -2,6 +2,65 @@ import Foundation
import Testing
@testable import RadrootsKit
+@Test func captureAsyncSupportRunsCleanupOnTimeout() async throws {
+ let probe = RadrootsCaptureCleanupProbe()
+
+ await #expect(throws: RadrootsCaptureIntakeError.transientFailure("timeout")) {
+ let _: Int = try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
+ timeout: 0.001,
+ timeoutMessage: "timeout"
+ ) { _, setCleanup in
+ setCleanup {
+ probe.recordCleanup()
+ }
+ }
+ }
+
+ try await Task.sleep(nanoseconds: 20_000_000)
+ #expect(await probe.cleanupCount == 1)
+}
+
+@Test func captureAsyncSupportRunsCleanupOnceOnDoubleCompletion() async throws {
+ let probe = RadrootsCaptureCleanupProbe()
+
+ let value: Int = try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
+ timeout: 10,
+ timeoutMessage: "timeout"
+ ) { completion, setCleanup in
+ setCleanup {
+ probe.recordCleanup()
+ }
+ completion(.success(7))
+ completion(.success(8))
+ }
+
+ try await Task.sleep(nanoseconds: 20_000_000)
+ #expect(value == 7)
+ #expect(await probe.cleanupCount == 1)
+}
+
+@Test func captureAsyncSupportRunsCleanupOnTaskCancellation() async throws {
+ let probe = RadrootsCaptureCleanupProbe()
+ let task = Task<Int, any Error> {
+ try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
+ timeout: 10,
+ timeoutMessage: "timeout"
+ ) { _, setCleanup in
+ setCleanup {
+ probe.recordCleanup()
+ }
+ }
+ }
+
+ task.cancel()
+
+ await #expect(throws: RadrootsCaptureIntakeError.userCancelled("capture request was cancelled")) {
+ _ = try await task.value
+ }
+ try await Task.sleep(nanoseconds: 20_000_000)
+ #expect(await probe.cleanupCount == 1)
+}
+
#if !canImport(UIKit)
@Test func appleMediaPickerReportsUnavailableWithoutUIKit() async throws {
let picker = try RadrootsAppleMediaPicker(fileAccess: mediaPickerTestFileAccess())
@@ -32,3 +91,12 @@ private func mediaPickerTestFileAccess() throws -> RadrootsAppleFileAccess {
)
return RadrootsAppleFileAccess(roots: roots)
}
+
+@MainActor
+private final class RadrootsCaptureCleanupProbe {
+ private(set) var cleanupCount = 0
+
+ func recordCleanup() {
+ cleanupCount += 1
+ }
+}