diff --git a/Sources/PladderCore/Support/OutputMuteController.swift b/Sources/PladderCore/Support/OutputMuteController.swift index 369ac66..1e15d0e 100644 --- a/Sources/PladderCore/Support/OutputMuteController.swift +++ b/Sources/PladderCore/Support/OutputMuteController.swift @@ -5,6 +5,7 @@ import Foundation public actor OutputMuteController: OutputMuter { private let control: any OutputMuteControl private let delay: Duration + private let clock: any Clock private let log: @Sendable (String) -> Void // A pending arm that wakes to find this changed belongs to a session already over. @@ -15,10 +16,12 @@ public actor OutputMuteController: OutputMuter { public init( control: any OutputMuteControl, delay: Duration = .milliseconds(200), + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) { self.control = control self.delay = delay + self.clock = clock self.log = log } @@ -29,7 +32,7 @@ public actor OutputMuteController: OutputMuter { latestSession = session generation += 1 let mine = generation - try? await Task.sleep(for: delay) + try? await clock.sleep(for: delay) guard generation == mine else { return } guard restore == nil else { // An earlier session muted it and its end has not come; this session's end restores it. diff --git a/Sources/PladderRefine/S1MiniPolisher.swift b/Sources/PladderRefine/S1MiniPolisher.swift index 40b7c16..c46d394 100644 --- a/Sources/PladderRefine/S1MiniPolisher.swift +++ b/Sources/PladderRefine/S1MiniPolisher.swift @@ -39,7 +39,7 @@ public actor S1MiniPolisher: ReportingRefiner { private var loading: Task<(any PromptCompleter)?, Never>? // llama.cpp ignores cancellation, so a load `unload()` gave up on still reads weights; // the next load waits for it rather than holding two models at once. - private var abandoned: Task<(any PromptCompleter)?, Never>? + private(set) var abandoned: Task<(any PromptCompleter)?, Never>? // Bumped by `unload()`, so a load it interrupted cannot store its model. private var generation = 0 private static let log = Logger(subsystem: "de.dinooo13.pladder", category: "polish") diff --git a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift index c9d3681..a7f6996 100644 --- a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift +++ b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift @@ -53,12 +53,16 @@ import Testing @Test func aPressRightAfterACancelKeepsItsUtterance() async { let engine = FakeStreamingEngine() let (c, output, capture, _) = await makeStreamingCoordinator(style: .compact, engine: engine) - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed() + let press = Task { await c.hotkeyPressed() } + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() + await press.value #expect(c.state.isRecording) await cancel.value c.hotkeyReleased() @@ -92,7 +96,8 @@ import Testing @Test func aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone() async { let (c, _, capture) = makeCoordinator() - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } @@ -103,6 +108,7 @@ import Testing #expect(await waitUntil { c.state == .idle }) let press = Task { await c.hotkeyPressed() } #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() _ = await (cancel.value, cancelledPress.value, cancelPress.value, press.value) #expect(c.state.isRecording) #expect(await capture.startCount == 2) @@ -290,7 +296,7 @@ import Testing let (c, output, _) = makeCoordinator(clock: clock) await c.startIdle() await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration - .seconds(1)) #expect(c.state.isRecording) clock.advance(by: .seconds(1)) @@ -354,7 +360,7 @@ import Testing await c.hotkeyPressed() #expect(await waitUntil { warmPasses() == 1 }) for expected in 2...4 { - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.warmupInterval) #expect(await waitUntil { warmPasses() == expected }) } diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 8401aa1..c3e071e 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -17,7 +17,7 @@ actor FakeCapture: AudioCapture { var startCount = 0 var stopCount = 0 var levelContinuation: AsyncStream.Continuation? - var stopDelay: Duration = .zero + var stopGate: Gate? func start() async throws -> AsyncStream { startCount += 1 @@ -33,7 +33,7 @@ actor FakeCapture: AudioCapture { func stop() async -> CapturedAudio { stopCount += 1 levelContinuation?.finish() - if stopDelay > .zero { try? await Task.sleep(for: stopDelay) } + await stopGate?.wait() return CapturedAudio(samples: samplesToReturn) } @@ -41,7 +41,7 @@ actor FakeCapture: AudioCapture { func setSamples(_ s: [Float]) { samplesToReturn = s } func setDrainSamples(_ s: [Float]) { drainSamples = s } - func setStopDelay(_ d: Duration) { stopDelay = d } + func setStopGate(_ gate: Gate) { stopGate = gate } func emitLevel(_ l: Float) { levelContinuation?.yield(l) } } @@ -126,20 +126,20 @@ final class FakeRefiner: TranscriptRefiner, @unchecked Sendable { private var _calls: [String] = [] private var _prepareCount = 0 private let result: String? - private let delay: Duration + private let gate: Gate? var calls: [String] { lock.withLock { _calls } } var prepareCount: Int { lock.withLock { _prepareCount } } - init(result: String? = "polished", delay: Duration = .zero) { + init(result: String? = "polished", gate: Gate? = nil) { self.result = result - self.delay = delay + self.gate = gate } func prepare() async { lock.withLock { _prepareCount += 1 } } func refine(_ text: String) async -> String? { lock.withLock { _calls.append(text) } - if delay > .zero { try? await Task.sleep(for: delay) } + await gate?.wait() return result } } @@ -184,15 +184,15 @@ actor CountingEngine: TranscriptionEngine { nonisolated let displayName = "Counting" private(set) var status: EngineStatus = .unloaded private let recorder = Recorder() - private let delay: Duration + private let warmGate: Gate? - init(delay: Duration = .zero) { self.delay = delay } + init(warmGate: Gate? = nil) { self.warmGate = warmGate } nonisolated var calls: [Int] { recorder.all } func load() async throws { status = .ready } func transcribe(_ samples: [Float]) async throws -> Transcript { recorder.append(samples.count) - if delay > .zero { try? await Task.sleep(for: delay) } + if samples.count == 8_000 { await warmGate?.wait() } return Transcript(text: "counted", audioDuration: Double(samples.count) / CapturedAudio.sampleRate, processingTime: 0, engineID: id) } func unload() { status = .unloaded } @@ -357,10 +357,10 @@ extension EngineRegistry.Entry { @MainActor func makeCountingCoordinator( - engineDelay: Duration = .zero, + warmGate: Gate? = nil, clock: any Clock = ContinuousClock() ) -> (DictationCoordinator, FakeOutput, FakeCapture, CountingEngine) { - let engine = CountingEngine(delay: engineDelay) + let engine = CountingEngine(warmGate: warmGate) var settings = DictationSettings(engineID: CountingEngine.engineID) settings.appendTrailingSpace = false let (coordinator, output, capture) = makeCoordinator(engines: [.serving(engine)], settings: settings, clock: clock) @@ -389,26 +389,6 @@ func makeStreamingCoordinator( return (coordinator, output, capture, engine) } -@MainActor -func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return condition() -} - -// A dropped recording is idle at once; its microphone stops on a task after. -func eventually(_ timeout: Duration = .seconds(2), _ condition: () async -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return await condition() -} - extension DictationCoordinator { func startIdle(sourceLocation: SourceLocation = #_sourceLocation) async { start() @@ -652,12 +632,15 @@ final class EventLog: @unchecked Sendable { let a = FakeHotkey() let b = FakeHotkey() let (c, _, capture) = makeCoordinator(hotkeyMonitor: a) - await capture.setStopDelay(.milliseconds(50)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.press(a) c.update(c.settings, standInHotkey: c.standInHotkey, monitor: b) #expect(c.state == .idle) b.press() + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() #expect(await waitUntil { c.handledHotkeyEvents == 2 }) #expect(c.state.isRecording) #expect(await capture.startCount == 2) @@ -851,9 +834,14 @@ final class EventLog: @unchecked Sendable { } @Test func releaseDuringWarmupStillInserts() async { - let (c, output, _, _) = makeCountingCoordinator(engineDelay: .milliseconds(200)) + let warmGate = Gate() + let (c, output, _, engine) = makeCountingCoordinator(warmGate: warmGate) await c.startIdle() - await c.dictate() + await c.hotkeyPressed() + #expect(await waitUntil { engine.calls == [8_000] }) + c.hotkeyReleased() + await warmGate.open() + await c.inFlight?.value #expect(output.inserted.count == 1) #expect(c.state == .idle) } @@ -1090,7 +1078,8 @@ final class EventLog: @unchecked Sendable { } @Test func polishingStateIsPublishedWhileTheModelRuns() async { - let refiner = FakeRefiner(delay: .milliseconds(200)) + let gate = Gate() + let refiner = FakeRefiner(gate: gate) let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) await c.startIdle() await c.hotkeyPressed() @@ -1099,6 +1088,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .polishing }) #expect(c.willPolish) #expect(output.inserted.isEmpty) + await gate.open() await c.inFlight?.value #expect(c.state == .idle) #expect(!c.willPolish) @@ -1327,7 +1317,7 @@ final class EventLog: @unchecked Sendable { await c.press(hotkey) hotkey.release() #expect(await waitUntil { c.isLatched }) - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) await c.cycleFinished() #expect(output.inserted.count == 1) @@ -1464,7 +1454,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .idle }) #expect(hotkey.cancelKeyEnabled == [true, false, true, false]) await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) #expect(await waitUntil { !c.state.isRecording }) await c.inFlight?.value diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index ab2242e..f5ac5d7 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -1,4 +1,5 @@ import Foundation +import PladderTestSupport import Testing @testable import PladderCore @@ -63,7 +64,8 @@ import Testing await first.open() #expect(await first.waitUntilLoaded()) - // Time for the superseded load task to resume on the main actor. + // Negative path: a superseded load leaves nothing to wait for, so this gives its task + // time to resume on the main actor. try? await Task.sleep(for: .milliseconds(50)) #expect(loader.engine.id == second.id) #expect(loader.status == .downloading(progress: 0.5)) @@ -82,6 +84,7 @@ private actor GatedEngine: TranscriptionEngine { private var progress: Double private var isOpen = false private var waiters: [CheckedContinuation] = [] + private let loaded = Gate() init(id: EngineID, progress: Double) { self.id = id @@ -94,13 +97,11 @@ private actor GatedEngine: TranscriptionEngine { await withCheckedContinuation { waiters.append($0) } } status = .ready + await loaded.open() } func waitUntilLoaded() async -> Bool { - for _ in 0..<100 { - if status == .ready { return true } - try? await Task.sleep(for: .milliseconds(10)) - } + await loaded.wait() return status == .ready } diff --git a/Tests/PladderCoreTests/OrderedTasksTests.swift b/Tests/PladderCoreTests/OrderedTasksTests.swift index 4f59dd6..b7e9870 100644 --- a/Tests/PladderCoreTests/OrderedTasksTests.swift +++ b/Tests/PladderCoreTests/OrderedTasksTests.swift @@ -5,26 +5,31 @@ import Testing private actor Downloads { private(set) var running = false + private let windDown: Gate + + init(windDown: Gate) { self.windDown = windDown } + func ensure() { running = true } + // Winding down, as a cancelled URLSession task does, and only then marked stopped. func cancel() async { + await windDown.wait() running = false - // Winding down, as a cancelled URLSession task does. - try? await Task.sleep(for: .milliseconds(5)) } } @MainActor @Suite(.timeLimit(.minutes(1))) struct OrderedTasksTests { @Test func aCancelThenEnsureLeavesTheDownloadRunning() async { - for _ in 0..<50 { - let downloads = Downloads() - await downloads.ensure() - let tasks = OrderedTasks() - tasks.enqueue { await downloads.cancel() } - tasks.enqueue { await downloads.ensure() } - await tasks.drained() - #expect(await downloads.running) - } + let windDown = Gate() + let downloads = Downloads(windDown: windDown) + await downloads.ensure() + let tasks = OrderedTasks() + tasks.enqueue { await downloads.cancel() } + tasks.enqueue { await downloads.ensure() } + await windDown.untilSomeoneWaits() + await windDown.open() + await tasks.drained() + #expect(await downloads.running) } @Test func eachPieceStartsAfterThePreviousOneFinished() async { @@ -37,6 +42,7 @@ private actor Downloads { } tasks.enqueue { order.append("second") } await gate.untilSomeoneWaits() + // Gives a wrongly started second piece time to show. try? await Task.sleep(for: .milliseconds(10)) #expect(order.all.isEmpty) await gate.open() diff --git a/Tests/PladderCoreTests/OutputMuteControllerTests.swift b/Tests/PladderCoreTests/OutputMuteControllerTests.swift index 12ccace..dfd5dc4 100644 --- a/Tests/PladderCoreTests/OutputMuteControllerTests.swift +++ b/Tests/PladderCoreTests/OutputMuteControllerTests.swift @@ -61,36 +61,33 @@ private final class FakeMuteControl: OutputMuteControl, @unchecked Sendable { } } -// Short enough to keep the tests fast, long enough that a "before the delay" call -// really lands before it. +// Short, so the tests that let it pass in real time stay fast. private let testDelay = Duration.milliseconds(10) private func makeController( _ control: FakeMuteControl, delay: Duration = testDelay, + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) -> OutputMuteController { - OutputMuteController(control: control, delay: delay, log: log) + OutputMuteController(control: control, delay: delay, clock: clock, log: log) } private func waitForGeneration(_ muter: OutputMuteController, _ target: Int) async { while await muter.armedGeneration < target { await Task.yield() } } -private func pastTheDelay() async { - try? await Task.sleep(for: testDelay * 4) -} - @Suite(.timeLimit(.minutes(1))) struct OutputMuteControllerTests { @Test func tapAndReleaseBeforeTheDelayNeverMutes() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let started = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleeper(at: testDelay)) await muter.recordingEnded(session: 1) + clock.advance(by: testDelay) await started.value - await pastTheDelay() #expect(control.setCalls.isEmpty) #expect(control.state(of: 1) == false) @@ -186,16 +183,17 @@ private func pastTheDelay() async { @Test func aRestartInsideTheDelayStillEndsUpMuted() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let first = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleepers(1)) await muter.recordingEnded(session: 1) let second = Task { await muter.recordingStarted(session: 2) } - await waitForGeneration(muter, 3) + #expect(await clock.waitForSleepers(2)) + clock.advance(by: testDelay) await first.value await second.value - await pastTheDelay() #expect(control.state(of: 1) == true) #expect(control.setCalls == [.mute(1)]) diff --git a/Tests/PladderRefineTests/ModelFilesTests.swift b/Tests/PladderRefineTests/ModelFilesTests.swift index 498d190..59edee4 100644 --- a/Tests/PladderRefineTests/ModelFilesTests.swift +++ b/Tests/PladderRefineTests/ModelFilesTests.swift @@ -121,6 +121,7 @@ private actor StandInTransport: ModelFileTransport { if call == 1 { try Data("wei".utf8).write(to: staging) await started.open() + // A cancellable sleep stands in for the download; the test is that cancel interrupts it. try await Task.sleep(for: .seconds(3)) } try Data("weights".utf8).write(to: staging) diff --git a/Tests/PladderRefineTests/S1MiniTests.swift b/Tests/PladderRefineTests/S1MiniTests.swift index 9c437b1..a64f986 100644 --- a/Tests/PladderRefineTests/S1MiniTests.swift +++ b/Tests/PladderRefineTests/S1MiniTests.swift @@ -136,9 +136,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() await gate.open() - // Time for the first load to finish and try to store its model; the right answer - // does not depend on it. - try await Task.sleep(for: .milliseconds(20)) + _ = await polisher.abandoned?.value #expect(await polisher.refine("send it") == "Second.") } @@ -156,6 +154,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() let second = Task { await polisher.prepare() } + // Negative path: gives a second load that wrongly skipped the wait time to start. try await Task.sleep(for: .milliseconds(30)) #expect(loadsStarted.all == [0]) await gate.open() diff --git a/Tests/PladderTestSupport/ManualClock.swift b/Tests/PladderTestSupport/ManualClock.swift index 2ab6f7b..e7d199d 100644 --- a/Tests/PladderTestSupport/ManualClock.swift +++ b/Tests/PladderTestSupport/ManualClock.swift @@ -10,11 +10,31 @@ public final class ManualClock: Clock, Sendable { let continuation: CheckedContinuation } + private struct Watcher { + let id: Int + let isMet: @Sendable ([Instant]) -> Bool + let continuation: CheckedContinuation + } + private struct State { var now: Instant var nextID = 0 var sleepers: [Sleeper] = [] var cancelled: Set = [] + var watchers: [Watcher] = [] + var expired: Set = [] + + mutating func takeID() -> Int { + defer { nextID += 1 } + return nextID + } + + mutating func takeMetWatchers() -> [Watcher] { + let deadlines = sleepers.map(\.deadline) + let met = watchers.filter { $0.isMet(deadlines) } + watchers.removeAll { watcher in met.contains { $0.id == watcher.id } } + return met + } } private let state: Mutex @@ -47,18 +67,16 @@ public final class ManualClock: Clock, Sendable { } public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { - let id = state.withLock { state -> Int in - defer { state.nextID += 1 } - return state.nextID - } + let id = state.withLock { $0.takeID() } try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let early = state.withLock { state -> Result? in - if state.cancelled.remove(id) != nil || Task.isCancelled { return .failure(CancellationError()) } - if deadline <= state.now { return .success(()) } + let (early, met) = state.withLock { state -> (Result?, [Watcher]) in + if state.cancelled.remove(id) != nil || Task.isCancelled { return (.failure(CancellationError()), []) } + if deadline <= state.now { return (.success(()), []) } state.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation)) - return nil + return (nil, state.takeMetWatchers()) } + for watcher in met { watcher.continuation.resume(returning: true) } if let early { continuation.resume(with: early) } } } onCancel: { @@ -73,19 +91,42 @@ public final class ManualClock: Clock, Sendable { } } - // Waits in real time, but briefly, until a detached task has reached its sleep. public func waitForSleeper(at offset: Duration) async -> Bool { let deadline = start + offset - return await eventually { self.deadlines.contains(deadline) } + return await waitFor { $0.contains(deadline) } } -} -// Polls in real time, a millisecond at a time for up to about two seconds, for what a -// detached task does on its own schedule. -public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { - for _ in 0..<2_000 { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(1)) + public func waitForSleepers(_ count: Int) async -> Bool { + await waitFor { $0.count >= count } + } + + private func waitFor(_ isMet: @escaping @Sendable ([Instant]) -> Bool) async -> Bool { + let id = state.withLock { $0.takeID() } + // Ends the wait after two seconds, as a `Gate` does, so a regression fails rather than hangs. + let timeout = Task { + guard (try? await Task.sleep(for: .seconds(2))) != nil else { return } + self.expire(id) + } + defer { timeout.cancel() } + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let early = state.withLock { state -> Bool? in + if state.expired.remove(id) != nil { return false } + if isMet(state.sleepers.map(\.deadline)) { return true } + state.watchers.append(Watcher(id: id, isMet: isMet, continuation: continuation)) + return nil + } + if let early { continuation.resume(returning: early) } + } + } + + private func expire(_ id: Int) { + let watcher = state.withLock { state -> Watcher? in + if let index = state.watchers.firstIndex(where: { $0.id == id }) { + return state.watchers.remove(at: index) + } + state.expired.insert(id) + return nil + } + watcher?.continuation.resume(returning: false) } - return await condition() } diff --git a/Tests/PladderTestSupport/TestSupport.swift b/Tests/PladderTestSupport/TestSupport.swift index 8d350f3..b662b60 100644 --- a/Tests/PladderTestSupport/TestSupport.swift +++ b/Tests/PladderTestSupport/TestSupport.swift @@ -61,3 +61,24 @@ public func scratchDirectory(_ name: String) throws -> URL { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } + +// Polls in real time: the fakes' counters are not observable. +@MainActor +public func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() +} + +// Polls in real time, a millisecond at a time for up to about two seconds, for what a +// detached task does on its own schedule. +public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + for _ in 0..<2_000 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(1)) + } + return await condition() +}