fix(agent-server): kill setsid escapees holding pipe write-end (#548) - #619
fix(agent-server): kill setsid escapees holding pipe write-end (#548)#619dolho wants to merge 2 commits into
Conversation
The canvas is inside v-if="voice.isActive.value" so canvasEl.value is null when onMounted fires. renderFrame() exits early without scheduling the next frame, killing the loop permanently. Replace onMounted initialization with watch(canvasEl) so the RAF loop starts when the canvas enters the DOM and stops when it leaves. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Async Stop hooks (e.g. git-push spawning ssh) and some MCP launchers fork detached children via setsid(), placing them in a brand-new session that killpg(pgid) cannot reach. Those children keep our stdout/stderr pipe write-ends open, so drain_reader_threads' post-kill natural-drain window expires without seeing EOF and the final result JSON line is dropped. Executions are recorded as failed with "0 tool calls" even when tools ran. Add _kill_pipe_write_holders(): a Linux-only /proc/*/fd scan keyed on the pipe inode of our subprocess.Popen pipes. After the group kill in drain_reader_threads, SIGKILL any non-self process holding a writable handle to one of our pipes. Inode keying makes this race-safe across concurrent executions in the same container — every Popen allocates a fresh pipe with a unique inode. Regression test test_kills_setsid_escapee_holding_pipe spawns a parent that forks a grandchild which os.setsid()'s and then writes heartbeats to stdout. Asserts drain completes in <3s, proving the kernel EOF'd via the helper instead of falling through to the force-close path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end verification in production base imageBuilt ResultWhat this proves
Coverage summary
|
|
Base branch needs to be retargeted before merge. This PR targets `main` directly. Per the Trinity SDLC, all feature branches must land through `dev` first:
Please change the base branch to `dev`: ```bash The inode-keyed /proc scan is solid and the Linux-only guard is correct — this is the only change needed before merge. Reminder for whoever merges this: run `./scripts/deploy/build-base-image.sh` after merge — the fix lives in `docker/base-image/` and won't reach running agents until the base image is rebuilt. |
|
Closing as superseded. Both changes in this PR are already in
No code from this PR was lost. Safe to close. |
Summary
_kill_pipe_write_holders()tosubprocess_pgroup.py— a Linux-only/proc/*/fdscan that SIGKILLs processes holding the write end of our subprocess pipesdrain_reader_threads()after the existingterminate_process_group(), before the post-kill natural-drain windowgit push→sshcallingsetsid()) escape the process group, keep stdout/stderr open, and cause executions to be recorded asfailedwith"0 tool calls"even when tools ranRoot cause
killpg(pgid, SIGKILL)only reaches members of the captured process group. Async hooks and some MCP launcherssetsid()their children into a brand-new session, invisible to that signal. Those orphans keep our pipe write-ends open;drain_reader_threads()'s 30s natural-drain timer expires without seeing EOF, the force-close path runs, and the final{"type":"result"}JSON line is discarded from the kernel buffer.Fix
Inode-keyed
/proc/*/fdscan: collect the inode(s) ofprocess.stdout/process.stderr(we own the read ends, sofstatis reliable), walk every PID, look for a matchingpipe:[N]symlink whosefdinfoflags indicate a writer (access_mode != O_RDONLY), SIGKILL it. Inode keying makes this race-safe across concurrent executions in the same container — eachPopengets a fresh pipe with a unique inode, so a stuck execution never kills writers belonging to another execution.Behaviour on macOS / non-Linux: helper returns
0immediately (no/proc).Test plan
tests/unit/test_subprocess_pgroup.py::TestKillPipeWriteHolders::test_kills_setsid_escapee_holding_pipe— full repro: parent forks grandchild that callsos.setsid()and writes heartbeats to stdout; assertsdrain_reader_threadscompletes in<3s(proving kernel EOF via helper, not the force-close fallback)test_returns_zero_on_no_writers— read-end-only pipe → 0 killstest_handles_none_and_closed_pipes— None / closed handles skipped silently14 passed in 3.30sFixes #548