Skip to content

Kill processes left in a destroyed worktree environment - #1696

Open
SawyerHood wants to merge 2 commits into
mainfrom
bb/investigate-1647-thr_7vuiqfqzqf
Open

Kill processes left in a destroyed worktree environment#1696
SawyerHood wants to merge 2 commits into
mainfrom
bb/investigate-1647-thr_7vuiqfqzqf

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

Fixes #1647

Problem

environment.destroy closed terminals with pty.kill() and stopped provider CLIs with child.kill("SIGTERM"). Both signal only the direct child pid. Grandchildren (dev servers from an agent's Bash tool, MCP servers, nohup/disowned jobs) survived git worktree remove with a cwd in the deleted directory. Reproduction: SIGTERM to a sh -c 'sleep 300 & wait' child, then remove its cwd; /proc/<pid>/cwd shows <path> (deleted) for the sleep.

Change

  • @bb/process-utils: add supportsProcessGroups, killProcessGroup, listProcessesWithCwdUnder, and killProcessesWithCwdUnder (Linux /proc/*/cwd; macOS lsof -d cwd; no-op on Windows).
  • Provider CLIs (runtime-provider-process.ts), ACP agents (agent-connection.ts), and setup scripts (provisioning.ts) spawn as process-group leaders and receive group signals on shutdown. The setup script already did this; the helper is now shared.
  • Terminal close/force-close/shutdown signal the pty process group.
  • RuntimeManager.destroyEnvironment sweeps and kills every process still rooted in a managed workspace (SIGTERM, 2 s grace, SIGKILL) before it removes the directory, and logs the reaped pids. forgetEnvironment and idle eviction do not sweep because the workspace stays on disk.

No server/daemon wire change, so no protocol version bump.

Tests

  • packages/process-utils/test/process-tree.test.ts: group kill reaps a grandchild; the cwd sweep finds and kills a detached session leader and its child, ignores the current process and sibling directories.
  • apps/host-daemon/src/runtime-manager.test.ts: destroyEnvironment kills a detached process rooted in a managed workspace.
  • Ran turbo run typecheck test for @bb/process-utils, @bb/host-workspace, @bb/agent-runtime, @bb/host-daemon: all pass.

AGENT GENERATED: by Claude Opus 5

Environment teardown only signalled the shell and provider CLI pids. Their
children (dev servers, MCP servers, background jobs) survived with a cwd in
the removed directory. Spawn provider CLIs, ACP agents, and setup scripts as
process-group leaders and signal the group; signal the pty process group on
terminal close; and sweep every process still rooted in a managed workspace
before its directory is removed.

Fixes #1647

Co-Authored-By: Claude <noreply@anthropic.com>

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 SLOP COP 🚨 · review

I am the Slop Cop. I started my review of this process-cleanup change.

I will check security, code quality, performance, tests, and architectural duplication. I will post one final review after these checks finish.

Comment thread packages/process-utils/src/index.ts Outdated
for (const target of signalled) {
if (isProcessAlive(target.pid)) {
try {
process.kill(target.pid, "SIGKILL");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 slopcop/review — The final SIGKILL can target an unrelated process after PID reuse.

The code waits two seconds and then checks only PID existence. The operating system can reuse an exited target's PID during that wait.

Save the process start identity. Verify that identity and the workspace path before each signal. Use a pidfd on Linux when possible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b3f6d2e. The sweep now rescans before each signal and only SIGKILLs pids that a fresh scan still shows rooted in the workspace. A reused pid with a cwd elsewhere no longer receives a signal.

Comment thread packages/process-utils/src/index.ts Outdated
}
let directory = resolve(args.directory);
try {
directory = await realpath(directory);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 slopcop/review — A changed workspace symlink can redirect this process sweep.

realpath() follows the workspace root at destroy time. Worktree code can replace that root with a symlink before destruction.

BB can then signal unrelated processes under the symlink target. Store the canonical device and inode after provision. Reject identity changes before the scan.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Mitigated in b3f6d2e. The sweep resolves the parent path and skips the sweep when the root itself is a symlink. Storing device/inode at provision is more than this change needs: BB owns the worktree root, and a process that can swap it already runs on the host.

): AcpAgentConnection {
const child: ChildProcess = spawn(options.command, options.args, {
cwd: options.cwd,
detached: supportsProcessGroups(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 slopcop/review — The ACP agent now leaves the bridge process group.

Runtime shutdown signals the outer bridge group. The bridge handles a closed input stream, but it has no SIGTERM handler.

The ACP agent group can therefore survive daemon shutdown or idle eviction. Add signal handlers that stop all sessions and escalate when necessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b3f6d2e. I reverted the ACP agent to stay in the bridge process group. The bridge itself is now a group leader, so the runtime's group signal reaches the bridge, the agent, and their children.

Comment thread packages/process-utils/src/index.ts Outdated
export async function killProcessesWithCwdUnder(
args: KillProcessesWithCwdUnderArgs,
): Promise<ProcessWithCwd[]> {
const targets = await listProcessesWithCwdUnder({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 slopcop/review — One current-directory snapshot does not stop processes that appear during shutdown.

I reproduced this race. A target handled SIGTERM, created a new-session child, and exited. This helper returned while the new child remained alive.

Repeat the scan until no matching process remains. An owned process-group or operating-system job boundary would give stronger control.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b3f6d2e. killProcessesWithCwdUnder now loops: scan, SIGTERM, wait, rescan, SIGKILL, and repeat until a scan is empty (bounded to 5 rounds). A test covers a target that spawns a setsid child on SIGTERM.

new Promise<void>((resolve) => {
const timer = setTimeout(() => {
providerProcess.child.kill("SIGKILL");
killProcessGroup({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 slopcop/review — The group escalation stops when only the leader exits.

This exit handler clears the SIGKILL timer without checking the process group. A direct probe confirmed that a group member can remain alive.

Keep the timer until kill(-pgid, 0) reports no group. Send SIGKILL to the group after the grace period.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in b3f6d2e. On leader exit both shutdown paths check kill(-pgid, 0) and keep the SIGKILL timer while group members remain.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 SLOP COP 🚨 · review

Plain-English summary: This PR tries to stop commands, terminals, and agents that remain after BB deletes a worktree.

The process-group helper is a useful shared abstraction. The runtime manager also keeps environment policy in the correct host-daemon layer.

I found five important correctness and safety defects:

  1. A replaced workspace symlink can redirect cleanup toward unrelated processes.
  2. A process can create a new child after the single current-directory scan. I reproduced this survivor race.
  3. PID reuse during the grace period can send SIGKILL to an unrelated process.
  4. ACP agents now enter a separate group that outer provider shutdown does not reach.
  5. Provider escalation stops when the group leader exits, even if other group members remain.

The macOS implementation also scans every host process with lsof for each destroyed environment. Concurrent destroys multiply that cost.

The focused Turbo typechecks passed for all four affected packages. The focused Turbo test run failed in the new process-tree test.

That test reached its five-second timeout under the normal parallel package run. A separate run passed, which shows a load-sensitive test.

The source dev app loaded in Chromium. I created and deleted a managed environment through the local lifecycle path. The worktree was removed.

The direct race probe still left a new-session child alive after cleanup. The inline comments contain the specific fixes.

Architecture note: Keep process primitives in @bb/process-utils. Add one verified group-termination operation and reuse the existing process-identity checks.

Do not use a current directory as the only ownership boundary. Use tracked process groups or operating-system jobs when the platform supports them.

…oup escalation

- killProcessesWithCwdUnder rescans before each signal and repeats until a
  scan is empty, so reused pids and processes that appear during shutdown
  never receive a stale signal.
- The sweep skips a workspace root that is itself a symlink.
- Provider shutdown keeps the SIGKILL timer while group members outlive the
  leader.
- ACP agents stay in the bridge process group so the bridge group kill
  reaches them.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

Deleting a worktree environment leaves its processes running

1 participant