fix(acp): kill agent process trees synchronously on quit - #389
Conversation
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.
|
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. The fix has to live in the layer that owns the process, and "the driver returned" is not "the agent is gone": 2. Pids were read before the grace window. A connection still Also switched the shutdown broadcast to Tests. Three in Verified at 3998630: 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, Separately: |
|
感谢PR |
- 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。
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_connectionis driven on a dedicatedstd::thread(not a tokio task).sacp-tokio'sChildGuard::drop→kill_tree, which only runs when that driver thread unwinds.ConnectionManager::disconnect_allis fire-and-forget: it sendsConnectionCommand::Disconnectto each connection and returns immediately.ExitRequestedhandler returns right after, the process terminates, and the driver threads are killed mid-flight — usually before they reachChildGuard::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.exealive, and a user watching Task Manager sees processes that look stuck.Change
vendor/sacp-tokio: addAcpAgent::on_spawn(cb)— invokescb(pid)once with the child's OS pid right after spawn. Non-breaking (new optional field defaulting toNone; the child is still owned byChildGuard).connection.rs: attachon_spawnto store the pid in a newAgentConnection.child_pid: Arc<AtomicU32>(0 = not spawned / unknown).manager.rs:disconnect_allnow snapshots the pids, fires everyDisconnect, sleeps aDISCONNECT_ALL_GRACE(500ms) window to let the graceful drops win the race, thenkill_trees any survivor on aspawn_blockingthread. Already-dead pids are a best-effort no-op (logged at debug), so cleanly-closed connections cost nothing.Notes
kill_tree::blockingis already used byChildGuard::drop; no new deps or features.DISCONNECT_ALL_GRACE) — tune if quit feels sluggish.Test plan
cargo build --releasenode.exe(previously several, gone only after ~30s)