Skip to content

Commit 1d33dcd

Browse files
Merge pull request #46 from BlueFenixProductions/claude/65-scheduler-flake
refactor(65): deterministic SyncScheduler timing — kill the CI flake
2 parents 4a025ce + 67f880f commit 1d33dcd

4 files changed

Lines changed: 271 additions & 38 deletions

File tree

FenixKanban.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
1A8A7DADB70C45BD073693AC /* FizzyClient+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D940AFA2D7BAA47055968F /* FizzyClient+Directory.swift */; };
2525
1A8EDCFA9E34232FEF82D754 /* BoardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB041FBA0AE74AA75078C039 /* BoardViewModelTests.swift */; };
2626
1AC18227D4ECC15AE10448B8 /* SyncSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CCDC89B952EC09B79E7A1D /* SyncSchedulerTests.swift */; };
27+
1B5035906CBBCD18791D0671 /* ManualClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBCC7A00A449AE22617DACC7 /* ManualClock.swift */; };
2728
1B68F7745E72DE4116ECBC5A /* FizzyDTOs+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D477B9EB12FF832B4E19D6 /* FizzyDTOs+Directory.swift */; };
2829
1C06BE028DED35124CDC51B7 /* CardStepsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 140F9619D806C4B36AF13C6B /* CardStepsViewModelTests.swift */; };
2930
1CB04CF828EEDBD05FD6D100 /* FirstSyncModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D613029DDB577CD954C47EEC /* FirstSyncModeTests.swift */; };
@@ -423,6 +424,7 @@
423424
F90C5E7DFE341CFA9C38DACC /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
424425
FABA181CFB55168EB577EAA1 /* FizzyError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FizzyError.swift; sourceTree = "<group>"; };
425426
FAE77B96655930ABFC5A54DA /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = "<group>"; };
427+
FBCC7A00A449AE22617DACC7 /* ManualClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClock.swift; sourceTree = "<group>"; };
426428
FDC0555AA6318151E788C7B4 /* BackupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupSettingsView.swift; sourceTree = "<group>"; };
427429
FF14922C6549BC2260540129 /* FizzyClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FizzyClient.swift; sourceTree = "<group>"; };
428430
/* End PBXFileReference section */
@@ -560,6 +562,7 @@
560562
isa = PBXGroup;
561563
children = (
562564
0FC40B241554124652DF6B03 /* BackgroundRefreshTests.swift */,
565+
FBCC7A00A449AE22617DACC7 /* ManualClock.swift */,
563566
76CCDC89B952EC09B79E7A1D /* SyncSchedulerTests.swift */,
564567
2532EED83F28CDBD8101A208 /* Fizzy */,
565568
);
@@ -1341,6 +1344,7 @@
13411344
B5E32C89B552064B2F4DA094 /* LivePlaygroundRestoreTests.swift in Sources */,
13421345
9C31D6BE994B45F7AEC637E9 /* LiveSmokeTests.swift in Sources */,
13431346
EE3AE5B8CCA089F5BAF7595B /* LiveTestEnv.swift in Sources */,
1347+
1B5035906CBBCD18791D0671 /* ManualClock.swift in Sources */,
13441348
7F544F9BCF81BF33D0F84D8F /* MigrationModelLoading.swift in Sources */,
13451349
DEFA903A08A0D1F9DC624551 /* MockURLProtocol.swift in Sources */,
13461350
52518B243D944207DE9AE664 /* MockURLProtocolIsolationTests.swift in Sources */,

FenixKanban/Features/Sync/SyncScheduler.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import SwiftUI
1313
/// duplicate HTTP work, but the scheduler adds a second layer so the
1414
/// `activityState` never flips twice).
1515
///
16-
/// Injectable `interval` (default 300 s) so tests can run at millisecond
17-
/// resolution without a test clock abstraction.
16+
/// An `any Clock<Duration>` is injected (default `ContinuousClock`) so
17+
/// that tests can drive time forward deterministically without relying on
18+
/// real wall-clock sleeps.
1819
@Observable
1920
@MainActor
2021
final class SyncScheduler {
@@ -27,6 +28,7 @@ final class SyncScheduler {
2728

2829
private let provider: any SyncTriggering
2930
private let interval: Duration
31+
private let clock: any Clock<Duration>
3032
private var loopTask: Task<Void, Never>?
3133
private var isSyncing = false
3234
private var isSceneActive = false
@@ -35,10 +37,12 @@ final class SyncScheduler {
3537

3638
init(
3739
provider: any SyncTriggering,
38-
interval: Duration = .seconds(300)
40+
interval: Duration = .seconds(300),
41+
clock: any Clock<Duration> = ContinuousClock()
3942
) {
4043
self.provider = provider
4144
self.interval = interval
45+
self.clock = clock
4246
}
4347

4448
// Note: loopTask cancellation is intentionally triggered by setSceneActive(false)
@@ -70,7 +74,7 @@ final class SyncScheduler {
7074
// the user may have just foregrounded and the last sync
7175
// may still be fresh.
7276
do {
73-
try await Task.sleep(for: interval)
77+
try await clock.sleep(for: interval)
7478
} catch {
7579
// CancellationError — break cleanly
7680
return
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import Foundation
2+
3+
// MARK: - ManualClock
4+
5+
/// A deterministic test clock whose time only advances when the test calls
6+
/// `advance(by:)`. Sleepers are suspended on continuations and resumed once
7+
/// their deadline is crossed.
8+
///
9+
/// Typical usage in a `@MainActor` Swift Testing test:
10+
///
11+
/// ```swift
12+
/// let clock = ManualClock()
13+
/// let scheduler = SyncScheduler(provider: spy, interval: .seconds(1), clock: clock)
14+
/// scheduler.setSceneActive(true)
15+
///
16+
/// // Wait for the loop to actually suspend in clock.sleep before advancing.
17+
/// await clock.waitForSleeper()
18+
/// await clock.advance(by: .seconds(1))
19+
/// #expect(spy.callCount >= 1)
20+
/// ```
21+
///
22+
/// `waitForSleeper()` is the key — without it, `advance()` would run before
23+
/// the loop task has had a chance to call `sleep(until:)`, so there would be
24+
/// no sleepers to wake.
25+
final class ManualClock: Clock, @unchecked Sendable {
26+
27+
// MARK: - Instant
28+
29+
struct Instant: InstantProtocol {
30+
var offset: Duration
31+
32+
static var zero: Instant { Instant(offset: .zero) }
33+
34+
func advanced(by duration: Duration) -> Instant {
35+
Instant(offset: offset + duration)
36+
}
37+
38+
func duration(to other: Instant) -> Duration {
39+
other.offset - offset
40+
}
41+
42+
static func < (lhs: Instant, rhs: Instant) -> Bool {
43+
lhs.offset < rhs.offset
44+
}
45+
}
46+
47+
// MARK: - Clock conformance
48+
49+
var now: Instant {
50+
lock.withLock { _now }
51+
}
52+
53+
var minimumResolution: Duration { .nanoseconds(1) }
54+
55+
func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws {
56+
try Task.checkCancellation()
57+
58+
// Fast path: already past deadline.
59+
if lock.withLock({ _now }) >= deadline {
60+
await Task.yield()
61+
return
62+
}
63+
64+
// Register a continuation keyed to a stable UUID owned by this call.
65+
let id = UUID()
66+
try await withTaskCancellationHandler {
67+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
68+
// Take the lock once; handle both the double-check and the
69+
// sleeper-waiter notification inside it.
70+
var waitersToResume: [CheckedContinuation<Void, Never>] = []
71+
lock.withLock {
72+
if _now >= deadline {
73+
cont.resume()
74+
} else {
75+
sleepers[id] = Sleeper(deadline: deadline, continuation: cont)
76+
// Collect waiters to resume outside the lock.
77+
waitersToResume = sleeperWaiters
78+
sleeperWaiters.removeAll()
79+
}
80+
}
81+
// Resume sleeperWaiters outside the lock (safe: NSLock is not reentrant).
82+
for waiter in waitersToResume {
83+
waiter.resume()
84+
}
85+
}
86+
} onCancel: {
87+
let sleeper = lock.withLock { sleepers.removeValue(forKey: id) }
88+
sleeper?.continuation.resume(throwing: CancellationError())
89+
}
90+
}
91+
92+
// MARK: - Time advancement
93+
94+
/// Advance `now` by `duration`, waking all sleepers whose deadline is
95+
/// ≤ the new `now`. Yields several times after resuming so woken tasks
96+
/// have time to run before the caller continues asserting.
97+
func advance(by duration: Duration) async {
98+
let woken: [Sleeper] = lock.withLock {
99+
_now = _now.advanced(by: duration)
100+
let threshold = _now
101+
var woken: [Sleeper] = []
102+
for (key, sleeper) in sleepers where sleeper.deadline <= threshold {
103+
woken.append(sleeper)
104+
sleepers.removeValue(forKey: key)
105+
}
106+
return woken
107+
}
108+
109+
for sleeper in woken {
110+
sleeper.continuation.resume()
111+
}
112+
113+
// Multiple yields give resumed tasks (and their downstream MainActor
114+
// work) time to complete before the caller's assertion.
115+
for _ in 0..<8 {
116+
await Task.yield()
117+
}
118+
}
119+
120+
// MARK: - Test synchronisation
121+
122+
/// Suspend until at least one task has registered a sleep with this clock.
123+
///
124+
/// Call this after starting the scheduler and before calling `advance(by:)`
125+
/// to guarantee the loop has actually suspended in `sleep(until:)`.
126+
func waitForSleeper() async {
127+
// Fast path: already have a sleeper.
128+
if lock.withLock({ !sleepers.isEmpty }) { return }
129+
130+
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
131+
lock.withLock {
132+
// Double-check under the lock.
133+
if sleepers.isEmpty {
134+
sleeperWaiters.append(cont)
135+
} else {
136+
cont.resume()
137+
}
138+
}
139+
}
140+
}
141+
142+
// MARK: - Private
143+
144+
private var _now: Instant = .zero
145+
private let lock = NSLock()
146+
private var sleepers: [UUID: Sleeper] = [:]
147+
private var sleeperWaiters: [CheckedContinuation<Void, Never>] = []
148+
149+
private struct Sleeper {
150+
let deadline: Instant
151+
let continuation: CheckedContinuation<Void, Error>
152+
}
153+
}
154+
155+
// MARK: - NSLock helper
156+
157+
private extension NSLock {
158+
@discardableResult
159+
func withLock<T>(_ body: () throws -> T) rethrows -> T {
160+
lock()
161+
defer { unlock() }
162+
return try body()
163+
}
164+
}

0 commit comments

Comments
 (0)