commit 3b48d55d61110ac1e82d95a5a9e06c5234d5358f
parent d537bc60350fdd2b614fb698f183cb1e5b7c59ee
Author: Michael Camilleri <[email protected]>
Date: Wed, 13 May 2026 05:09:58 +0900
Make MovesUpdater debounce timing test-injectable
debounceCoalescesRapidEnqueues was racing the test's actor hop between two
enqueue() calls against the 50ms Task.sleep spawned by the first enqueue's
scheduleDebounce. On a contended simulator, that sleep would sometimes wake
before the test's second enqueue had been processed by the actor: the cancelled
debounce task got to debouncedFlush() first with the stale 'A' value, the
test's waitForFlushCount(1) saw flushCount already at one, and the assertion
that the cell ended at 'B' failed because the flush of 'B' hadn't happened yet.
The previous flake-mitigation commit only bumped a polling timeout, which
didn't address the underlying race — the race window is independent of how long
the test is willing to wait.
MovesUpdater now takes an injectable sleep closure (defaulting to Task.sleep)
and the debounce timer goes through it. The test passes a ManualDebounceSleep
that captures every sleep continuation and releases them on demand, so the test
can buffer both enqueues before any debounce task wakes. The cancelled task
wakes, observes Task.isCancelled, and returns without flushing; the live task
wakes and flushes once with 'B'. The behaviour the test is verifying is
unchanged; it just no longer depends on wall-clock timing.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift
@@ -32,6 +32,10 @@ actor MovesUpdater {
private let writerAuthorIDProvider: @Sendable () async -> String?
private let sink: @Sendable (Set<UUID>) async -> Void
private let sessionPingSink: (@Sendable (UUID, String) async -> Void)?
+ /// Sleep primitive used by the debounce timer. Injected so tests can
+ /// drive flushes deterministically instead of racing against wall-clock
+ /// `Task.sleep` from the actor's own task queue.
+ private let sleep: @Sendable (Duration) async throws -> Void
private var buffer: [Key: Pending] = [:]
/// Cell most recently enqueued. A subsequent enqueue targeting a different
@@ -50,7 +54,8 @@ actor MovesUpdater {
persistence: PersistenceController,
writerAuthorIDProvider: @escaping @Sendable () async -> String?,
sink: @escaping @Sendable (Set<UUID>) async -> Void,
- sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil
+ sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil,
+ sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
) {
self.debounceInterval = debounceInterval
self.sessionPingStaleInterval = sessionPingStaleInterval
@@ -58,6 +63,7 @@ actor MovesUpdater {
self.writerAuthorIDProvider = writerAuthorIDProvider
self.sink = sink
self.sessionPingSink = sessionPingSink
+ self.sleep = sleep
}
/// Registers a cell edit. `authorID` is the cell-effective author that
@@ -126,8 +132,9 @@ actor MovesUpdater {
private func scheduleDebounce() {
debounceTask?.cancel()
let interval = debounceInterval
+ let sleep = self.sleep
debounceTask = Task { [weak self] in
- try? await Task.sleep(for: interval)
+ try? await sleep(interval)
if Task.isCancelled { return }
await self?.debouncedFlush()
}
diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift
@@ -44,16 +44,47 @@ struct MovesUpdaterTests {
persistence: PersistenceController,
capture: Capture,
debounce: Duration = .seconds(10),
- writerAuthorID: String? = MovesUpdaterTests.writerAuthorID
+ writerAuthorID: String? = MovesUpdaterTests.writerAuthorID,
+ sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
) -> MovesUpdater {
MovesUpdater(
debounceInterval: debounce,
persistence: persistence,
writerAuthorIDProvider: { writerAuthorID },
- sink: { await capture.append($0) }
+ sink: { await capture.append($0) },
+ sleep: sleep
)
}
+ /// Test-controllable sleep: pending awaits hang until `releaseAll()`.
+ /// Lets the debounce test verify "rapid enqueues coalesce" without
+ /// depending on wall-clock timing — the previous test relied on a 50ms
+ /// `Task.sleep` finishing later than the inter-enqueue actor hop, which
+ /// flaked on a contended simulator.
+ final class ManualDebounceSleep: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuations: [CheckedContinuation<Void, Never>] = []
+
+ var sleepFn: @Sendable (Duration) async throws -> Void {
+ { @Sendable [weak self] _ in
+ await withCheckedContinuation { cont in
+ guard let self else { cont.resume(); return }
+ self.lock.lock()
+ self.continuations.append(cont)
+ self.lock.unlock()
+ }
+ }
+ }
+
+ func releaseAll() {
+ lock.lock()
+ let toRelease = continuations
+ continuations.removeAll()
+ lock.unlock()
+ toRelease.forEach { $0.resume() }
+ }
+ }
+
private func movesEntity(
gameID: UUID,
persistence: PersistenceController
@@ -135,16 +166,23 @@ struct MovesUpdaterTests {
func debounceCoalescesRapidEnqueues() async throws {
let (persistence, gameID) = try makePersistenceWithGame()
let capture = Capture()
+ let manualSleep = ManualDebounceSleep()
let updater = makeUpdater(
persistence: persistence,
capture: capture,
- debounce: .milliseconds(50)
+ sleep: manualSleep.sleepFn
)
await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice")
await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice")
- try await waitForFlushCount(1, capture: capture)
+ // Both enqueues are buffered; the second enqueue cancelled the
+ // first debounce task. Releasing the manual sleep lets the live
+ // debounce proceed to flush; the cancelled task wakes, observes
+ // Task.isCancelled, and returns without flushing.
+ manualSleep.releaseAll()
+
+ try await waitForFlushCount(1, capture: capture)
#expect(await capture.flushCount == 1)
let cells = try decodedCells(gameID: gameID, persistence: persistence)
#expect(cells[GridPosition(row: 0, col: 0)]?.letter == "B")