From b144422b9c7e2d5170bd5888ac4bbd9c0d6ed65e Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 13:51:20 +0200 Subject: [PATCH 1/3] fix toggle intent ordering --- crates/echo/src/rec.rs | 59 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index 0f12b74..9d0bf25 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -134,8 +134,8 @@ pub fn run_rec_once() -> i32 { } pub fn run_rec_toggle() -> i32 { - match ToggleSession::start_or_stop() { - Ok(action) => { + match start_or_stop_with_intent() { + Ok((action, cancel_transcription)) => { let recording_token = action.recording_token().map(str::to_string); if let Err(err) = status::mark_shortcut_activation("toggle-command", recording_token.as_deref()) @@ -148,7 +148,9 @@ pub fn run_rec_toggle() -> i32 { // Preserve the CLI/hotkey toggle convention after capture: // its stop gesture means cancel while transcription is live. // Explicit desktop capture-stop never takes this path. - apply_toggle_stop_intent(&owner); + if cancel_transcription { + apply_toggle_stop_intent(&owner); + } 0 } } @@ -163,8 +165,8 @@ pub fn run_rec_toggle() -> i32 { /// Toggle an in-process recording after synchronously acquiring or stopping /// the cross-process session. Recording work continues on a background thread. pub fn toggle_managed_recording() -> Result, String> { - match ToggleSession::start_or_stop()? { - ToggleAction::Start(session) => { + match start_or_stop_with_intent()? { + (ToggleAction::Start(session), _) => { let recording_token = session.token.clone(); std::thread::Builder::new() .name("echo-record-toggle".to_string()) @@ -174,13 +176,30 @@ pub fn toggle_managed_recording() -> Result, String> { .map(|_| Some(recording_token)) .map_err(|err| err.to_string()) } - ToggleAction::Stop(owner) => { - apply_toggle_stop_intent(&owner); + (ToggleAction::Stop(owner), cancel_transcription) => { + if cancel_transcription { + apply_toggle_stop_intent(&owner); + } Ok(owner.token) } } } +/// Decide the meaning of a toggle before writing a capture-stop signal. The +/// token check prevents an observation of an earlier owner from authorizing a +/// cancellation for a replacement owner. +fn start_or_stop_with_intent() -> Result<(ToggleAction, bool), String> { + let observed = status::read(); + let action = ToggleSession::start_or_stop()?; + let cancel_transcription = + matches!(&action, ToggleAction::Stop(owner) if should_cancel_toggle(&observed, owner)); + Ok((action, cancel_transcription)) +} + +fn should_cancel_toggle(observed: &status::Status, owner: &LockOwner) -> bool { + observed.state == "Transcribing" && observed.session_id.as_deref() == owner.token.as_deref() +} + fn apply_toggle_stop_intent(owner: &LockOwner) { if status::read().state == "Transcribing" { if let Some(token) = owner.token.as_deref() { @@ -1624,6 +1643,32 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn capture_toggle_observed_before_transition_never_becomes_cancel() { + let owner = LockOwner { + pid: 1, + token: Some("capture-a".to_string()), + start_time_ticks: Some(1), + scoped_intents: true, + }; + let recording = status::Status { + state: "Recording".to_string(), + last: None, + last_history_id: None, + error: None, + recording_limit: None, + session_id: Some("capture-a".to_string()), + revision: 2, + }; + assert!(!should_cancel_toggle(&recording, &owner)); + let transcribing_replacement = status::Status { + state: "Transcribing".to_string(), + session_id: Some("capture-b".to_string()), + ..recording + }; + assert!(!should_cancel_toggle(&transcribing_replacement, &owner)); + } + #[test] fn fixture_returns_wav_without_opening_host() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/claude_code.wav"); From 7b3748c503e612feb1bc553b0ec0283b1af8904f Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 13:53:00 +0200 Subject: [PATCH 2/3] test toggle intent observation order --- crates/echo/src/rec.rs | 51 +++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index 9d0bf25..762cb17 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -189,8 +189,15 @@ pub fn toggle_managed_recording() -> Result, String> { /// token check prevents an observation of an earlier owner from authorizing a /// cancellation for a replacement owner. fn start_or_stop_with_intent() -> Result<(ToggleAction, bool), String> { - let observed = status::read(); - let action = ToggleSession::start_or_stop()?; + decide_toggle_intent(status::read, ToggleSession::start_or_stop) +} + +fn decide_toggle_intent( + read_status: impl FnOnce() -> status::Status, + start_or_stop: impl FnOnce() -> Result, +) -> Result<(ToggleAction, bool), String> { + let observed = read_status(); + let action = start_or_stop()?; let cancel_transcription = matches!(&action, ToggleAction::Stop(owner) if should_cancel_toggle(&observed, owner)); Ok((action, cancel_transcription)) @@ -201,10 +208,8 @@ fn should_cancel_toggle(observed: &status::Status, owner: &LockOwner) -> bool { } fn apply_toggle_stop_intent(owner: &LockOwner) { - if status::read().state == "Transcribing" { - if let Some(token) = owner.token.as_deref() { - let _ = ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), token); - } + if let Some(token) = owner.token.as_deref() { + let _ = ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), token); } } @@ -1660,13 +1665,43 @@ mod tests { session_id: Some("capture-a".to_string()), revision: 2, }; - assert!(!should_cancel_toggle(&recording, &owner)); + let read_happened = Cell::new(false); + let (_, cancel) = decide_toggle_intent( + || { + read_happened.set(true); + recording.clone() + }, + || { + assert!( + read_happened.get(), + "status must be observed before stop writes its signal" + ); + // This callback represents capture ending and the owner + // immediately publishing Transcribing. + Ok(ToggleAction::Stop(owner.clone())) + }, + ) + .unwrap(); + assert!(!cancel); + let transcribing = status::Status { + state: "Transcribing".to_string(), + ..recording.clone() + }; + let (_, cancel) = + decide_toggle_intent(|| transcribing, || Ok(ToggleAction::Stop(owner.clone()))) + .unwrap(); + assert!(cancel); let transcribing_replacement = status::Status { state: "Transcribing".to_string(), session_id: Some("capture-b".to_string()), ..recording }; - assert!(!should_cancel_toggle(&transcribing_replacement, &owner)); + let (_, cancel) = decide_toggle_intent( + || transcribing_replacement, + || Ok(ToggleAction::Stop(owner)), + ) + .unwrap(); + assert!(!cancel); } #[test] From 6ce28470512220816e82f79d73bdcaec8461ab0d Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 14:16:09 +0200 Subject: [PATCH 3/3] ci: rebuild after stalled dependency installation