Commit Graph

1 Commits

Author SHA1 Message Date
Rocky 56d0da0a51 fix: preview disc stuck at .processing after stop
The previous code path for the keyboard preview's ASR controller
cancelled the consumer task at the exact moment it closed the
audio stream:

    asrTask?.cancel()       // ← kills the .final consumer
    asrTask = nil
    ...
    bufferContinuation?.finish()   // tells ASR "no more audio"

The cancellation cascaded: the for-await on the events stream
exited → the AsyncStream's `continuation.onTermination` fired →
ASR.cancel() ran → producer task was marked cancelled → the
producer's `if !Task.isCancelled { yield(.final) }` guard
suppressed the .final event. Net result: nobody told the UI to
leave `.processing`, and the disc sat there forever.

Fix (4 changes):

1) `stop()` no longer cancels the consumer. The consumer task
   exits naturally when the events stream finishes, sees the
   `.final` event the producer still yields, and transitions
   the phase out of `.processing`. This is the primary fix.

2) `start()` cancels any leftover `asrTask` at the entry point
   as a safety net — covers the "user smashes the disc twice
   quickly" race where a previous consumer is still draining.

3) `stop()` schedules a 3-second safety-net Task: if the ASR
   pipeline never produces a `.final` (analyzer hang, system
   glitch), force the phase back to `.idle` so the user isn't
   stuck. Normal recordings complete well under 3 seconds, so
   the timeout is only hit on the unhappy path.

4) `KeyboardPreviewSheet` adds `.onDisappear { asr.stop() }`
   so closing the sheet mid-recording releases the
   AVAudioSession and mic. `stop()` is idempotent (no-op on
   non-recording phases), safe to call here.

State machine: `phase = .processing` now has TWO transition
paths out — the consumer receiving `.final` (fast path) and
the 3-second safety net (fallback). Both are required; the
fast path is the common case, the fallback is the
"guaranteed-progress" guarantee.

Testability: `asrTask` was `private`; relaxed to `internal` so
the regression test in
`OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
install a known consumer task and assert `stop()` does not
cancel it. The class is `@MainActor` so Swift 6 isolation
rules still prevent production code outside the class from
racing on it.

Tests:
- `testStopDoesNotCancelConsumerTask` — primary fix regression.
- `testStopIsIdempotent` — `.onDisappear` after a manual stop
  doesn't misbehave.
- 27/27 tests pass (25 existing + 2 new).
- BUILD SUCCEEDED.

🤖 Generated with Claude Code
2026-06-18 21:00:18 +08:00