From e0a0c220740bf31f7fa4f39d6febc08dbb81d262 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 22:33:04 +0000 Subject: [PATCH] Make the warm-recorder tests headless-runner-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three tests driving production warmUp() / scheduleRewarm() reached prepareWarmRecorder()'s AudioRoute.currentInput() — a CoreAudio HAL query — on a runner with no input device: 3 expectation failures, plus at least one blocked CoreAudio call that pinned the cooperative pool and wedged the whole parallel test process into the job's 30-minute timeout (the suite's .timeLimit can't fire once the pool is pinned). The re-warm test's unbounded 'while ... { await Task.yield() }' wait made sure nothing shorter than the job timeout could end it. Rework those three to drive the same guard logic through the proven installWarmRecorder(boundTo:) seam and direct state setters: warmUp() and scheduleRewarm() now appear only on their refusal paths, which return before the device read. The one remaining wait is deadline-bounded (500 ms) and cancellation-aware. Add a direct armPreparedRecorderExpiry test to keep line coverage of the countdown arming that the prepare path used to reach. The three seam-driven tests that already passed on the runner are unchanged. Tests only; no production changes. prepareWarmRecorder()'s success path is now exercised only on hardware, and the header comment says so instead of claiming CI reachability. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CNFeP9D8k1HJ1piwyidUv3 --- .../MicCaptureWarmTests.swift | 93 ++++++++++++++----- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift index 4fb6dc3f..34ec80ed 100644 --- a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift @@ -6,35 +6,40 @@ import Testing /// refuse to double-prepare, validate against the live input before reuse, and /// tear down on a stale expiry ticket. /// -/// Runs without hardware: none of this begins capture — `warmUp()` only -/// constructs and prepares a file-backed recorder, so unlike the capture -/// lifecycle in `MicCapture.swift` (excluded from the coverage gate, exercised -/// by the env-gated `MicCaptureLevelsTests`) the decisions here are reachable in -/// CI. The device-identity checks are driven through -/// `installWarmRecorder(boundTo:)` below rather than `warmUp()` itself, so what -/// the test machine's `AudioRoute.currentInput()` answers never decides a test. +/// Everything here is driven through the `installWarmRecorder(boundTo:)` seam +/// and direct state setters, never through `prepareWarmRecorder()`. Preparing +/// for real reads `AudioRoute.currentInput()` — a CoreAudio HAL query that needs +/// an input device. On a headless CI runner that read fails or, worse, blocks +/// inside CoreAudio, pinning the cooperative pool and wedging the whole parallel +/// test process (a pinned pool means even the suite's `.timeLimit` never fires). +/// Production `warmUp()` / `scheduleRewarm()` therefore appear below only on +/// their refusal paths, which return before the prepare. The cost is honest: +/// `prepareWarmRecorder()`'s success path is exercised only on hardware. @Suite("MicCapture warm recorder", .timeLimit(.minutes(1))) struct MicCaptureWarmTests { private let builtIn = AudioRoute.InputSnapshot(deviceID: 7, transportType: nil) private let airPods = AudioRoute.InputSnapshot(deviceID: 8, transportType: nil) - @Test("warmUp prepares a recorder once; a second call has been overtaken and no-ops") - func warmUpPreparesOnce() async throws { + @Test("warmUp refuses while the slot is taken, so a re-warm can't stack a second recorder") + func warmUpRefusedWhileSlotTaken() async throws { let mic = MicCapture() #expect(await mic.canPrepareWarmRecorder) - await mic.warmUp() + try await mic.installWarmRecorder(boundTo: builtIn) #expect(await mic.hasWarmRecorder) // The slot is taken, so it is no longer safe to open the input for another. #expect(await mic.canPrepareWarmRecorder == false) - // A second warm-up — e.g. a scheduled re-warm that lost its race with a + // A warm-up landing now — e.g. a scheduled re-warm that lost its race with a // launch-time warmUp — must not stack a second open recorder onto the input. + // Safe to call for real: the guard refuses before the device read. let generation = await mic.preparedGeneration await mic.warmUp() #expect(await mic.preparedGeneration == generation) + // Emptying the slot reopens the guard. await mic.discardWarmRecorder() + #expect(await mic.canPrepareWarmRecorder) } @Test("warmUp is refused across the bring-up window, when both recorder slots are nil") @@ -45,14 +50,15 @@ struct MicCaptureWarmTests { // the already-open input. let mic = MicCapture() await mic.setBringingUpCapture(true) + #expect(await mic.canPrepareWarmRecorder == false) await mic.warmUp() #expect(await mic.hasWarmRecorder == false) + // Closing the window reopens the guard. Asserted on the guard itself rather + // than by calling `warmUp()` again — past the guard it prepares for real, + // which needs an input device (see the suite comment). await mic.setBringingUpCapture(false) - await mic.warmUp() - #expect(await mic.hasWarmRecorder) - - await mic.discardWarmRecorder() + #expect(await mic.canPrepareWarmRecorder) } @Test("a warm recorder is reused only while still bound to the live default input") @@ -109,18 +115,52 @@ struct MicCaptureWarmTests { #expect(await mic.hasWarmRecorder == false) } - @Test("a scheduled re-warm eventually prepares a recorder on its own turn") - func scheduledRewarmPreparesARecorder() async throws { + @Test("arming the expiry attaches a countdown; consuming the recorder cancels it") + func armingExpiryAttachesCountdown() async throws { + let mic = MicCapture() + try await mic.installWarmRecorder(boundTo: builtIn) + #expect(await mic.hasExpiryCountdown == false) + + let generation = await mic.preparedGeneration + await mic.armPreparedRecorderExpiry(generation: generation) + #expect(await mic.hasExpiryCountdown) + + // Re-arming cancels the previous countdown and installs a fresh one rather + // than leaving two racing tasks against the same recorder. + await mic.armPreparedRecorderExpiry(generation: generation) + #expect(await mic.hasExpiryCountdown) + + // The take cancels the countdown along with consuming the recorder, so a + // consumed recorder's expiry can never fire against its successor. + #expect(await mic.takeWarm(matching: builtIn)) + #expect(await mic.hasExpiryCountdown == false) + } + + @Test("a scheduled re-warm that has been overtaken no-ops on its turn") + func overtakenScheduledRewarmDoesNothing() async throws { // `stop()`/`cancelCapture()` schedule rather than prepare inline because // preparing re-opens the input — the slow part — and both sit on paths the - // user is waiting behind. All this can pin deterministically is the other - // half of that contract: the scheduled task does land, and prepares. - // Condition-waited rather than yield-budgeted (see `awaitCancelRequest`); - // the suite's time limit turns a re-warm that never lands into a failure. + // user is waiting behind. That deferral means a press (here: an installed + // recorder) can land before the re-warm's turn, and the overtaken task must + // find the slot taken and do nothing. (The success half of the contract — + // the scheduled task preparing a fresh recorder — goes through the device + // read and is only reachable on hardware; see the suite comment.) let mic = MicCapture() + try await mic.installWarmRecorder(boundTo: builtIn) let before = await mic.preparedGeneration + await mic.scheduleRewarm() - while await mic.preparedGeneration == before { await Task.yield() } + // A deadline-bounded settling window, not a condition wait: the assertion is + // a negative, so there is nothing to wait *for* — the window just gives the + // scheduled task actor turns. A regression (a bumped generation) exits + // early, straight into the failing expectation; the sleep is + // cancellation-aware so the suite's time limit can still preempt it. + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .milliseconds(500)) + while await mic.preparedGeneration == before, clock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await mic.preparedGeneration == before) #expect(await mic.hasWarmRecorder) await mic.discardWarmRecorder() @@ -135,6 +175,9 @@ extension MicCapture { /// Whether a warm recorder is currently held. var hasWarmRecorder: Bool { warm != nil } + /// Whether the held warm recorder has an expiry countdown armed. + var hasExpiryCountdown: Bool { warm?.expiry != nil } + /// Opens or closes the bring-up window `canPrepareWarmRecorder` guards on, /// standing in for a `start()` suspended in its liveness wait (which needs /// real hardware to reach). @@ -143,9 +186,9 @@ extension MicCapture { } /// Installs a warm recorder bound to a *known* input — `prepareWarmRecorder` - /// with the `AudioRoute.currentInput()` read replaced by `input`, so the - /// device-identity tests don't depend on what the test machine's routing - /// happens to answer. + /// with the `AudioRoute.currentInput()` read replaced by `input`, so no test + /// depends on what the test machine's routing happens to answer (or on there + /// being an input device at all). func installWarmRecorder(boundTo input: AudioRoute.InputSnapshot?) throws { preparedGeneration += 1 warm = WarmRecorder(