@@ -105,6 +105,23 @@ Action taken: |
||
| 105 | 105 |
- keep failed `earliest_sample` behavior as failed/timeout, because no safe zero |
| 106 | 106 |
inference is possible when HealthKit did not answer the existence query. |
| 107 | 107 |
|
| 108 |
+Follow-up root cause review: |
|
| 109 |
+- user correctly suspected commit `36d0904` as the regression source; |
|
| 110 |
+- the monotonic timer itself was not the problem, but the same commit also |
|
| 111 |
+ removed the shared boundary-query deadline (`dateDeadline`) and gave both |
|
| 112 |
+ `earliest_sample` and `latest_sample` a full per-query timeout; |
|
| 113 |
+- the same commit rewrote `withTimeout` using `withThrowingTaskGroup`; when the |
|
| 114 |
+ timeout child fired, the task-group scope could still wait for the operation |
|
| 115 |
+ child that was blocked inside a HealthKit query; |
|
| 116 |
+- this explains both symptoms: empty types spending timeout-scale time in |
|
| 117 |
+ boundary queries and `Import page 1` remaining visible past the configured page |
|
| 118 |
+ timeout. |
|
| 119 |
+ |
|
| 120 |
+Additional action: |
|
| 121 |
+- replace the task-group timeout race with a continuation-based race guarded by |
|
| 122 |
+ a lock. The timeout path now cancels the HealthKit operation and resumes the |
|
| 123 |
+ caller immediately instead of waiting for a stuck child task to unwind. |
|
| 124 |
+ |
|
| 108 | 125 |
### 2026-06-07 Overnight Initial Imports On Both Large Devices |
| 109 | 126 |
|
| 110 | 127 |
Source: two user-provided overnight first-import reports from large devices, |
@@ -2436,30 +2436,33 @@ final class HealthKitService {
|
||
| 2436 | 2436 |
private func withTimeout<T: Sendable>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
|
| 2437 | 2437 |
guard seconds > 0, seconds.isFinite else { throw CancellationError() }
|
| 2438 | 2438 |
let nanoseconds = UInt64((seconds * 1_000_000_000).rounded(.up)) |
| 2439 |
- let operationTask = Task {
|
|
| 2440 |
- try await operation() |
|
| 2441 |
- } |
|
| 2442 |
- let timeoutTask = Task<T, Error> {
|
|
| 2443 |
- try await Task.sleep(nanoseconds: nanoseconds) |
|
| 2444 |
- operationTask.cancel() |
|
| 2445 |
- throw CancellationError() |
|
| 2446 |
- } |
|
| 2447 |
- defer {
|
|
| 2448 |
- operationTask.cancel() |
|
| 2449 |
- timeoutTask.cancel() |
|
| 2450 |
- } |
|
| 2451 |
- |
|
| 2452 |
- return try await withThrowingTaskGroup(of: T.self) { group in
|
|
| 2453 |
- group.addTask { try await operationTask.value }
|
|
| 2454 |
- group.addTask { try await timeoutTask.value }
|
|
| 2455 |
- do {
|
|
| 2456 |
- let result = try await group.next()! |
|
| 2457 |
- group.cancelAll() |
|
| 2458 |
- return result |
|
| 2459 |
- } catch {
|
|
| 2460 |
- group.cancelAll() |
|
| 2461 |
- throw error |
|
| 2439 |
+ let box = TimeoutContinuationBox<T>() |
|
| 2440 |
+ |
|
| 2441 |
+ return try await withTaskCancellationHandler {
|
|
| 2442 |
+ try await withCheckedThrowingContinuation { continuation in
|
|
| 2443 |
+ let operationTask = Task {
|
|
| 2444 |
+ do {
|
|
| 2445 |
+ let value = try await operation() |
|
| 2446 |
+ box.resume(continuation, with: .success(value)) |
|
| 2447 |
+ } catch {
|
|
| 2448 |
+ box.resume(continuation, with: .failure(error)) |
|
| 2449 |
+ } |
|
| 2450 |
+ } |
|
| 2451 |
+ box.setOperationTask(operationTask) |
|
| 2452 |
+ |
|
| 2453 |
+ let timeoutTask = Task {
|
|
| 2454 |
+ do {
|
|
| 2455 |
+ try await Task.sleep(nanoseconds: nanoseconds) |
|
| 2456 |
+ } catch {
|
|
| 2457 |
+ return |
|
| 2458 |
+ } |
|
| 2459 |
+ box.cancelOperation() |
|
| 2460 |
+ box.resume(continuation, with: .failure(CancellationError())) |
|
| 2461 |
+ } |
|
| 2462 |
+ box.setTimeoutTask(timeoutTask) |
|
| 2462 | 2463 |
} |
| 2464 |
+ } onCancel: {
|
|
| 2465 |
+ box.cancelAll() |
|
| 2463 | 2466 |
} |
| 2464 | 2467 |
} |
| 2465 | 2468 |
|
@@ -2685,6 +2688,82 @@ private struct RebuiltRecordArchive: Sendable {
|
||
| 2685 | 2688 |
let recordArchiveData: Data? |
| 2686 | 2689 |
} |
| 2687 | 2690 |
|
| 2691 |
+private final class TimeoutContinuationBox<Value: Sendable>: @unchecked Sendable {
|
|
| 2692 |
+ private let lock = NSLock() |
|
| 2693 |
+ nonisolated(unsafe) private var didResume = false |
|
| 2694 |
+ nonisolated(unsafe) private var operationTask: Task<Void, Never>? |
|
| 2695 |
+ nonisolated(unsafe) private var timeoutTask: Task<Void, Never>? |
|
| 2696 |
+ |
|
| 2697 |
+ nonisolated func setOperationTask(_ task: Task<Void, Never>) {
|
|
| 2698 |
+ setTask(task, keyPath: \.operationTask) |
|
| 2699 |
+ } |
|
| 2700 |
+ |
|
| 2701 |
+ nonisolated func setTimeoutTask(_ task: Task<Void, Never>) {
|
|
| 2702 |
+ setTask(task, keyPath: \.timeoutTask) |
|
| 2703 |
+ } |
|
| 2704 |
+ |
|
| 2705 |
+ nonisolated func cancelOperation() {
|
|
| 2706 |
+ lock.lock() |
|
| 2707 |
+ let task = operationTask |
|
| 2708 |
+ lock.unlock() |
|
| 2709 |
+ task?.cancel() |
|
| 2710 |
+ } |
|
| 2711 |
+ |
|
| 2712 |
+ nonisolated func cancelAll() {
|
|
| 2713 |
+ lock.lock() |
|
| 2714 |
+ let operationTask = operationTask |
|
| 2715 |
+ let timeoutTask = timeoutTask |
|
| 2716 |
+ lock.unlock() |
|
| 2717 |
+ operationTask?.cancel() |
|
| 2718 |
+ timeoutTask?.cancel() |
|
| 2719 |
+ } |
|
| 2720 |
+ |
|
| 2721 |
+ nonisolated func resume( |
|
| 2722 |
+ _ continuation: CheckedContinuation<Value, Error>, |
|
| 2723 |
+ with result: Result<Value, Error> |
|
| 2724 |
+ ) {
|
|
| 2725 |
+ let shouldResume: Bool |
|
| 2726 |
+ let operationTask: Task<Void, Never>? |
|
| 2727 |
+ let timeoutTask: Task<Void, Never>? |
|
| 2728 |
+ lock.lock() |
|
| 2729 |
+ if didResume {
|
|
| 2730 |
+ shouldResume = false |
|
| 2731 |
+ operationTask = nil |
|
| 2732 |
+ timeoutTask = nil |
|
| 2733 |
+ } else {
|
|
| 2734 |
+ didResume = true |
|
| 2735 |
+ shouldResume = true |
|
| 2736 |
+ operationTask = self.operationTask |
|
| 2737 |
+ timeoutTask = self.timeoutTask |
|
| 2738 |
+ } |
|
| 2739 |
+ lock.unlock() |
|
| 2740 |
+ |
|
| 2741 |
+ guard shouldResume else { return }
|
|
| 2742 |
+ operationTask?.cancel() |
|
| 2743 |
+ timeoutTask?.cancel() |
|
| 2744 |
+ continuation.resume(with: result) |
|
| 2745 |
+ } |
|
| 2746 |
+ |
|
| 2747 |
+ private nonisolated func setTask( |
|
| 2748 |
+ _ task: Task<Void, Never>, |
|
| 2749 |
+ keyPath: ReferenceWritableKeyPath<TimeoutContinuationBox<Value>, Task<Void, Never>?> |
|
| 2750 |
+ ) {
|
|
| 2751 |
+ let shouldCancel: Bool |
|
| 2752 |
+ lock.lock() |
|
| 2753 |
+ if didResume {
|
|
| 2754 |
+ shouldCancel = true |
|
| 2755 |
+ } else {
|
|
| 2756 |
+ shouldCancel = false |
|
| 2757 |
+ self[keyPath: keyPath] = task |
|
| 2758 |
+ } |
|
| 2759 |
+ lock.unlock() |
|
| 2760 |
+ |
|
| 2761 |
+ if shouldCancel {
|
|
| 2762 |
+ task.cancel() |
|
| 2763 |
+ } |
|
| 2764 |
+ } |
|
| 2765 |
+} |
|
| 2766 |
+ |
|
| 2688 | 2767 |
private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked Sendable {
|
| 2689 | 2768 |
private let lock = NSLock() |
| 2690 | 2769 |
nonisolated(unsafe) private var continuation: CheckedContinuation<Value, Error>? |