Showing 2 changed files with 119 additions and 23 deletions
+17 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -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,
+102 -23
HealthProbe/Services/HealthKitService.swift
@@ -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>?