fix(coordinator): 关闭 cancel race 两处窗口期 — Codex audit HIGH #1 + HIGH #2 (#79)

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。

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-04-30 14:05:04 +08:00
1 parent d0d2da27a5
commit bcd4d815c8
1 file changed
+91 -14
+91 -14
View File
@@ -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<Inner>) -> 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<dyn crate::recorder::AudioConsumer> = Arc::new(AsrBridge {
asr: Arc::clone(&asr),
});
@@ -410,18 +424,43 @@ async fn begin_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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<Inner>) {
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<Inner>) -> Vec<String> {
/// 用户点 ✕ / ✓ / 中途出错 / 按 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<Inner>) -> bool {
let state = inner.state.lock();
state.cancelled || state.phase != SessionPhase::Starting
}
fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {