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
This commit is contained in:
Rocky
2026-06-18 21:00:18 +08:00
parent a227309059
commit 56d0da0a51
3 changed files with 125 additions and 3 deletions
@@ -101,6 +101,16 @@ struct KeyboardPreviewSheet: View {
insertRecognizedText(new)
asr.reset()
}
// Tear down the ASR pipeline when the sheet leaves the screen
// (preview dismissed, app backgrounded mid-recording, etc.).
// Without this, a leftover `asrTask` keeps the AVAudioSession
// active and the mic permission in use after the user has
// moved on. `asr.stop()` is idempotent it no-ops on
// `.idle`/`.denied`/`.error` so it's safe to call here
// even when the disc is not currently recording.
.onDisappear {
asr.stop()
}
}
/// Real `TextField` (was a static placeholder HStack before the
+45 -3
View File
@@ -50,7 +50,13 @@ final class PreviewASRController: ObservableObject {
private let asr: ASRService = ASRServiceFactory.make()
private let audioEngine = AVAudioEngine()
private var asrTask: Task<Void, Never>?
/// `internal` (not `private`) so the regression test in
/// `OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
/// install a known consumer task and assert `stop()` doesn't
/// cancel it. The class is `@MainActor`-isolated, so the
/// natural Swift 6 isolation rules still prevent production
/// code outside the class from racing on it.
var asrTask: Task<Void, Never>?
private var bufferContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var didConfigureAudioSession = false
private var didInstallTap = false
@@ -65,6 +71,15 @@ final class PreviewASRController: ObservableObject {
default:
break
}
// Cancel any leftover consumer task from a previous recording.
// Normally `stop()` lets the task run to completion (so it can
// see the `.final` and transition out of `.processing`), but if
// the user smashed the disc twice stop, then immediately
// start the previous task might still be draining. Cancel it
// here so we don't have two consumer tasks fighting over the
// same `events` stream.
asrTask?.cancel()
asrTask = nil
phase = .requestingPermission
currentPartial = ""
lastFinal = ""
@@ -111,8 +126,20 @@ final class PreviewASRController: ObservableObject {
}
func stop() {
asrTask?.cancel()
asrTask = nil
// Don't `asrTask?.cancel()` here see the comment in
// `startEngineAndASR` for the full rationale. Short version:
// cancelling the consumer task at the same moment we close the
// audio stream also triggers the producer's
// `continuation.onTermination self?.cancel()` cascade, which
// marks the producer's outer task as cancelled and skips the
// `.final` event. The UI is then left in `.processing` forever
// because no one schedules the transition out. The consumer
// task naturally exits when `events` finishes, so the right
// thing is to let it run.
//
// If a previous `asrTask` is somehow still running (e.g. the
// user smashed the disc twice quickly), `start()` cancels it
// at the entry point as a safety net.
if didInstallTap {
audioEngine.inputNode.removeTap(onBus: 0)
didInstallTap = false
@@ -128,6 +155,21 @@ final class PreviewASRController: ObservableObject {
// Deactivate so the user's music resumes if the preview is
// dismissed mid-recording.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
// Safety net: if the ASR pipeline never produces a `.final`
// (analyzer hang, system glitch, dropped continuation), force
// the UI back to idle after a short delay so the user isn't
// stuck. Normal recordings complete well under a second, so
// the 3-second budget is only hit on the unhappy path; if the
// pipeline finishes first and flips the phase to `.idle` (or
// `.error`), the check below no-ops.
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
guard let self else { return }
if self.phase == .processing {
self.phase = .idle
}
}
}
func reset() {