Skip to content

fix(acp): kill agent process trees synchronously on quit - #389

Merged
xintaofei merged 2 commits into
xintaofei:mainfrom
noxenys:fix-process-cleanup
Jul 30, 2026
Merged

fix(acp): kill agent process trees synchronously on quit#389
xintaofei merged 2 commits into
xintaofei:mainfrom
noxenys:fix-process-cleanup

Conversation

@noxenys

@noxenys noxenys commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Fixes the ~30s orphan window after app quit: agent CLI subprocesses (and their own children — MCP servers, forked node, etc.) linger after the app closes and only self-exit when they independently notice stdin EOF.

Problem

The graceful teardown chain is correct but doesn't get to run at process exit:

  • run_connection is driven on a dedicated std::thread (not a tokio task).
  • The kill happens in sacp-tokio's ChildGuard::dropkill_tree, which only runs when that driver thread unwinds.
  • ConnectionManager::disconnect_all is fire-and-forget: it sends ConnectionCommand::Disconnect to each connection and returns immediately.
  • Tauri's ExitRequested handler returns right after, the process terminates, and the driver threads are killed mid-flight — usually before they reach ChildGuard::drop. The agent CLI is reparented and lingers (~30s for Claude Code / node until stdin EOF).

Not a permanent leak, but the window means quit-then-relaunch leaves many orphaned node.exe alive, and a user watching Task Manager sees processes that look stuck.

Change

  1. vendor/sacp-tokio: add AcpAgent::on_spawn(cb) — invokes cb(pid) once with the child's OS pid right after spawn. Non-breaking (new optional field defaulting to None; the child is still owned by ChildGuard).

  2. connection.rs: attach on_spawn to store the pid in a new AgentConnection.child_pid: Arc<AtomicU32> (0 = not spawned / unknown).

  3. manager.rs: disconnect_all now snapshots the pids, fires every Disconnect, sleeps a DISCONNECT_ALL_GRACE (500ms) window to let the graceful drops win the race, then kill_trees any survivor on a spawn_blocking thread. Already-dead pids are a best-effort no-op (logged at debug), so cleanly-closed connections cost nothing.

Notes

  • Backstop only touches pids this manager spawned + their descendants.
  • kill_tree::blocking is already used by ChildGuard::drop; no new deps or features.
  • 500ms is a const (DISCONNECT_ALL_GRACE) — tune if quit feels sluggish.

Test plan

  • cargo build --release
  • Quit app with several active agent sessions → Task Manager shows no residual node.exe (previously several, gone only after ~30s)
  • Normal single-connection disconnect still exits cleanly within the window (backstop kill_tree is a no-op)

noxenys and others added 2 commits July 28, 2026 18:40
ChildGuard::drop -> kill_tree never runs at process exit: run_connection
is driven on a dedicated std::thread and disconnect_all is fire-and-forget,
so when Tauri's ExitRequested handler returns the process terminates those
threads before the Drop fires. Agent CLIs (and their children -- MCP servers,
forked node) reparent and linger until stdin EOF (~30s for Claude Code / node).

Record each agent subprocess's OS pid at spawn via a new sacp-tokio on_spawn
callback, then in disconnect_all fire every Disconnect, wait a 500ms grace
window so the graceful ChildGuard::drop wins where it can, and synchronously
kill_tree any survivor on a blocking thread. kill_tree on an already-dead pid
is a best-effort no-op, so cleanly-closed connections cost nothing.

- vendor/sacp-tokio: AcpAgent::on_spawn(cb) publishes the child pid; new
  optional field defaults to None (non-breaking). All 3 constructors updated.
- connection.rs: AgentConnection.child_pid: Arc<AtomicU32> (0 = unknown).
- manager.rs: disconnect_all grace window + spawn_blocking kill_tree backstop;
  test-helper construction sites updated.
- lifecycle.rs: test-helper construction site updated.
The backstop recorded each agent pid at spawn and never cleared it, so a
connection that closed inside the grace window still had its already-reaped
pid killed 500ms later — a number the OS is free to have handed to an
unrelated process tree by then (worst on Windows, where pid reuse is
aggressive and the tree walk follows snapshot ppid links). Pids were also
read before the window, so a connection whose agent was still launching
when quit began was skipped — exactly the orphan the backstop exists for.

The pid is now published and retracted by the layer that owns the process.
The vendored ChildGuard keeps the Child until it observes a real reap and
reports that through a new AcpAgent::on_exit callback, where the connection
zeroes its recorded pid. It deliberately does not report an exit merely
because the connection ended: kill_tree signals without waiting, so the
agent can outlive its driver and still needs the backstop. Letting the
child fall into tokio's orphan queue instead would have it reaped out of
sight, leaving the host holding a pid that no longer names it.

disconnect_all now reads each pid from its live cell inside the kill loop
rather than from an up-front snapshot, so it kills precisely the trees
still running, and broadcasts with try_send so a connection with a full
command queue cannot park the quit before the backstop runs.

CI now also runs the vendored crate's unit tests, which the workspace
cargo test never reached.
@xintaofei

Copy link
Copy Markdown
Owner

Reviewed this and pushed a follow-up commit (3998630) rather than just leaving notes — the diagnosis here is right, and the graceful-teardown chain really doesn't get to run at exit, but the backstop as written had two problems and one of them made it fire in the wrong direction.

1. The recorded pid was never retracted. disconnect_all drains the map first and then sleeps, so a connection that closes cleanly inside the window still has its pid on the kill list — and by then the process has been killed and reaped. The comment says as much (Already-dead pid is the common, expected case), which means every quit rolled the dice on kill_tree-ing a number the OS had already handed to someone else. That's the exact hazard supervise.rs:195 guards against with WORKER_PID.store(0) after waitpid.

The fix has to live in the layer that owns the process, and "the driver returned" is not "the agent is gone": kill_tree only signals (SIGTERM on Unix) and doesn't wait. So the vendored ChildGuard now keeps the Child until it observes a real reap and reports that through a new AcpAgent::on_exit, paired with your on_spawn. A still-running agent keeps its pid published and still gets swept; only an observed reap clears it. Letting the child fall into tokio's orphan queue instead isn't an option — Reaper::drop (tokio 1.49 process/unix/reap.rs:117) pushes it there unconditionally and reaps it out of sight, which would leave us holding a pid that no longer names it.

2. Pids were read before the grace window. A connection still Connecting when quit begins publishes its pid after the drain, so the snapshot read 0 and skipped it — precisely the orphan this is for. The Arcs live across the sleep (the senders are only borrowed, never consumed), so the load moved into the kill loop.

Also switched the shutdown broadcast to try_send: it's serial send().await on a bounded channel today, so a connection with a full queue could park the quit before the backstop ever runs — the wedged connection whose tree most needs killing.

Tests. Three in manager.rs (tree kill; pid published during the window; cleared pid left alone) — I checked they're not vacuous by reverting disconnect_all to its current form, where two of the three fail. Three in the vendored crate covering the exit contract, including an agent that ignores SIGTERM and must keep its pid published. That crate is a path dependency and not a workspace member, so cargo test never reached its tests; added a CI step so it does — drop that hunk if you'd rather keep it out.

Verified at 3998630: clippy --all-targets --features test-utils -- -D warnings clean, cargo test --features test-utils --lib 1871 passed / 0 failed, vendor --lib 12 passed (5 consecutive runs, the timing-sensitive ones are stable). I couldn't build the tests/ integration binaries or the server cell locally (ran out of disk) — CI covers both, and nothing here is cfg(feature)-gated.

One residual, deliberately not addressed here: between loading a live pid and issuing the kill, that process could exit and its number be reused. It's microseconds now instead of a 500ms window on a pid we expected to be dead, and it's inherent to killing a tree — descendants aren't our children, so no handle or pidfd binds their identity. Fixing it properly means containment at spawn (Job Object on Windows, setsid + killpg on Unix), which is a launch-semantics change and belongs in its own PR.

Separately: codeg-server never calls disconnect_all at all, so the same orphans persist there. Out of scope for this, worth a follow-up.

@xintaofei

Copy link
Copy Markdown
Owner

感谢PR

@xintaofei
xintaofei merged commit 4bacba9 into xintaofei:main Jul 30, 2026
7 checks passed
xintaofei added a commit that referenced this pull request Jul 30, 2026
- feat(settings): **Kimi Code with your own API key now gets a reasoning picker.** A Reasoning card in the panel declares which levels to offer and which one to start on — until now only subscription accounts had the option.
- fix(acp): **A long-running command no longer freezes the conversation.** Agents that run their commands through the ACP terminal — Grok among them — got stuck halfway through a session with Stop doing nothing, because a command that never exits on its own blocked everything behind it. Such a command now runs in the background with its output streaming as it comes, and the turn stays interruptible. Agents that run commands themselves, Claude Code and Codex included, were never affected (#394).
- fix(codex): **Codex searches read like searches.** A `rg` or `grep` shows as "Grep <pattern>" with results grouped per file and every line clickable to open it there, instead of a raw JSON blob. Claude, Cline, Gemini, OpenCode and Cursor searches get the same view.
- fix(grok): **A command Grok runs in the background finally shows what it did.** Each task gets a card with the command, its status, exit code and terminal output, where it used to leave an empty one. Grok's internal notices no longer cut a reply in half either.
- fix(composer): **File and command references show as badges wherever text lands in the composer.** Quick messages, restored drafts, queued-message edits, expert and Office templates and saved automations used to leave raw `[label](file:…)` text behind. A Windows path also stops gaining a backslash every time the message is reopened.
- fix(acp): **Quitting codeg actually shuts the agents down.** Agent CLIs and the processes they start no longer linger after the window closes — thanks to @noxenys for the fix (#389).
- chore(acp): **Updated the built-in agents.** OpenCode 1.18.10, Cline 3.0.47, CodeBuddy 2.130.0, Kimi Code 0.31.0 and Grok 0.2.114.

-----------------------------

# 发布版本 0.22.2

- 功能(设置):**用自己 API Key 的 Kimi Code 也有了思考强度选择。** 面板新增「推理」卡片,用于声明提供哪些强度、默认用哪一档——此前只有订阅账号才有这个选项。
- 修复(智能体):**长时间运行的命令不再把整个会话冻住。** 通过 ACP 终端执行命令的智能体(Grok 就是其一)会聊到一半卡住、点「停止」也没反应,因为一条不会自己结束的命令堵住了它后面的一切。这类命令现在在后台运行,输出边跑边显示,回合随时可以打断。自己执行命令的智能体(包括 Claude Code 与 Codex)从来不受此影响(#394)。
- 修复(Codex):**Codex 的搜索终于长得像搜索。** 一次 `rg` 或 `grep` 不再是一坨原始 JSON,而是显示为「Grep <关键词>」,结果按文件分组,每一行都能点击跳转。Claude、Cline、Gemini、OpenCode 与 Cursor 的搜索也同样受益。
- 修复(Grok):**Grok 在后台跑的命令,终于能看到它干了什么。** 每个任务都有自己的卡片,包含命令、状态、退出码和终端输出,而不再是一张空卡片。Grok 的内部通知也不会再把回复从中间劈开。
- 修复(输入框):**只要文本被填进输入框,文件与命令引用就会显示成徽章。** 快捷消息、恢复的草稿、编辑排队中的消息、专家与办公模板、已保存的自动化任务,过去都还留着 `[label](file:…)` 的原始写法。Windows 路径也不会每重新打开一次就多出一层反斜杠了。
- 修复(智能体):**退出 codeg 时智能体真的会被关掉。** 智能体 CLI 及它拉起的进程不会再在窗口关闭后继续残留——感谢 @noxenys 贡献的修复(#389)。
- 维护(智能体):**更新内置智能体版本。** OpenCode 1.18.10、Cline 3.0.47、CodeBuddy 2.130.0、Kimi Code 0.31.0、Grok 0.2.114。
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.

2 participants