Skip to content

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

Merged
appergb merged 1 commit into
developfrom
fix/cancel-races-coord
Apr 30, 2026
Merged

fix(coordinator): 关闭 cancel race 两处窗口期 (Codex HIGH #1 #2)#79
appergb merged 1 commit into
developfrom
fix/cancel-races-coord

Conversation

@appergb
Copy link
Copy Markdown
Collaborator

@appergb appergb commented Apr 30, 2026

修两条 audit HIGH

HIGH #1 — begin_session await 期间 cancel 被覆盖

:新增 cancel_raced_during_starting helper + BeginOutcome 三态枚举(Started/PendingStop/CancelRaced),ASR open + Recorder start 后原子检查,CancelRaced 时清理资源并回 Idle。

HIGH #2 — cancelled check 与 inserter.insert 不原子

:新增 SessionPhase::Inserting + 在同一 lock 内做 check + 转换。cancel_sessionInserting 拒绝介入(Cmd+V 已发出,撤销不掉)。

Test

  • cargo check 通过
  • 录音中按 Esc → 不插入
  • Processing 阶段按 Esc → 不插入
  • 极快按下 Esc 同时录音停止 → 不会出现显示已取消但实际插入

Summary by Sourcery

Handle cancel/stop races in session lifecycle to keep UI state and actual insertion behavior consistent.

Bug Fixes:

  • Prevent begin_session from overwriting a cancel that occurs while awaiting ASR open or recorder start by detecting cancel races and aborting startup with proper cleanup.
  • Eliminate the race between the final cancel check and text insertion by introducing an Inserting session phase and ignoring cancel requests once insertion has begun.

Enhancements:

  • Introduce a BeginOutcome enum and helper to centralize begin_session race handling and outcomes across starting, pending stop, and cancel-raced paths.
  • Extend SessionPhase with an Inserting state and adjust cancel_session behavior to respect this terminal phase.

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。
@appergb appergb merged commit bcd4d81 into develop Apr 30, 2026
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 30, 2026

Reviewer's Guide

This PR hardens the coordinator’s session lifecycle against two cancel-vs-start/insert race conditions by adding an explicit Inserting phase, a BeginOutcome enum plus a cancel_raced_during_starting helper, and by making key phase transitions and cancel checks happen atomically under the state lock with proper rollback of ASR/recorder resources when a race is detected.

Sequence diagram for begin_session with cancel race handling

sequenceDiagram
    actor User
    participant Coordinator
    participant Inner
    participant InnerState
    participant ASR
    participant Recorder

    User->>Coordinator: begin_session(inner)
    Coordinator->>InnerState: lock state
    InnerState-->>Coordinator: phase = Starting
    Coordinator-->>InnerState: unlock state

    Coordinator->>ASR: open_session()
    par
        ASR-->>Coordinator: open_session completes
    and
        User->>Coordinator: cancel_session(inner)
        Coordinator->>InnerState: lock state
        InnerState-->>Coordinator: phase, cancelled
        Coordinator->>InnerState: set cancelled = true or phase = Idle
        Coordinator-->>InnerState: unlock state
    end

    Coordinator->>Coordinator: cancel_raced_during_starting(inner)
    Coordinator->>InnerState: lock state
    InnerState-->>Coordinator: cancelled, phase
    alt cancel_raced_during_starting is true
        Coordinator-->>InnerState: unlock state
        Coordinator->>ASR: cancel()
        Coordinator->>InnerState: lock state
        InnerState->>InnerState: phase = Idle
        Coordinator-->>InnerState: unlock state
        Coordinator-->>User: Ok(())
    else cancel_raced_during_starting is false
        Coordinator-->>InnerState: unlock state
        Coordinator->>Recorder: start(consumer, level_handler)
        Recorder-->>Coordinator: Ok(rec)
        Coordinator->>InnerState: lock state
        alt state.cancelled or phase != Starting
            Coordinator->>InnerState: determine BeginOutcome = CancelRaced
            Coordinator-->>InnerState: unlock state
            Coordinator->>Recorder: rec.stop()
            Coordinator->>Inner: lock asr
            alt ActiveAsr::Volcengine
                Coordinator->>ASR: cancel()
            else ActiveAsr::Whisper
                Coordinator->>ASR: cancel()
            end
            Coordinator->>InnerState: lock state
            InnerState->>InnerState: phase = Idle
            Coordinator-->>InnerState: unlock state
            Coordinator-->>User: Ok(())
        else pending_stop is true
            Coordinator->>InnerState: phase = Listening
            Coordinator->>InnerState: pending_stop = false
            Coordinator->>InnerState: determine BeginOutcome = PendingStop
            Coordinator-->>InnerState: unlock state
            Coordinator->>Inner: lock recorder
            Inner->>Inner: recorder = Some(rec)
            Coordinator-->>Inner: unlock recorder
            Coordinator->>Coordinator: end_session(inner)
            Coordinator-->>User: result of end_session
        else no pending_stop
            Coordinator->>InnerState: phase = Listening
            Coordinator->>InnerState: pending_stop = false
            Coordinator->>InnerState: determine BeginOutcome = Started
            Coordinator-->>InnerState: unlock state
            Coordinator->>Inner: lock recorder
            Inner->>Inner: recorder = Some(rec)
            Coordinator-->>Inner: unlock recorder
            Coordinator-->>User: Ok(())
        end
    end
Loading

Class diagram for updated session lifecycle types and helpers

classDiagram
    class SessionPhase {
        <<enum>>
        Idle
        Starting
        Listening
        Processing
        Inserting
    }

    class BeginOutcome {
        <<enum>>
        Started
        PendingStop
        CancelRaced
    }

    class InnerState {
        SessionPhase phase
        bool cancelled
        bool pending_stop
    }

    class Inner {
        Mutex~InnerState~ state
        Mutex~Option~ recorder
        Mutex~Option~ asr
    }

    class ActiveAsr {
        <<enum>>
        Volcengine
        Whisper
    }

    class Coordinator {
        +async begin_session(inner : Arc~Inner~) Result~(), String~
        +async end_session(inner : Arc~Inner~) Result~(), String~
        +cancel_session(inner : Arc~Inner~) void
        +cancel_raced_during_starting(inner : Arc~Inner~) bool
    }

    Inner o-- InnerState : has
    Inner o-- ActiveAsr : holds
    Coordinator --> Inner : operates_on
    Coordinator --> BeginOutcome : uses
    InnerState --> SessionPhase : uses
Loading

File-Level Changes

Change Details Files
Make begin_session robust to cancel races during ASR open and recorder start, with explicit outcome handling and cleanup.
  • Introduce BeginOutcome enum (Started/PendingStop/CancelRaced) to represent recorder-start outcomes including pending-stop and cancel-raced cases.
  • After ASR open_session completes, call cancel_raced_during_starting to detect if cancel_session has already flipped cancelled or phase, and if so cancel ASR, reset phase to Idle, and abort begin_session.
  • On Recorder::start success, take the state lock once to both transition from Starting to Listening and consume pending_stop, or detect a cancel race and return a corresponding BeginOutcome instead of always forcing Listening.
  • On CancelRaced outcome, stop the recorder, cancel any active ASR backend, and reset state.phase to Idle without entering Listening; on Started/PendingStop, store the recorder and immediately end_session if PendingStop was set.
openless-all/app/src-tauri/src/coordinator.rs
Close the cancel vs insert race by adding an Inserting phase and making the final cancel check + phase transition atomic, while preventing cancel_session from intervening once insertion has begun.
  • Extend SessionPhase with an Inserting variant representing the window where inserter.insert is about to run or running, with commentary that cancel is no longer reversible here.
  • In end_session, perform the final cancelled check and phase transition inside a single state lock, either discarding polished output and setting phase to Idle or transitioning to Inserting and proceeding to inserter.insert.
  • Update cancel_session to early-return (with logging) if the current phase is Inserting so it does not set cancelled during an in-progress paste, avoiding a UI state where the session shows cancelled but text was already inserted.
openless-all/app/src-tauri/src/coordinator.rs
Introduce a small helper to centralize the "cancel raced during Starting" check under the state lock.
  • Add cancel_raced_during_starting(inner: &Arc) -> bool helper that locks state and returns true if cancelled is set or phase is no longer Starting, with comments explaining it must be used immediately before the next side effect.
  • Use this helper in begin_session between awaits to gate proceeding with ASR attachment when a cancel race has occurred.
openless-all/app/src-tauri/src/coordinator.rs

Possibly linked issues

  • #: PR adds atomic cancel checks and an Inserting phase so Esc during Processing discards polished output before insert.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@appergb appergb deleted the fix/cancel-races-coord branch April 30, 2026 06:05
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In begin_session, the CancelRaced path after open_session calls asr.cancel() but does not clear inner.asr, whereas the CancelRaced branch after Recorder::start uses inner.asr.lock().take(); consider unifying the cleanup so you don’t leave a stale ASR handle around in one of the early-abort cases.
  • In cancel_session, you read phase without a lock and then later lock only to mutate cancelled; since you’ve introduced a new Inserting phase that’s checked via that unlocked read, it would be safer and simpler to perform the phase check and cancelled mutation within a single state lock to avoid TOCTOU issues.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `begin_session`, the CancelRaced path after `open_session` calls `asr.cancel()` but does not clear `inner.asr`, whereas the CancelRaced branch after `Recorder::start` uses `inner.asr.lock().take()`; consider unifying the cleanup so you don’t leave a stale ASR handle around in one of the early-abort cases.
- In `cancel_session`, you read `phase` without a lock and then later lock only to mutate `cancelled`; since you’ve introduced a new `Inserting` phase that’s checked via that unlocked read, it would be safer and simpler to perform the phase check and `cancelled` mutation within a single `state` lock to avoid TOCTOU issues.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

appergb pushed a commit that referenced this pull request Apr 30, 2026
包含本轮所有合并:
- Codex 终审两条 HIGH (cancel race) 修复 (PR #79)
- 6 个 Cooper-X-Oak/Codex bot PRs 自动合并 (#44 #49 #53 #68 #72 #73)
- 2 个有冲突 PR 本地 rebase 后合并 (#66 cancel + 空转写并存 / #67 Windows docs)
- README 破图修复 (PR #80)
- workflow-scope 受限的 #48 + #75 由用户在 GitHub UI 直接合并

3 处版本字段同步:package.json + tauri.conf.json + Cargo.toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant