diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index 0f12b74..762cb17 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,18 +176,40 @@ 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> { + 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)) +} + +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() { - 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); } } @@ -1624,6 +1648,62 @@ 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, + }; + 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 + }; + let (_, cancel) = decide_toggle_intent( + || transcribing_replacement, + || Ok(ToggleAction::Stop(owner)), + ) + .unwrap(); + assert!(!cancel); + } + #[test] fn fixture_returns_wav_without_opening_host() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/claude_code.wav");