From 7f28b63cb53a75f89bc64b793ace7ed6cb8faac1 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 30 Apr 2026 14:04:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(coordinator):=20=E5=85=B3=E9=97=AD=20cancel?= =?UTF-8?q?=20race=20=E4=B8=A4=E5=A4=84=E7=AA=97=E5=8F=A3=E6=9C=9F=20?= =?UTF-8?q?=E2=80=94=20Codex=20audit=20HIGH=20#1=20+=20HIGH=20#2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes Codex audit blockers on PR #78 ## HIGH #1 — begin_session 内 await 期间 cancel 被覆盖 背景:volcengine open_session().await + Recorder::start 都是异步,期间用户按 Esc 调用 cancel_session 把 phase 改回 Idle 并 cancelled=true。但原代码后续无 条件 `state.phase = SessionPhase::Listening`,把 Idle 又翻回 Listening → 用户的 cancel 被吞掉,session 仍然继续。 修: - 新增 `cancel_raced_during_starting(inner)` helper:持锁查 cancelled or phase != Starting - ASR open_session().await 后调用:如已 race,asr.cancel() + 回 Idle - Recorder::start Ok 分支用 BeginOutcome { Started / PendingStop / CancelRaced } 原子在同一 lock 内决定,CancelRaced 时清理 recorder + asr 资源不进 Listening ## HIGH #2 — cancelled check 与 inserter.insert 之间的窗口 背景:end_session 检查 cancelled 后到调用 inserter.insert 之间释放了 lock, 此时 Esc 触发 cancel_session 设 cancelled=true 已经晚了 — Cmd+V 即将发出, 撤销不掉。但 cancel_session 仍然 emit "已取消" → UI 与实际行为相反(已插入但 显示已取消)。 修: - 新增 SessionPhase::Inserting:表示「已过最后一次 cancel 检查、即将/正在 调用 inserter.insert」的窗口 - end_session:把 polish 后的 cancel check + phase 转换 atomic 在同一 lock 内:cancelled → Idle + return;否则 → phase=Inserting 后 release lock 走 insert - cancel_session:phase==Inserting → 直接 return(不设 cancelled、不 emit 取 消)。理由:物理上无法撤销 Cmd+V,硬装"已取消"只会让 UI 撒谎 cargo check 通过,13 个 warnings 全部是 pre-existing。 --- openless-all/app/src-tauri/src/coordinator.rs | 105 +++++++++++++++--- 1 file changed, 91 insertions(+), 14 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 8594b86e..7dfacba3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -37,6 +37,11 @@ enum SessionPhase { Starting, Listening, Processing, + /// 已经过了最后一次 cancel 检查、即将 / 正在调用 inserter.insert 的窗口。 + /// cancel_session 在此阶段拒绝介入:Cmd+V 模拟点击已开始或已发出, + /// 无法撤销,硬把 cancelled=true 也救不回来,只会让 UI 出现 cancelled + /// 但实际还是插入了的诡异状态。详见 PR 修 Codex audit HIGH #2。 + Inserting, } enum ActiveAsr { @@ -364,6 +369,15 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); } + // open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session + // 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。 + // audit HIGH #1。 + if cancel_raced_during_starting(inner) { + log::info!("[coord] cancel raced during ASR open_session — aborting begin"); + asr.cancel(); + inner.state.lock().phase = SessionPhase::Idle; + return Ok(()); + } let c: Arc = Arc::new(AsrBridge { asr: Arc::clone(&asr), }); @@ -410,18 +424,43 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { match Recorder::start(consumer, level_handler) { Ok(rec) => { - *inner.recorder.lock() = Some(rec); - // 转 Listening 同时检查 Starting 期间是否积累了 pending_stop 边沿。 - // hold 模式快速松开 / toggle 快速双击会到这里:握手刚完就要立即停。 - let should_stop_immediately = { + // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 + // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle + // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 + let outcome = { let mut state = inner.state.lock(); - state.phase = SessionPhase::Listening; - std::mem::replace(&mut state.pending_stop, false) + if state.cancelled || state.phase != SessionPhase::Starting { + BeginOutcome::CancelRaced + } else { + state.phase = SessionPhase::Listening; + let pending = std::mem::replace(&mut state.pending_stop, false); + if pending { + BeginOutcome::PendingStop + } else { + BeginOutcome::Started + } + } }; - log::info!("[coord] session started (asr={})", active_asr); - if should_stop_immediately { - log::info!("[coord] applying pending_stop edge → end_session immediately"); - let _ = end_session(inner).await; + match outcome { + BeginOutcome::CancelRaced => { + log::info!("[coord] cancel raced during recorder start — aborting begin"); + rec.stop(); + if let Some(asr) = inner.asr.lock().take() { + match asr { + ActiveAsr::Volcengine(v) => v.cancel(), + ActiveAsr::Whisper(w) => w.cancel(), + } + } + inner.state.lock().phase = SessionPhase::Idle; + } + BeginOutcome::Started | BeginOutcome::PendingStop => { + *inner.recorder.lock() = Some(rec); + log::info!("[coord] session started (asr={})", active_asr); + if matches!(outcome, BeginOutcome::PendingStop) { + log::info!("[coord] applying pending_stop edge → end_session immediately"); + let _ = end_session(inner).await; + } + } } } Err(e) => { @@ -530,10 +569,22 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let hotword_strs = enabled_phrases(inner); let polished = polish_or_passthrough(&raw, mode, &hotword_strs).await; - // Polish 完成后再 check 一次:即使 polish 已经返回,只要还没插入,仍可丢弃。 - if inner.state.lock().cancelled { - log::info!("[coord] cancel detected after polish — discarding output (chars={})", polished.chars().count()); - inner.state.lock().phase = SessionPhase::Idle; + // 原子化最后一次 cancel 检查 + 转 Inserting: + // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, + // cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复, + // 之前 check 与 inserter.insert 之间有窗口期。 + let proceed_to_insert = { + let mut state = inner.state.lock(); + if state.cancelled { + state.phase = SessionPhase::Idle; + false + } else { + state.phase = SessionPhase::Inserting; + true + } + }; + if !proceed_to_insert { + log::info!("[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count()); return Ok(()); } @@ -606,6 +657,13 @@ fn cancel_session(inner: &Arc) { if phase == SessionPhase::Idle { return; } + // Inserting 阶段已经过了最后一次 cancel 检查 + 锁内转换,inserter.insert 即将 + // 或正在执行 → Cmd+V 已发出无法撤销。这里硬设 cancelled=true 只会让 UI 显示 + // "已取消" 但文本仍被插入,与用户预期相反。直接拒绝,让本次 session 走完。 + if phase == SessionPhase::Inserting { + log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); + return; + } // Processing 阶段 cancel 不能直接干掉 in-flight polish task(已经 await 了), // 但可以打 cancelled 标记,让 end_session 在插入前检查并丢弃结果。 inner.state.lock().cancelled = true; @@ -786,6 +844,25 @@ fn enabled_phrases(inner: &Arc) -> Vec { /// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。 const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; +/// begin_session 中各 await 之间的 cancel race 检查结果。 +enum BeginOutcome { + /// 正常进入 Listening。 + Started, + /// Starting 阶段积累了 pending_stop 边沿,应立即 end_session(hold 快速松开 / toggle 快速双击)。 + PendingStop, + /// 期间 cancel_session 触发(cancelled=true 或 phase 被外部改回 Idle)。 + /// 必须回滚 recorder + ASR 资源,不进 Listening。 + CancelRaced, +} + +/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 +/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 +/// 「准备做下一步副作用前」用。 +fn cancel_raced_during_starting(inner: &Arc) -> bool { + let state = inner.state.lock(); + state.cancelled || state.phase != SessionPhase::Starting +} + fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { let inner_clone = Arc::clone(inner); async_runtime::spawn(async move {