Skip to content

fix(voice): harden streaming concurrency, provider resilience, and codec labeling#226

Merged
lIang70 merged 8 commits into
mainfrom
review/voice-main
Jul 8, 2026
Merged

fix(voice): harden streaming concurrency, provider resilience, and codec labeling#226
lIang70 merged 8 commits into
mainfrom
review/voice-main

Conversation

@lIang70

@lIang70 lIang70 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Full-review pass over the voice package, fixing concurrency hazards, goroutine leaks, stream-termination semantics, and provider-resilience bugs surfaced while reviewing main. No public API changes; every fix ships with a regression test (goroutine-leak checks use go.uber.org/goleak, and each hardened test was mutation-verified to fail when its fix is reverted).

Grouped into five scoped commits:

  • voice/audio — add CodecFLAC; make the ResampleStream worker check its output context before each blocking input.Read so it exits promptly on consumer interrupt.
  • voice/provider — fail fast on cancelled context before each provider and each retry attempt; SleepWithContext honors ctx for non-positive durations; recompute the clock per provider so a cooled-down breaker isn't skipped; add a half-open state; count provider-attributable failures toward the breaker and exclude client faults via IsProviderFault so bad client input can't open a healthy provider.
  • voice/stt/bytedance — writer goroutine observes inner-context cancellation before each input.Read (no leak when the reader returns early); surface genuine send failures via Interrupt() instead of a clean EOF so a truncated transcript isn't mistaken for a complete one.
  • voice/tts/minimax — stop the ctx-watcher goroutine leaking on normal/error returns; bail out of the synth loop on ctx cancel; replace the 1MB-capped bufio.Scanner with bufio.Reader so a long utterance's cumulative status=2 final line no longer fails with ErrTooLong; centralize codec mapping in resolveOutput so Frame.Format matches the bytes the API actually returns; drain the warmup body before closing.
  • voice/webrtc — only tear down the source pipe on Failed/Closed, not on transient Disconnected (which flaps on packet loss and can recover); previously the latched Interrupt irrecoverably killed recoverable sessions.

Test plan

  • go build ./... and go vet ./... (voice module)
  • go test -race ./... (voice module)
  • Mutation-verified each hardened regression test (revert fix -> test fails -> restore -> passes)
  • Reviewer sanity check on provider circuit-breaker attribution semantics

Made with Cursor

lIang70 and others added 5 commits July 8, 2026 15:43
Add audio.CodecFLAC so streamed audio can be labeled to match the bytes a
provider actually returns. Make the ResampleStream worker check the output
pipe's context before each blocking input.Read so it exits promptly when the
consumer interrupts, and document that the upstream must be interruptible.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-aware

Fail fast on a cancelled context before trying each provider and before each
retry attempt; previously a retryable, ctx-ignoring execFn could be retried
MaxAttempts times after cancellation when RetryBackoff==0. SleepWithContext
now honors ctx even for non-positive durations, and the clock is recomputed
per provider so a breaker that has actually cooled down is not skipped.

Add a half-open state so recovery is gradual and consecutiveFailures stays
bounded, count provider-attributable failures (not only retryable ones)
toward the breaker, and exclude client-fault errors (bad input, caller
cancellation) via IsProviderFault so bad client input can never open a
healthy provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ntics

Make the writer goroutine observe inner-context cancellation before each
input.Read so it exits when the reader returns early (server error frame or
ctx cancel) instead of leaking while parked on the caller-owned input stream.
Surface genuine writer send failures via out.Interrupt() instead of a clean
io.EOF so a truncated transcript is no longer mistaken for a complete one,
and honor out.Send()'s result consistently.

Adds go.uber.org/goleak as a test dependency for the leak regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ec labels

Stop the SynthesizeStream ctx-watcher goroutine from leaking on the normal and
error return paths (exit via a done channel) and bail out of the synth loop
promptly on ctx cancellation. Replace the 1MB-capped bufio.Scanner with a
bufio.Reader so a long utterance's cumulative status=2 final line no longer
fails with ErrTooLong, and skip that redundant cumulative line.

Centralize request-codec -> (API format, output codec) mapping in resolveOutput
so an emitted Frame.Format matches the bytes the API actually returns
(Opus -> mp3, WAV -> flac), and drain the warmup response body before closing
so the connection returns to the idle pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
Only tear down the source pipe on PeerConnectionStateFailed/Closed, not on the
transient Disconnected state, which routinely flaps on packet loss and can
recover to connected. Because Pipe.Interrupt latches context.Canceled
permanently, interrupting on Disconnected irrecoverably killed otherwise
recoverable sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3733acbe29

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// would surface a clean io.EOF indistinguishable from a
// normal end-of-stream.
telemetry.Error(innerCtx, fmt.Sprintf("bytedance stt: writer error: %v", err))
out.Interrupt()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve interrupt semantics for writer send failures

On this writer-error path the goroutine still has defer out.Close() at the top of RecognizeStream, so returning after out.Interrupt() immediately closes the pipe too. audio.Pipe.Read is nondeterministic when both cancel and close are ready (see TestPipe_InterruptThenClose), so callers that read after the goroutine exits can cache io.EOF and still treat a truncated upload as a clean transcript; avoid closing the pipe on interrupt/error paths or otherwise make the terminal error deterministic.

Useful? React with 👍 / 👎.

Comment thread voice/tts/minimax/minimax.go Outdated
// loses no error signal that callers rely on, and
// FallbackTTS only falls back on stream-setup failure,
// not on mid-stream errors.
out.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve MiniMax stream errors instead of EOF

This branch handles synthesizeStreamChunk errors such as MiniMax base_resp API errors or response read failures, but out.Close() makes the next Read end with io.EOF when no audio, or only partial audio, was produced. Direct StreamTTS callers and the pipeline cannot distinguish provider failure or rate limiting from a normal empty/completed utterance, violating the stream contract for abnormal termination; preserve an interrupt/error signal instead of closing cleanly for provider/decode errors.

Useful? React with 👍 / 👎.

Comment thread voice/provider/fallback.go Outdated
Comment on lines +193 to +195
if state.openUntil.IsZero() {
// Closed: no open window pending.
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate additional calls while half-open

After the open window elapses, the first caller clears openUntil and sets halfOpen; any concurrent or subsequent Allow before that probe finishes re-enters here and returns true because openUntil is now zero. That defeats the new half-open invariant of allowing a single recovery probe, so a burst of requests can hit a still-unhealthy provider immediately after cooldown instead of being held until OnSuccess or OnFailure resolves the probe.

Useful? React with 👍 / 👎.

lIang70 and others added 3 commits July 8, 2026 15:59
…ipe.Read

Pipe.Read's select was chosen at random when both a value/closed channel and
ctx.Done() were ready, so a producer that calls Interrupt() and then runs a
deferred Close() (e.g. bytedance RecognizeStream's server-error and writer-error
paths) could have its abnormal termination observed as a clean io.EOF. Check
ctx.Done() first so "Interrupt returns context.Canceled immediately, skipping
buffered values" is the deterministic guarantee the docs already promise.

Co-authored-by: Cursor <cursoragent@cursor.com>
When the open window elapsed, the first Allow zeroed openUntil and set halfOpen,
but any concurrent or subsequent Allow then saw a zero window and returned true,
letting a post-cooldown burst hit a still-unhealthy provider. Distinguish a
half-open probe in flight from a fully closed circuit and hold other callers
until OnSuccess/OnFailure resolves the probe, restoring the single-probe
invariant.

Co-authored-by: Cursor <cursoragent@cursor.com>
…clean EOF

A mid-stream failure (MiniMax base_resp API error or a response read/decode
error) closed the output, so the terminal Read returned io.EOF and a direct
SynthesizeStream caller could not distinguish a provider failure or a truncated
utterance from a normally completed one. Interrupt on any mid-stream failure so
the terminal Read reports an error, honoring the audio.Stream abnormal-
termination contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lIang70
lIang70 merged commit 9a1d028 into main Jul 8, 2026
15 checks passed
@lIang70
lIang70 deleted the review/voice-main branch July 8, 2026 08:13
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