Skip to content

Commit ef2d476

Browse files
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dtlongwell-block
andcommitted
fix(huddle): serialize barge-in monitor clears with worker player ops
Max's PR #997 review blocker: the monitor's check-then-clear was not ordered against the worker's cancel consumption. Sequence: monitor loads cancel=true → preempted → worker consumes cancel and appends a fresh post-cancel utterance → monitor resumes its stale branch and clears the fresh audio (and drops tts_active while it plays, un-gating echo). Fix (Max's suggested shape): a player_ops mutex serializes all Player mutations — monitor clear, worker cancel/shutdown clear, worker append — and the monitor re-checks cancel while holding it. Either the clear runs before fresh audio can be appended, or it observes cancel=false and no-ops. Cancel consumption moved under the same lock so the false is visible to the monitor's under-lock re-check. The worker's post-synth stale-sentence check now also runs under the lock together with its append, closing the symmetric window where the monitor clears between the worker's check passing and the buffer landing. Lock is uncontended except during an actual barge-in (worker holds it only for appends/clears, never across synth), so the hot path and the ~15 ms flag-to-silence are unchanged. Lock acquisition recovers from poison — data is (), nothing inconsistent to observe — so a panicked peer can't wedge the other thread. New regression test models the exact stale-branch interleaving; monitor-contract tests updated to the locked shape. 521 tests green. Co-authored-by: Tyler Longwell <tlongwell@squareup.com> Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
1 parent 82d997f commit ef2d476

2 files changed

Lines changed: 170 additions & 35 deletions

File tree

desktop/src-tauri/src/huddle/tts.rs

Lines changed: 98 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
//! → cancel flag: a 10 ms barge-in monitor thread silences the player and
1919
//! releases tts_active on the flag's rising edge (~15 ms flag-to-silence,
2020
//! even mid-sentence while the worker is blocked in synth_chunk); the
21-
//! worker then consumes the flag — drain queue + clear + play (un-pause)
21+
//! worker then consumes the flag — drain queue + clear + play (un-pause).
22+
//! Monitor clears and worker player mutations are serialized through the
23+
//! `player_ops` mutex, with the flag re-checked under the lock — see the
24+
//! monitor block in `tts_worker` for the race this closes.
2225
//! ```
2326
//!
2427
//! Lookahead pipelining spans *items*, not just sentences within one item:
@@ -37,7 +40,7 @@ use std::{
3740
sync::{
3841
atomic::{AtomicBool, Ordering},
3942
mpsc::{self, SyncSender},
40-
Arc,
43+
Arc, Mutex, MutexGuard, PoisonError,
4144
},
4245
thread,
4346
time::Duration,
@@ -364,23 +367,42 @@ fn tts_worker(
364367
// monitor keeps re-clearing until the worker catches up, which also
365368
// covers a sentence appended in the race window after the worker's own
366369
// post-synthesis cancel check.
370+
//
371+
// `player_ops` closes the converse race (found in review): the monitor
372+
// loads `cancel == true`, is preempted, the worker consumes the cancel
373+
// and appends a fresh post-cancel utterance, then the monitor resumes
374+
// from its stale branch and deletes audio that was meant to play. All
375+
// worker player mutations (appends and cancel/shutdown clears) hold this
376+
// lock, and the monitor re-checks `cancel` *while holding it* — so its
377+
// clear either runs before fresh audio can be appended, or observes
378+
// `cancel == false` and no-ops. The lock is uncontended except during an
379+
// actual barge-in, so the hot path is unaffected.
380+
let player_ops = Arc::new(Mutex::new(()));
367381
let monitor_stop = Arc::new(AtomicBool::new(false));
368382
let monitor = {
369383
let player = Arc::clone(&player);
370384
let cancel = Arc::clone(&cancel);
371385
let tts_active = Arc::clone(&tts_active);
372386
let stop = Arc::clone(&monitor_stop);
387+
let player_ops = Arc::clone(&player_ops);
373388
thread::Builder::new()
374389
.name("tts-barge-in-monitor".into())
375390
.spawn(move || {
376391
while !stop.load(Ordering::Acquire) {
377392
if cancel.load(Ordering::Acquire) {
378-
// clear() pauses the persistent player; play() un-pauses
379-
// (see handle_cancel_or_shutdown). Idempotent — safe to
380-
// repeat every tick until the worker consumes the flag.
381-
player.clear();
382-
player.play();
383-
tts_active.store(false, Ordering::Release);
393+
let _ops = lock_player_ops(&player_ops);
394+
// Re-check under the lock: the worker may have
395+
// consumed this cancel (and appended fresh audio)
396+
// between the load above and the lock acquisition.
397+
if cancel.load(Ordering::Acquire) {
398+
// clear() pauses the persistent player; play()
399+
// un-pauses (see handle_cancel_or_shutdown).
400+
// Idempotent — safe to repeat every tick until
401+
// the worker consumes the flag.
402+
player.clear();
403+
player.play();
404+
tts_active.store(false, Ordering::Release);
405+
}
384406
}
385407
thread::sleep(MONITOR_TICK);
386408
}
@@ -408,7 +430,13 @@ fn tts_worker(
408430
let mut first_append = true;
409431

410432
loop {
411-
if handle_cancel_or_shutdown(&cancel, &shutdown, &tts_active, &text_rx, Some(&player)) {
433+
if handle_cancel_or_shutdown(
434+
&cancel,
435+
&shutdown,
436+
&tts_active,
437+
&text_rx,
438+
Some((&player, &player_ops)),
439+
) {
412440
if shutdown.load(Ordering::Acquire) {
413441
break;
414442
}
@@ -435,7 +463,13 @@ fn tts_worker(
435463

436464
// Check cancel again after unblocking — a cancel may have arrived
437465
// while we were waiting.
438-
if handle_cancel_or_shutdown(&cancel, &shutdown, &tts_active, &text_rx, Some(&player)) {
466+
if handle_cancel_or_shutdown(
467+
&cancel,
468+
&shutdown,
469+
&tts_active,
470+
&text_rx,
471+
Some((&player, &player_ops)),
472+
) {
439473
if shutdown.load(Ordering::Acquire) {
440474
break;
441475
}
@@ -471,7 +505,13 @@ fn tts_worker(
471505
.collect();
472506

473507
for sentence in &sentences {
474-
if handle_cancel_or_shutdown(&cancel, &shutdown, &tts_active, &text_rx, Some(&player)) {
508+
if handle_cancel_or_shutdown(
509+
&cancel,
510+
&shutdown,
511+
&tts_active,
512+
&text_rx,
513+
Some((&player, &player_ops)),
514+
) {
475515
first_append = true;
476516
break;
477517
}
@@ -483,16 +523,6 @@ fn tts_worker(
483523

484524
match engine.synth_chunk(text, "en", &style, SYNTH_STEPS, SYNTH_SPEED) {
485525
Ok(samples) if !samples.is_empty() => {
486-
// A barge-in may have arrived during synthesis (the
487-
// blocking window the monitor thread exists for). Don't
488-
// append the now-stale sentence — the human interrupted;
489-
// speaking it anyway would talk over them. The flag is
490-
// deliberately NOT consumed here: the loop-top
491-
// handle_cancel_or_shutdown does the full consume
492-
// (drain queue, reset lead-in) on the next iteration.
493-
if cancel.load(Ordering::Acquire) {
494-
break;
495-
}
496526
let mut boosted = apply_playback_gain(samples);
497527
// Fade-out only — fading-in would attenuate the consonant
498528
// onset (see `apply_fade_out` docstring + the
@@ -506,6 +536,25 @@ fn tts_worker(
506536
// every chunk a quiet device warm-up window.
507537
let buf =
508538
build_sentence_append_buffer(&mut first_append, boosted, silence_buf_len);
539+
540+
// Check-and-append under `player_ops`, serialized with
541+
// the monitor: a barge-in may have arrived during
542+
// synthesis (the blocking window the monitor thread
543+
// exists for). Don't append the now-stale sentence — the
544+
// human interrupted; speaking it anyway would talk over
545+
// them. Holding the lock for the check + append means the
546+
// monitor can never clear between our check passing and
547+
// the buffer landing. The flag is deliberately NOT
548+
// consumed here: the loop-top handle_cancel_or_shutdown
549+
// does the full consume (drain queue, reset lead-in) on
550+
// the next iteration.
551+
let _ops = lock_player_ops(&player_ops);
552+
if cancel.load(Ordering::Acquire) {
553+
// Nothing appended; the loop-top consume re-arms
554+
// `first_append` (the flag is still set — the worker
555+
// is its only consumer).
556+
break;
557+
}
509558
player.append(SamplesBuffer::new(channels, rate, buf));
510559
// NOTE: tts_active is set AFTER player.append(), not
511560
// before. Setting it before synthesis would cause STT to
@@ -540,38 +589,61 @@ fn tts_worker(
540589

541590
/// Check for cancel or shutdown. Returns `true` if the caller should break/continue.
542591
/// On cancel: drains the text queue and clears the cancel flag.
592+
///
593+
/// `player` pairs the Player with the `player_ops` mutex shared with the
594+
/// barge-in monitor thread; the cancel/shutdown clear runs under that lock so
595+
/// it is serialized with the monitor's stale-branch re-check (see the monitor
596+
/// block in `tts_worker`).
543597
fn handle_cancel_or_shutdown(
544598
cancel: &AtomicBool,
545599
shutdown: &AtomicBool,
546600
tts_active: &AtomicBool,
547601
text_rx: &mpsc::Receiver<String>,
548-
player: Option<&rodio::Player>,
602+
player: Option<(&rodio::Player, &Mutex<()>)>,
549603
) -> bool {
550604
if shutdown.load(Ordering::Acquire) {
551-
if let Some(p) = player {
605+
if let Some((p, ops)) = player {
606+
let _ops = lock_player_ops(ops);
552607
p.clear();
553608
}
554609
tts_active.store(false, Ordering::Release);
555610
return true;
556611
}
557612
if cancel.load(Ordering::Acquire) {
558-
if let Some(p) = player {
613+
if let Some((p, ops)) = player {
614+
let _ops = lock_player_ops(ops);
559615
// `Player::clear()` removes queued sources AND pauses the player
560616
// (rodio 0.22 `clear()` ends with `self.pause()`). With one
561617
// persistent Player for the worker's lifetime, the un-pause is
562618
// mandatory: without `play()`, every append after a barge-in
563619
// would queue silently forever.
564620
p.clear();
565621
p.play();
622+
// Consume the flag under the lock: once released with
623+
// `cancel == false`, the monitor's stale branch no-ops instead
624+
// of clearing the fresh post-cancel utterance.
625+
while text_rx.try_recv().is_ok() {}
626+
cancel.store(false, Ordering::Release);
627+
} else {
628+
while text_rx.try_recv().is_ok() {}
629+
cancel.store(false, Ordering::Release);
566630
}
567-
while text_rx.try_recv().is_ok() {}
568-
cancel.store(false, Ordering::Release);
569631
tts_active.store(false, Ordering::Release);
570632
return true;
571633
}
572634
false
573635
}
574636

637+
/// Acquire the `player_ops` lock, recovering from poison.
638+
///
639+
/// The data under the mutex is `()` — it only serializes Player mutations —
640+
/// so a panicked holder leaves nothing inconsistent to observe and recovery
641+
/// is always safe. Without this, a worker panic would wedge the monitor (or
642+
/// vice versa) on `unwrap()`.
643+
fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> {
644+
ops.lock().unwrap_or_else(PoisonError::into_inner)
645+
}
646+
575647
/// Apply the fixed playback gain ([`PLAYBACK_GAIN`]), hard-clamped to ±1.0.
576648
///
577649
/// Replaces the earlier per-sentence peak normalization — see the

desktop/src-tauri/src/huddle/tts_tests.rs

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use super::*;
77
use std::collections::HashMap;
88
use std::sync::atomic::{AtomicBool, Ordering};
99
use std::sync::mpsc;
10-
use std::sync::Arc;
10+
use std::sync::{Arc, Mutex};
1111

1212
// ── Remote interrupt tracker ──────────────────────────────────────────────
1313
//
@@ -468,19 +468,33 @@ fn on_receipt_check_releases_mic_gate_before_synthesis() {
468468
//
469469
// Models one tick of the tts-barge-in-monitor thread in `tts_worker`. The
470470
// contract (must match production):
471-
// - cancel set → silence player + release tts_active, flag NOT consumed
471+
// - cancel set → take `player_ops`, RE-CHECK cancel under the lock; if
472+
// still set: silence player + release tts_active, flag NOT consumed
472473
// (the worker owns consumption: queue drain + lead-in reset)
474+
// - cancel cleared by the time the lock is held → no-op (stale branch)
473475
// - cancel clear → no-op
474476
// Not consuming the flag is what makes the monitor idempotent across ticks
475477
// and closes the race where the worker appends a sentence after the
476-
// monitor's clear but before consuming the flag.
478+
// monitor's clear but before consuming the flag. The under-lock re-check
479+
// closes the converse race (PR #997 review blocker): worker consumes the
480+
// cancel and appends a fresh post-cancel utterance between the monitor's
481+
// initial load and its clear — the stale branch must not delete that audio.
477482

478483
/// Test-side model of one monitor tick. `player_cleared` stands in for the
479-
/// `clear()+play()` pair on the real Player.
480-
fn simulate_monitor_tick(cancel: &AtomicBool, tts_active: &AtomicBool, player_cleared: &mut bool) {
484+
/// `clear()+play()` pair on the real Player; `player_ops` is the mutex
485+
/// serializing monitor clears with worker player mutations.
486+
fn simulate_monitor_tick(
487+
cancel: &AtomicBool,
488+
tts_active: &AtomicBool,
489+
player_ops: &Mutex<()>,
490+
player_cleared: &mut bool,
491+
) {
481492
if cancel.load(Ordering::Acquire) {
482-
*player_cleared = true;
483-
tts_active.store(false, Ordering::Release);
493+
let _ops = player_ops.lock().unwrap();
494+
if cancel.load(Ordering::Acquire) {
495+
*player_cleared = true;
496+
tts_active.store(false, Ordering::Release);
497+
}
484498
}
485499
}
486500

@@ -490,9 +504,10 @@ fn simulate_monitor_tick(cancel: &AtomicBool, tts_active: &AtomicBool, player_cl
490504
fn monitor_tick_silences_and_releases_without_consuming_cancel() {
491505
let cancel = AtomicBool::new(true);
492506
let tts_active = AtomicBool::new(true);
507+
let player_ops = Mutex::new(());
493508
let mut player_cleared = false;
494509

495-
simulate_monitor_tick(&cancel, &tts_active, &mut player_cleared);
510+
simulate_monitor_tick(&cancel, &tts_active, &player_ops, &mut player_cleared);
496511

497512
assert!(player_cleared, "monitor must silence in-flight audio");
498513
assert!(
@@ -511,9 +526,10 @@ fn monitor_tick_silences_and_releases_without_consuming_cancel() {
511526
fn monitor_tick_noop_without_cancel() {
512527
let cancel = AtomicBool::new(false);
513528
let tts_active = AtomicBool::new(true);
529+
let player_ops = Mutex::new(());
514530
let mut player_cleared = false;
515531

516-
simulate_monitor_tick(&cancel, &tts_active, &mut player_cleared);
532+
simulate_monitor_tick(&cancel, &tts_active, &player_ops, &mut player_cleared);
517533

518534
assert!(!player_cleared);
519535
assert!(
@@ -522,6 +538,53 @@ fn monitor_tick_noop_without_cancel() {
522538
);
523539
}
524540

541+
/// Stale-branch race (PR #997 review blocker): monitor observes
542+
/// `cancel == true`, then the worker — under `player_ops` — consumes the
543+
/// cancel and appends a fresh post-cancel utterance before the monitor
544+
/// reaches its clear. The monitor's under-lock re-check must see
545+
/// `cancel == false` and no-op, leaving the fresh audio and its mic gate
546+
/// intact.
547+
#[test]
548+
fn monitor_stale_cancel_branch_must_not_clear_fresh_audio() {
549+
let cancel = AtomicBool::new(true);
550+
let tts_active = AtomicBool::new(true);
551+
let player_ops = Mutex::new(());
552+
let mut player_cleared = false;
553+
554+
// Monitor's initial (pre-lock) load observes the cancel…
555+
let stale_observation = cancel.load(Ordering::Acquire);
556+
assert!(stale_observation);
557+
558+
// …then the worker wins the lock: consumes the cancel, appends a fresh
559+
// utterance, sets tts_active (mirrors handle_cancel_or_shutdown +
560+
// the locked append in the sentence loop).
561+
{
562+
let _ops = player_ops.lock().unwrap();
563+
cancel.store(false, Ordering::Release);
564+
tts_active.store(true, Ordering::Release);
565+
}
566+
567+
// Monitor resumes from its stale branch — the re-check under the lock
568+
// must turn it into a no-op.
569+
if stale_observation {
570+
let _ops = player_ops.lock().unwrap();
571+
if cancel.load(Ordering::Acquire) {
572+
player_cleared = true;
573+
tts_active.store(false, Ordering::Release);
574+
}
575+
}
576+
577+
assert!(
578+
!player_cleared,
579+
"stale monitor branch must not clear a fresh post-cancel utterance",
580+
);
581+
assert!(
582+
tts_active.load(Ordering::Acquire),
583+
"stale monitor branch must not release the mic gate while fresh \
584+
audio is playing",
585+
);
586+
}
587+
525588
/// Full cycle: remote speech → cancel → TTS consumption → new TTS → cancel again.
526589
/// Validates the cancel mechanism is reusable across TTS sessions.
527590
/// The false→true transition on tts_active auto-clears counters — no

0 commit comments

Comments
 (0)