Skip to content

Tests: sleeps that stood in for ordering become Gates and ManualClock waits (20 → 13) - #78

Open
dinooo13 wants to merge 7 commits into
mainfrom
tests/no-ordering-sleeps
Open

dinooo13 wants to merge 7 commits into
mainfrom
tests/no-ordering-sleeps

Conversation

@dinooo13

Copy link
Copy Markdown
Owner

Part of #73, point 4.

Where a sleep in the tests stood in for ordering, it is now a Gate or ManualClock. Where it has to be real time, it now says why. Real-time sleeps in Tests/: 20 → 13. Counted with grep -rnE 'Task.sleep|Thread.sleep|usleep|clock.sleep' Tests, leaving out the clock.sleeperCount matches and PasteTestSupport's ManualClock forwarder.

No test was renamed and no assertion was removed. What changed is how the tests wait.

Every real-time sleep, before → after

# Where (at 69fe600) What it did After
1 DictationCoordinatorTests FakeCapture.stop, stopDelay held the mic stop 50–150 ms so a press or cancel landed during it replaced: stopGate: Gate?. aPressRightAfterACancelKeepsItsUtterance, aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone and aPressRightAfterAMonitorSwapStartsADictation open it once they have seen the next press reach .recording
2 DictationCoordinatorTests FakeRefiner.refine, delay held polish 200 ms replaced: gate: Gate?. polishingStateIsPublishedWhileTheModelRuns checks .polishing, then opens it
3 DictationCoordinatorTests CountingEngine.transcribe, delay held every pass 200 ms replaced: warmGate: Gate?, used only on the warm pass (samples.count == 8_000). releaseDuringWarmupStillInserts presses, waits for calls == [8_000], releases, then opens. Gate.wait cannot be cancelled, so it still models a CoreML pass that cannot be stopped. The old try? Task.sleep could be cancelled, so the release used to cut it short
4 DictationCoordinatorTests waitUntil, 10 ms poll kept, moved to PladderTestSupport/TestSupport.swift, labelled "Polls in real time: the fakes' counters are not observable."
5 DictationCoordinatorTests local eventually, 10 ms poll removed: the tests use PladderTestSupport.eventually
6 DictationCoordinatorTests theHoldIsTimedByTheEventsOwnInstants, 100 ms kept (already labelled: the test is about wall time passing)
7 EngineLoaderTests aSupersededLoadLeavesTheNextEngineAlone, 50 ms kept, labelled "Negative path: a superseded load leaves nothing to wait for"
8 EngineLoaderTests GatedEngine.waitUntilLoaded, 10 ms poll replaced: load() opens a Gate that waitUntilLoaded waits on
9 OrderedTasksTests Downloads.cancel, 5 ms, test looped 50× replaced: cancel waits on a Gate and marks the download stopped only after it; one run
10 OrderedTasksTests eachPieceStartsAfterThePreviousOneFinished, 10 ms kept, labelled "Gives a wrongly started second piece time to show."
11 OutputMuteControllerTests pastTheDelay() slept 4 × the delay replaced: OutputMuteController takes a clock (default ContinuousClock()). The two tests that used it run on ManualClock and call advance
12 ModelFilesTests cancelStopsTheDownloadAndLeavesNothingBehind, 3 s kept, labelled "A cancellable sleep stands in for the download; the test is that cancel interrupts it."
13 ModelFilesTests cancelReachesTheChecksum, Thread.sleep 1 ms kept (already labelled: polls for its cancel)
14 S1MiniTests aLoadInterruptedByUnloadIsDropped, 20 ms replaced: S1MiniPolisher.abandoned is now private(set); the test runs _ = await polisher.abandoned?.value
15 S1MiniTests aLoadAfterUnloadWaitsForTheAbandonedOneToBeFreed, 30 ms kept, labelled "Negative path: gives a second load that wrongly skipped the wait time to start."
16 TranscriptPolisherBudgetTests slowCall kept (labelled at file level: raced against the real clock on purpose)
17 ObservationLoopTests settle() kept (labelled)
18 PasteFlowTests aReadBeforeCmdVIsNotThePaste, 20 ms kept (labelled, negative path)
19 TestSupport Gate's safety timer kept (labelled)
20 ManualClock.swift eventually, 1 ms poll kept, moved to TestSupport.swift next to waitUntil
+ ManualClock.waitFor, new safety timer only: it ends a wait after 2 s, as Gate's does, and is cancelled as soon as the wait is met

ManualClock.waitForSleeper(at:) used to poll through eventually. It now waits for an event: sleep(until:) wakes any waiter whose condition it meets. The new waitForSleepers(_ count:) (at least count) replaces the four waitUntil { clock.sleeperCount >= 2 }. The == 1 and == 3 polls are left as they are, because "at least" would weaken them.

Not counted above, and not changed: uncancellableSleep in DictationCoordinatorTests (a DispatchQueue.asyncAfter) behind FakeStreamingEngine's feedDelay/beginDelay/abandonDelay/livePassDelay, and EchoEngine's delay. The grep does not find them and the issue's 20 did not include them. They are the next candidates.

Where the plan was wrong

  • Line numbers. All of them matched 69fe600.
  • OrderedTasksTests: "one deterministic iteration". A correct OrderedTasks now always passes. A broken one does not always fail. With enqueue changed so it no longer waits for the previous piece, aCancelThenEnsureLeavesTheDownloadRunning failed 29 of 30 runs on its own, and fewer in a full parallel run. The ensure task sometimes reaches the actor only after the test has opened the gate, and that cannot be detected without a sleep. eachPieceStartsAfterThePreviousOneFinished (row 10) failed every time under the same change, so that is the test that reliably catches it. I kept one run rather than the 50× loop.
  • GatedEngine.waitUntilLoaded. The plan said to store continuations; I used a Gate instead. Its 2 s safety timer means a regression fails instead of hanging until the one-minute suite limit.
  • OutputMuteControllerTests. The two tests run on ManualClock. The rest keep the 10 ms real testDelay through the default clock, because some of them assert that a start returns without sleeping, and with a ManualClock those would need a sleeper that never comes. pastTheDelay() was also redundant before: both tests already awaited the start task, which only returns after its sleep.

Production code touched

  • OutputMuteController: clock: any Clock<Duration> = ContinuousClock() in init, used for the arm delay. The default keeps AppModel's call unchanged. Off the release path: only recordingStarted(session:) reads the clock, and the coordinator calls it at key-down from armOutputMute(). The release side, recordingEnded(session:), does not touch the clock. Its spawn, the Task.detached in restoreOutputDevice() that endRecording() creates before recordingStopped is emitted, is unchanged. Nothing between recordingStopped and inserted changed, so the benchmark was not run.
  • S1MiniPolisher.abandoned: private → private(set). The tests read it through @testable import PladderRefine.

Verification

  • swift build: no warnings in Sources/.
  • swift test: three runs in a row, all green, on the final tree and on every commit of the branch.
  • for i in $(seq 20); do swift test --skip-build --filter CoordinatorHandOverTests || break; done: 20/20 green, 0.42–0.44 s each.
  • Each changed suite run 10× with --filter, 10/10 green each: DictationCoordinatorTests, PolishToggleTests, ToggleHotkeyTests, EscapeTests, CoordinatorClockTests, EngineLoaderTests, OrderedTasksTests, OutputMuteControllerTests, ModelFilesTests, S1MiniLoadTests, PasteFlowTests (uses waitForSleeper), CorrectionLearnerTests (uses waitUntil).
  • Each new wait was checked against the bug it guards, with the production code temporarily changed:
    • Without the generation guard in OutputMuteController, tapAndReleaseBeforeTheDelayNeverMutes fails 3/3.
    • Without the generation guard in S1MiniPolisher.finishLoad, aLoadInterruptedByUnloadIsDropped fails 3/3.
    • For OrderedTasks, see above.

Suite times reported by swift test, three runs each:

Target before (69fe600) after
PladderCoreTests (451) 0.570 / 0.566 / 0.550 s 0.567 / 0.559 / 0.549 s
PladderSystemTests (75) 0.115 / 0.120 / 0.119 s 0.094 / 0.093 / 0.090 s
PladderRefineTests (55) 0.082 / 0.089 / 0.080 s 0.085 / 0.079 / 0.077 s
PladderAudioTests (14) 0.088 / 0.108 / 0.095 s 0.087 / 0.087 / 0.083 s

Core's time is set by its slowest tests, the streaming fakes' 300 ms delays, which this PR leaves alone. System got faster because waitForSleeper no longer polls.

🤖 Generated with Claude Code

dinooo13 and others added 7 commits October 16, 2026 15:41
…ntil joins eventually in PladderTestSupport

`waitForSleeper(at:)` polled `deadlines` a millisecond at a time; a sleep now resumes the waiters it satisfies, and `waitForSleepers(_:)` does the same for a count, replacing four `waitUntil { clock.sleeperCount >= 2 }`. Both still end by themselves after two seconds, as a Gate does.

`waitUntil` moves from DictationCoordinatorTests to PladderTestSupport, and the coordinator tests' own `eventually` goes in favour of the shared one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`FakeCapture.stop`, `FakeRefiner.refine` and the counting engine's warm pass slept 50 to 200 ms so a cancel, a press or a release would land while they ran. Each now waits on a Gate the test opens once it has seen that state. The warm pass's wait cannot be cancelled, like a CoreML pass in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not a 10 ms poll

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of fifty

The stand-in cancel slept 5 ms and the test ran fifty times so the ensure would sometimes reach the actor first. It now waits on a Gate and marks the download stopped only after it, so an ensure that does not wait for the cancel loses its download whether it reaches the actor before the cancel or during its wind-down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Defaulted to ContinuousClock, so the app's call site is unchanged. The two tests that slept four times the delay to be sure an arm had given up now drive it with ManualClock. Off the release path: only `recordingStarted`, called at key-down, reads the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d load instead of sleeping 20 ms

`abandoned` becomes `private(set)` so the test, through `@testable import`, can wait for the first load to finish and try to store its model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each is a negative path, where nothing happens that a test could wait for, or a cancellable sleep standing in for a download that the cancel must interrupt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant