Skip to content

Session guard can leak permanently if tool execution is abandoned during a permission ask #410

Open

Description

@ranxianglei

来源: #404 / PR #408 分析时发现

Problem

PR #404 wraps the entire compress/decompress tool execution in registry.withSessionGuard(toolCtx.sessionID, ...). That guarded region includes prepareSession() → toolCtx.ask(...), i.e. the host permission prompt, which can wait an unbounded amount of time (user away from keyboard).

The guard releases in a finally block. If the host ever abandons tool execution without rejecting the execute promise (session deleted, user aborts the turn, process-level cancel that drops the continuation instead of settling it), the finally never runs and that session's guard chain never releases.

Impact: every subsequent same-session operation (transforms, tools, event-hook saves, command handlers) awaits the guard forever → the session wedges until the opencode process restarts. Cross-session work is unaffected (per-session chains).

Why it is filed separately

  • It is pre-existing exposure made visible by the new invariant: before fix: serialize same-session state initialization and transforms #404 a stuck ask only stuck that one tool call; now it would stick the whole session.
  • Whether opencode v1 actually abandons (vs rejects) interrupted tool executions could not be verified from this repo — needs live verification against the v1 runtime.

Suggested directions

  1. Verify v1 interrupt semantics live (abort a session while a compress permission dialog is open; observe whether execute settles).
  2. If abandonment is possible: narrow the guard scope to exclude the ask await (acquire around state mutation/persistence only), or add a watchdog/timeout that force-releases the chain entry.
  3. Add a regression test simulating an unsettled tool execution asserting the next same-session transform still completes.

Activity

  1. ranxianglei commented on Sep 16, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 Triage update — verified the exposure at the code level, implementing a fix now

    What I verified

    • PR fix: serialize same-session state initialization and transforms (#404) #408 (open, unmerged) wraps the entire compress/decompress execute() body in registry.withSessionGuard(sessionId, () => run()) — lib/compress/range.ts:375, lib/compress/decompress.ts:415.
    • run() starts with prepareSession / prepareDecompressSession, whose first statement is await toolCtx.ask(...) — the interactive permission prompt (lib/compress/pipeline.ts:59, lib/compress/decompress.ts:47). Every state mutation (ensureSessionInitialized, assignMessageRefs) happens after the ask.
    • createSessionGuard (lib/state/state.ts) releases the chain entry only in a finally after await fn(). Traced it: if fn() never settles, release() never runs, the stored chain promise stays pending forever, and every later same-session withSessionGuard call hangs at await previous. Cross-session keys are unaffected. Matches your described impact exactly.

    Root cause (not symptom)
    The guard couples lock-release to the guarded body settling, but the guarded body contains an unbounded, externally-abandonable await (ask). Holding a serialization lock across a user-facing permission prompt is wrong twice over: (1) it wedges the whole session if the prompt is abandoned, and (2) even when it settles normally, it blocks all legitimate same-session work for as long as the user is away from the keyboard.

    Fix (your primary suggested direction — narrow the scope)
    Move ask (and the sync metadata title call) outside the guard in both tools; the guard then covers only the bounded state-mutating transaction (fetch → init → mutate → persist). This kills the reported wedge with no magic timeout/timer. I'm also adding a regression test that drives the real compress tool with an abandoned (never-settling) ask and asserts the next same-session guard operation still completes — it fails on current #408 code and passes with the fix.

    Not done / needs you
    Live v1 verification (abort a session with the permission dialog open, observe whether execute settles) — I don't have a live v1 runtime here. Note the fix is robust regardless of v1's exact semantics, since it removes the dependence on the prompt settling. Residual: any other abandonable await inside the guarded region (e.g. if v1 abandons mid-fetch/mid-persist instead of at the prompt) would still wedge — closing that fully needs the live answer, or a deliberate watchdog decision (left out to avoid a magic-number timeout that could itself break serialization).

    I'm stacking the fix on PR #408's head (the guard lives there, and it hasn't merged). PR link coming shortly.

    中文一句话:已代码级确认 PR #408 把权限询问 ask 包进会话锁、ask 被放弃会导致整会话永久卡死是真实缺陷;修复是把 ask 移出锁范围(只锁有界的状态变更事务)+ 回归测试,正堆叠在 #408 上。

  2. ranxianglei commented on Sep 16, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    Confirmed real, root-caused, fixed, and shipped as a stacked PR.

    Verification (per triage rules)

    • Repro: reproduced against the real createSessionGuard — a same-session task whose body never settles leaves the next same-session task hanging forever (WEDGE CONFIRMED). Cross-session is unaffected, exactly as reported.
    • Layering: the whole-session wedge you described is the consequence; the root cause is that fix: serialize same-session state initialization and transforms (#404) #408's guard spans an abandonable await. prepareSession()'s first statement is await toolCtx.ask(...) — the interactive permission prompt — which can wait unbounded and can be abandoned without settling. Because the guard releases in a finally, an unsettled ask means the body never returns → the lock is never released → every later same-session op awaits the guard forever.
    • Fix direction: adopted your primary suggestion (narrow the scope) rather than a watchdog — rationale below.

    Fix (PR #413)
    Move the abandonable ask outside the guard in both tools; only the bounded state-mutating transaction stays serialized:

    • lib/compress/pipeline.ts: new exported requestCompressPermission(toolCtx, title) owns the ask + metadata({title}) side effect; prepareSession(...) gains an optional { preApproved?: boolean } (default false, so existing direct/test callers are unchanged).
    • lib/compress/range.ts / lib/compress/decompress.ts: compute the title, call requestCompressPermission outside withSessionGuard, and pass { preApproved: true } into the guarded body.

    Bonus: removes the unnecessary lock hold during normal prompt review — a user reading a permission dialog no longer blocks legitimate same-session work.

    Why not a watchdog/timeout: a magic timeout risks force-releasing a legitimately long-but-alive transaction (a large session fetch/persist), which would reintroduce #404's stale-write corruption. Narrowing the scope has no such failure mode.

    Testing

    • +2 regression tests in tests/session-guard.test.ts drive the real compress-range and decompress tools with a never-settling toolCtx.ask and assert a concurrent same-session withSessionGuard still completes. Verified red→green (reverting the fix makes them fail).
    • tsc --noEmit clean; full suite 1273/0. Dual-agent code + test review (AGENTS.md §5.3/§5.6): both approve with nits (addressed — added the decompress mirror test and documented the concurrent-prompts behavior delta).

    Residual (documented in devlog/2026-09-16_session-guard-abandoned-ask/WORKLOG.md §5): if v1 abandons a call at some other abandonable await inside the guarded region (e.g. mid-fetch / mid-persist), the same leak class would still apply. Fully closing that needs either live verification of v1's interrupt semantics (not possible from this sandbox) or a watchdog decision — I left it out of this PR deliberately. Happy to split it into a follow-up issue if you'd like it tracked.

    PR #413 is stacked on #408 (base = its branch) so the diff shows only the incremental change; it folds in cleanly once #408 lands. Merge is yours to do.

    中文摘要:确认该泄漏真实存在(用真实 createSessionGuard 复现了"同会话后续操作永久卡死"),根因是 #408 的会话守卫把可被宿主放弃的权限询问 ask() 包进了临界区,导致 finally 永不触发、锁永不释放;修复按你的首选方案把 ask 移到守卫之外、只让有界的状态事务保持串行(不引入魔法超时,避免复活 #404 的陈旧写风险);已补红绿回归测试、tsc 与全量 1273/0 通过、双 agent review 通过,以堆叠 PR #413 提交,待你合并。

  3. ranxianglei commented on Sep 17, 2026

    @ranxianglei
    OwnerAuthor

    继续

  4. ranxianglei commented on Oct 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    查重:不重复(上一轮已确认,无同症状+同机制的其他条目)。

    本轮「继续」做的事:把两个 PR 追平到最新 master(v1.18.5)并验证。它们停在 5 周前的 v1.18.0 基点上,期间 master 合入了大量改动(#478 图片归档、#485 紧急截断改 opt-in+sticky 回放、smart-plan 可见消息记录等),直接合并会冲突。

    完成项:

    1. master 并入 fix: serialize same-session state initialization and transforms (#404) #408 分支:唯一冲突在 lib/hooks.ts——fix: serialize same-session state initialization and transforms (#404) #408 把整个 transform 流水线移进了 runPipeline(),而 master 在旧的内联位置新增了三个阶段。解法:把 master 的三个阶段逐字移植进 runPipeline,移植后与 master 原文逐行比对(除缩进外字节一致);自动合并的三个文件(range.ts/decompress.ts/state.ts)逐一审计,与 master 的差集恰好只有 fix: serialize same-session state initialization and transforms (#404) #408 的 guard 包装本身,无丢失无污染。
    2. 更新后的 fix: serialize same-session state initialization and transforms (#404) #408 并入 fix(#410): move compress/decompress permission ask outside the per-session guard #413 分支:零冲突;核对 fix(#410): move compress/decompress permission ask outside the per-session guard #413 核心结构完好——权限询问移到锁外、锁内传 preApproved 避免二次询问。
    3. 定向验证(全量套件交给 CI):typecheck 通过;session-guard 8/8、e2e transform 流水线 32/32、decompress/range 相关 105 个测试中 1 个失败——是既有问题,见下条。
    4. 顺手小修(独立标注 commit):inactive-block-decompress.test.ts 一个用例硬编码 /tmp 路径,而 decompress 工具按 os.tmpdir() 校验路径——任何 TMPDIR 被重定向的环境都会挂(纯 master 上同样复现,CI 因 os.tmpdir() 恰为 /tmp 抓不到)。已改用 os.tmpdir() 构造路径,7/7 绿。

    当前卡点:推送失败——本地代码镜像的 git 服务持续返回 HTTP 502(同期 REST API 正常,判断为 git 服务侧瞬态故障),约 15 分钟内多轮重试未恢复。两条分支的提交已全部就绪在本地,服务恢复后立即推送并回报。这是基础设施问题,不是代码问题。

    八问:

    1. 是什么问题:权限询问进行中被放弃时,每会话串行化锁永久泄漏,该会话此后所有操作挂死(不重启不可用)。fix: serialize same-session state initialization and transforms (#404) #408 是其前置修复(同会话并发 init/transform 互相覆盖)。
    2. 什么原因导致:锁释放绑定在「事务体执行完」上,而事务体里含可被放弃的 await(权限询问)——放弃后 finally 永不执行、队列条目永不 resolve,后续所有同会话操作永远在排队。
    3. 影响范围与严重程度:仅 compress.permission=ask 且询问中途被中断的会话(默认 allow 模式不触发);一旦触发该会话彻底不可用,属会话无法恢复类 → p1。
    4. 怎么修复的:把权限询问移出锁(先问、再进锁执行事务、锁内标记 preApproved 免二次询问);刻意不加超时看门狗——魔法超时强放可能挤掉合法长事务,重新引入 fix: serialize same-session state initialization and transforms #404 的陈旧写破坏。
    5. 是否有回归:本次追平本身无新回归——流水线体与 master 逐字一致,三个自动合并文件的差集恰好是 guard 包装;行为变化仅限两个 PR 此前已披露的内容(同会话操作由并发变串行)。
    6. 是否破坏缓存:不涉及。串行化不改变任何缓存/重试/增量游标策略;ask 移出锁不改变其时序语义(仍在执行前询问)。
    7. 是否改了设计方向:不涉及。仍是链式 FIFO 互斥方案,只是把 ask 边界外移。
    8. 是否引入新配置:不涉及。

    标签(本环境 API 未实现标签端点,列出拟打值):bug / opencode / scope/kernel / p1(会话无法恢复类,按 2026-10-19 规则一律 p1)。

    判定:代码侧闭环(两分支就绪+定向验证全绿),待镜像 git 服务恢复即推送;推送后 CI 绿,合并顺序 #408 先行 → #413 跟进(合并由人来点)。

  5. ranxianglei commented on Oct 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 状态更新(接上一条):

    推送仍未完成。后台重试窗口(60 次、间隔 2 分钟、共约 2 小时)已全部耗尽,最后一次直推仍为同一错误:镜像 git 服务返回 HTTP 502(REST API 同期正常)。故障已持续 2.5 小时以上,不再是瞬态抖动——需要运维侧查看镜像的 git 服务。

    代码侧状态不变,全部就绪在本地两条分支上:

    git 服务恢复后(下次唤醒或收到「继续」)立即推送两条分支并回报,随后进入 CI 观察。在那之前没有更多可推进的代码工作——不重复空转重试。

  6. ranxianglei commented on Oct 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 推送完成(接状态更新):

    镜像 git 服务持续不可用超过 11 小时,已改走直连 GitHub 推送,两条分支均已落地并核对远端 ref:

    CI 现状:#408 的四个检查(pr-validation / test×2 / build)已触发但全部排队中——Actions runner 队列积压,约 12 分钟未开始执行;#413 尚未被 CI 拾取(同一队列)。结果出来我再核一遍并回报。

    待办只剩人工动作:CI 绿后按顺序合并 #408 先行 → #413 跟进(合并按钮由人来点)。镜像 git 服务恢复与否不影响这两条 PR,但建议运维仍要排查。

  7. ranxianglei commented on Oct 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 CI 状态更新(含一个新发现):

    #413 之前没有 CI 不是队列问题,是 base 设错了。 它此前 stack 在 #408 分支上(base = #408 的分支),而本仓库三个工作流(ci / pr-checks / pr-artifact)都只监听 pull_request → branches: [master]——stacked PR 永远不会触发任何检查。已处理:

    #408 @ b285315:Build Artifact 已绿;CI + PR Checks 仍在 runner 队列等待。其前三个 tip(571ccf0b / 2d817b1 / 8340afa)均为全绿记录。

    两条 PR 现在都在 CI 可见、排队执行的状态。结果出来我再核并回报;合并顺序不变——#408 先行 → #413 跟进,合并按钮由人来点。

  8. ranxianglei commented on Oct 23, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 排队原因查明(不是卡死):GitHub 组织级并发槽位被同账号其他仓库的 CI 占满(当前有 4 个兄弟仓库 CI 在跑),本仓库的测试任务在队列里等槽位。证据:#408 的 Build Artifact 任务从入队到跑完用了约 18 分钟(已绿),CI / PR Checks 还在等下一个空槽;兄弟仓库的在途 CI 最早已跑了 2 小时,陆续结束后这两条 PR 的检查会依次落地。无需干预,结果出来我继续核并回报。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions