From e95519a6b418dd023b2bfad85f826f8a0084f738 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 8 May 2026 10:39:33 +0800 Subject: [PATCH] Prevent stale recorder events from matching wrapped session ids The dictation coordinator used a wrapping u64 counter to guard delayed recorder and startup events. Replacing that guard with UUID-backed session ids removes the theoretical wraparound collision while keeping the existing stale-event comparison model. Constraint: Issue #297 requests the UUID-based fix rather than checked arithmetic.\nRejected: checked_add with warning | still preserves a numeric boundary condition instead of eliminating the collision class.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml coordinator::tests:: --lib\nNot-tested: Full cargo test --lib still has unrelated existing failure in types::tests::legacy_hotkey_trigger_still_produces_effective_key_codes.\nRelated: #297 --- openless-all/app/src-tauri/src/coordinator.rs | 158 +++++++++++------- 1 file changed, 98 insertions(+), 60 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d02f97d8..62998908 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -16,6 +16,16 @@ use parking_lot::Mutex; use tauri::{async_runtime, AppHandle, Emitter, Manager}; use uuid::Uuid; +type SessionId = Uuid; + +fn new_session_id() -> SessionId { + Uuid::new_v4() +} + +fn initial_session_id() -> SessionId { + Uuid::nil() +} + #[cfg(target_os = "windows")] use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; use crate::asr::{ @@ -75,12 +85,12 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool { } struct SessionResource { - session_id: u64, + session_id: SessionId, resource: T, } impl SessionResource { - fn new(session_id: u64, resource: T) -> Self { + fn new(session_id: SessionId, resource: T) -> Self { Self { session_id, resource, @@ -102,10 +112,10 @@ struct SessionState { /// 跳过 history.append。issue #52。 cancelled: bool, focus_target: Option, - /// 单调递增的 session id。begin_session 自增。 + /// 每次 begin_session 生成新的 UUID session id。 /// recorder error monitor 持有 captured id,处理时若与当前不等说明 /// 是上一 session 的迟到错误,必须 drop,不要 abort 当前 active session。 - session_id: u64, + session_id: SessionId, /// 用户开始 dictation 时所处的前台 app 标签("Mail (com.apple.mail)" / Windows 窗口标题)。 /// 用作 LLM polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 front_app: Option, @@ -133,7 +143,7 @@ impl Default for SessionState { pending_stop: false, cancelled: false, focus_target: None, - session_id: 0, + session_id: initial_session_id(), front_app: None, } } @@ -214,7 +224,7 @@ struct QaSessionState { selection: Option, front_app: Option, /// 用于忽略迟到的 RMS / runtime error。 - session_id: u64, + session_id: SessionId, /// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。 pinned: bool, /// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag; @@ -232,7 +242,7 @@ impl Default for QaSessionState { cancelled: false, selection: None, front_app: None, - session_id: 0, + session_id: initial_session_id(), pinned: false, panel_visible: false, messages: Vec::new(), @@ -243,7 +253,7 @@ impl Default for QaSessionState { #[cfg(target_os = "windows")] #[derive(Debug)] struct PreparedWindowsImeSessionSlot { - session_id: u64, + session_id: SessionId, prepared: PreparedWindowsImeSession, } @@ -1700,7 +1710,10 @@ fn request_stop_during_starting(inner: &Arc, reason: &str) { stop_recorder_if_pending_start_stop(inner); } -fn take_session_resource(slot: &mut Option>, session_id: u64) -> Option { +fn take_session_resource( + slot: &mut Option>, + session_id: SessionId, +) -> Option { if slot .as_ref() .map(|resource| resource.session_id == session_id) @@ -1712,11 +1725,11 @@ fn take_session_resource(slot: &mut Option>, session_id: u } } -fn store_asr_for_session(inner: &Arc, session_id: u64, asr: ActiveAsr) { +fn store_asr_for_session(inner: &Arc, session_id: SessionId, asr: ActiveAsr) { *inner.asr.lock() = Some(SessionResource::new(session_id, asr)); } -fn take_asr_for_session(inner: &Arc, session_id: u64) -> Option { +fn take_asr_for_session(inner: &Arc, session_id: SessionId) -> Option { let mut slot = inner.asr.lock(); take_session_resource(&mut slot, session_id) } @@ -1732,13 +1745,13 @@ fn cancel_active_asr(asr: ActiveAsr) { } } -fn cancel_asr_for_session(inner: &Arc, session_id: u64) { +fn cancel_asr_for_session(inner: &Arc, session_id: SessionId) { if let Some(asr) = take_asr_for_session(inner, session_id) { cancel_active_asr(asr); } } -fn store_recorder_for_session(inner: &Arc, session_id: u64, recorder: Recorder) { +fn store_recorder_for_session(inner: &Arc, session_id: SessionId, recorder: Recorder) { *inner.recorder.lock() = Some(SessionResource::new(session_id, recorder)); } @@ -1804,19 +1817,19 @@ fn stop_qa_recorder(inner: &Arc) { } } -fn take_recorder_for_session(inner: &Arc, session_id: u64) -> Option { +fn take_recorder_for_session(inner: &Arc, session_id: SessionId) -> Option { let mut slot = inner.recorder.lock(); take_session_resource(&mut slot, session_id) } -fn stop_recorder_for_session(inner: &Arc, session_id: u64) { +fn stop_recorder_for_session(inner: &Arc, session_id: SessionId) { if let Some(recorder) = take_recorder_for_session(inner, session_id) { recorder.stop(); release_recording_mute(inner, "dictation"); } } -fn discard_startup_resources_for_session(inner: &Arc, session_id: u64) { +fn discard_startup_resources_for_session(inner: &Arc, session_id: SessionId) { stop_recorder_for_session(inner, session_id); cancel_asr_for_session(inner, session_id); } @@ -1949,9 +1962,9 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { state.pending_stop = false; state.cancelled = false; state.focus_target = capture_focus_target(); - // 自增 session_id;spawn 出去的 recorder error monitor 会捕获这个值, + // 新建 UUID session_id;spawn 出去的 recorder error monitor 会捕获这个值, // 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。 - state.session_id = state.session_id.wrapping_add(1); + state.session_id = new_session_id(); state.front_app = capture_frontmost_app(); if let Some(label) = state.front_app.as_deref() { log::info!("[coord] front_app captured: {label}"); @@ -2168,7 +2181,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { fn start_recorder_for_starting( inner: &Arc, - session_id: u64, + session_id: SessionId, active_asr: &str, consumer: Arc, ) -> Result<(), String> { @@ -2341,7 +2354,7 @@ fn abort_recording_with_error(inner: &Arc, message: String) { struct RecordingAbort { elapsed: u64, - session_id: u64, + session_id: SessionId, } fn begin_recording_abort_before_restore(state: &mut SessionState) -> Option { @@ -2360,7 +2373,7 @@ fn begin_recording_abort_before_restore(state: &mut SessionState) -> Option, - session_id: u64, + session_id: SessionId, active_asr: &str, consumer: Arc, ) -> Result<(), String> { @@ -2377,7 +2390,7 @@ async fn start_recorder_and_enter_listening( Ok(()) } -async fn finish_starting_session(inner: &Arc, session_id: u64) { +async fn finish_starting_session(inner: &Arc, session_id: SessionId) { // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 @@ -2938,7 +2951,7 @@ fn cancel_session(inner: &Arc) { #[cfg(target_os = "windows")] fn store_prepared_windows_ime_session( slots: &mut Vec, - session_id: u64, + session_id: SessionId, prepared: PreparedWindowsImeSession, ) { slots.retain(|slot| slot.session_id != session_id); @@ -2951,7 +2964,7 @@ fn store_prepared_windows_ime_session( #[cfg(target_os = "windows")] fn take_matching_prepared_windows_ime_session( slots: &mut Vec, - session_id: u64, + session_id: SessionId, ) -> Option { let index = slots .iter() @@ -2962,8 +2975,8 @@ fn take_matching_prepared_windows_ime_session( #[cfg(target_os = "windows")] fn take_current_prepared_windows_ime_session_for_restore( slots: &mut Vec, - session_id: u64, - current_session_id: u64, + session_id: SessionId, + current_session_id: SessionId, ) -> Option { let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; if current_session_id == session_id { @@ -2974,7 +2987,7 @@ fn take_current_prepared_windows_ime_session_for_restore( } #[cfg(target_os = "windows")] -fn restore_prepared_windows_ime_session(inner: &Arc, session_id: u64) { +fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { let state = inner.state.lock(); let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); @@ -2990,12 +3003,12 @@ fn restore_prepared_windows_ime_session(inner: &Arc, session_id: u64) { } #[cfg(not(target_os = "windows"))] -fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: u64) {} +fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: SessionId) {} #[cfg(target_os = "windows")] async fn insert_with_windows_ime_first( inner: &Arc, - session_id: u64, + session_id: SessionId, polished: &str, restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, @@ -3219,12 +3232,12 @@ fn foundry_local_asr_release_keep_secs(inner: &Arc) -> u32 { } #[cfg(target_os = "windows")] -fn foundry_release_session_is_current(inner: &Arc, session_id: u64) -> bool { +fn foundry_release_session_is_current(inner: &Arc, session_id: SessionId) -> bool { inner.state.lock().session_id == session_id } #[cfg(target_os = "windows")] -fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { +fn schedule_foundry_local_asr_release(inner: &Arc, session_id: SessionId) { let keep_secs = foundry_local_asr_release_keep_secs(inner); let runtime = Arc::clone(&inner.foundry_local_runtime); let inner = Arc::clone(inner); @@ -3507,7 +3520,7 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { } state.phase = QaPhase::Recording; state.cancelled = false; - state.session_id = state.session_id.wrapping_add(1); + state.session_id = new_session_id(); state.front_app = capture_frontmost_app(); state.selection = None; } @@ -3999,6 +4012,10 @@ mod tests { use super::*; use crate::types::HotkeyTrigger; + fn session_id(n: u128) -> SessionId { + Uuid::from_u128(n) + } + #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { let _ = env_logger::builder() @@ -4122,7 +4139,7 @@ mod tests { old_session_id )); - coordinator.inner.state.lock().session_id = old_session_id.wrapping_add(1); + coordinator.inner.state.lock().session_id = new_session_id(); assert!(!foundry_release_session_is_current( &coordinator.inner, @@ -4214,11 +4231,11 @@ mod tests { let mut state = SessionState::default(); state.phase = SessionPhase::Listening; state.cancelled = false; - state.session_id = 7; + state.session_id = session_id(7); let abort = begin_recording_abort_before_restore(&mut state).unwrap(); - assert_eq!(abort.session_id, 7); + assert_eq!(abort.session_id, session_id(7)); assert!(state.cancelled); assert_eq!(state.phase, SessionPhase::Listening); @@ -4233,14 +4250,14 @@ mod tests { { let mut state = coordinator.inner.state.lock(); state.phase = SessionPhase::Inserting; - state.session_id = 41; + state.session_id = session_id(41); } handle_pressed_edge(&coordinator.inner).await; let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Inserting); - assert_eq!(state.session_id, 41); + assert_eq!(state.session_id, session_id(41)); } #[tokio::test] @@ -4298,17 +4315,17 @@ mod tests { #[cfg(target_os = "windows")] fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { let mut slots = vec![PreparedWindowsImeSessionSlot { - session_id: 2, + session_id: session_id(2), prepared: PreparedWindowsImeSession::unavailable(), }]; - assert!(take_matching_prepared_windows_ime_session(&mut slots, 1).is_none()); + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_none()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), - vec![2] + vec![session_id(2)] ); - assert!(take_matching_prepared_windows_ime_session(&mut slots, 2).is_some()); + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(2)).is_some()); assert!(slots.is_empty()); } @@ -4316,18 +4333,26 @@ mod tests { #[cfg(target_os = "windows")] fn prepared_windows_ime_sessions_keep_overlapping_snapshots() { let mut slots = Vec::new(); - store_prepared_windows_ime_session(&mut slots, 1, PreparedWindowsImeSession::unavailable()); - store_prepared_windows_ime_session(&mut slots, 2, PreparedWindowsImeSession::unavailable()); + store_prepared_windows_ime_session( + &mut slots, + session_id(1), + PreparedWindowsImeSession::unavailable(), + ); + store_prepared_windows_ime_session( + &mut slots, + session_id(2), + PreparedWindowsImeSession::unavailable(), + ); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), - vec![1, 2] + vec![session_id(1), session_id(2)] ); - assert!(take_matching_prepared_windows_ime_session(&mut slots, 1).is_some()); + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_some()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), - vec![2] + vec![session_id(2)] ); } @@ -4335,13 +4360,26 @@ mod tests { #[cfg(target_os = "windows")] fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() { let mut slots = Vec::new(); - store_prepared_windows_ime_session(&mut slots, 1, PreparedWindowsImeSession::unavailable()); - store_prepared_windows_ime_session(&mut slots, 2, PreparedWindowsImeSession::unavailable()); + store_prepared_windows_ime_session( + &mut slots, + session_id(1), + PreparedWindowsImeSession::unavailable(), + ); + store_prepared_windows_ime_session( + &mut slots, + session_id(2), + PreparedWindowsImeSession::unavailable(), + ); - assert!(take_current_prepared_windows_ime_session_for_restore(&mut slots, 1, 2).is_none()); + assert!(take_current_prepared_windows_ime_session_for_restore( + &mut slots, + session_id(1), + session_id(2) + ) + .is_none()); assert_eq!( slots.iter().map(|slot| slot.session_id).collect::>(), - vec![2] + vec![session_id(2)] ); } @@ -4400,10 +4438,10 @@ mod tests { let mut state = SessionState::default(); state.phase = SessionPhase::Starting; state.cancelled = false; - state.session_id = 2; + state.session_id = session_id(2); assert_eq!( - startup_race_status(&state, 1), + startup_race_status(&state, session_id(1)), StartupRaceStatus::StaleContinuation ); } @@ -4417,11 +4455,11 @@ mod tests { "model".to_string(), )); *coordinator.inner.asr.lock() = Some(SessionResource::new( - 2, + session_id(2), ActiveAsr::Whisper(Arc::clone(&newer_asr)), )); - discard_startup_resources_for_session(&coordinator.inner, 1); + discard_startup_resources_for_session(&coordinator.inner, session_id(1)); assert_eq!( coordinator @@ -4430,10 +4468,10 @@ mod tests { .lock() .as_ref() .map(|resource| resource.session_id), - Some(2) + Some(session_id(2)) ); - discard_startup_resources_for_session(&coordinator.inner, 2); + discard_startup_resources_for_session(&coordinator.inner, session_id(2)); assert!(coordinator.inner.asr.lock().is_none()); } @@ -4484,7 +4522,7 @@ enum StartupRaceStatus { StaleContinuation, } -fn startup_race_status(state: &SessionState, captured_session_id: u64) -> StartupRaceStatus { +fn startup_race_status(state: &SessionState, captured_session_id: SessionId) -> StartupRaceStatus { if state.session_id != captured_session_id { StartupRaceStatus::StaleContinuation } else if state.cancelled || state.phase != SessionPhase::Starting { @@ -4499,13 +4537,13 @@ fn startup_race_status(state: &SessionState, captured_session_id: u64) -> Startu /// 「准备做下一步副作用前」用。 fn startup_race_status_for_starting( inner: &Arc, - captured_session_id: u64, + captured_session_id: SessionId, ) -> StartupRaceStatus { let state = inner.state.lock(); startup_race_status(&state, captured_session_id) } -fn set_phase_idle_if_session_matches(inner: &Arc, session_id: u64) { +fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) { let mut state = inner.state.lock(); if state.session_id == session_id { state.phase = SessionPhase::Idle;