Skip to content

fix(dev-mcp): sweep orphaned temp dirs left by killed processes - #6029

Open
cristiansotogarciaxatech wants to merge 8 commits into
block:mainfrom
cristiansotogarciaxatech:fix/dev-mcp-temp-dir-cleanup
Open

cristiansotogarciaxatech wants to merge 8 commits into
block:mainfrom
cristiansotogarciaxatech:fix/dev-mcp-temp-dir-cleanup

Conversation

@cristiansotogarciaxatech

Copy link
Copy Markdown

Fixes #6025.

buzz-dev-mcp creates two tempfile::TempDir per server start, the shim dir holding copies of buzz, rg, tree and the git helpers, and the session dir. Cleanup lives in Drop. These processes get killed rather than exiting gracefully, so Drop never runs and both directories are orphaned. On Windows a hard kill is not catchable at all, so there is no cleanup hook to hang this on in the first place. I measured 104 folders and 5.05 GB over 33 hours on one machine, at 97 MB per shim dir.

The approach, and why it is not an age sweep

The obvious fix is "delete anything older than 24 hours" and it is wrong. Sessions legitimately run for days, and an age cutoff would rip the binaries and key file out from under a live one. Mine run far longer than a day.

So each directory now gets a small ownership marker, .buzz-dev-mcp-owner, recording the creating process's pid and creation time. At startup, before creating its own directories, the server sweeps the system temp dir for buzz-dev-mcp- prefixed entries and removes one only when the marker's pid is positively confirmed dead.

Every uncertain case is left alone. No marker, corrupt marker, pid alive, or liveness undeterminable, all skipped. That failure direction is the safe one, worst case the leak persists for that directory. The sweep is best-effort end to end and never fails startup, including on a permission error against someone else's temp entry.

Liveness is nix::sys::signal::kill(pid, None) on Unix and OpenProcess / GetExitCodeProcess on Windows. No new dependencies. Both crates were already dependencies here for the existing KillGroup timeout-kill path. The Unix branch stays fully safe code, the Windows FFI sits behind #[allow(unsafe_code)] matching the existing pattern in shell.rs and shim.rs.

Known limitation, stated up front

Directories that predate this change have no marker, so they are left alone rather than guessed at. This PR stops the leak going forward, it does not reclaim the existing backlog. Reclaiming that safely needs a separate opt-in tool, and I did not want to smuggle a destructive one-off into a bug fix. Happy to follow up with one if you want it.

Tests

Seven new tests in sweep.rs, all pointed at a tempfile::tempdir() and never at the real system temp dir.

  • dead pid marker is swept
  • live pid marker is not swept
  • missing marker is left alone, documenting the legacy rule
  • corrupt marker is treated like a missing one
  • unrelated entries are ignored
  • sweep tolerates a temp root that cannot be read
  • sweep tolerates a permission denied entry, Unix only with a root-aware skip

The dead pid is obtained deterministically by spawning and reaping a trivial child rather than guessing an unused number.

Verification

rustfmt --check on changed files          clean
cargo clippy -p buzz-dev-mcp --all-targets --all-features -- -D warnings
                                          exit 0, no warnings
cargo test -p buzz-dev-mcp                125 passed, 0 failed

One caveat I would rather flag than hide. My local toolchain is x86_64-pc-windows-gnu, not the -msvc target CI uses, because MSVC Build Tools were not available on this machine. The windows-sys calls are target agnostic, but I could not verify against MSVC directly. I also could not cross-compile-check the Unix branch, so I reviewed it by hand, it only calls the safe nix wrapper.

Separately, two or three pre-existing shell::tests::* cases flake under parallel execution in my sandbox. I reproduced the identical flake on unmodified main with the same command, so it is not from this change. Running with --test-threads=1 is green.

@cristiansotogarciaxatech
cristiansotogarciaxatech requested a review from a team as a code owner August 16, 2026 11:24

@themiguelamador themiguelamador 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.

Requesting changes for three correctness issues:

  1. P1 — Windows can delete a live process's directory after an unrelated probe failure. The current OpenProcess branch maps every failure other than ERROR_ACCESS_DENIED to Dead. Errors such as resource exhaustion or transient system failures do not prove that the PID is absent, so this contradicts the module's fail-safe contract and can remove a live session's binaries. Only ERROR_INVALID_PARAMETER should establish a nonexistent PID; all other failures must remain Unknown.

  2. P1 — the new Unix test does not compile. It calls nix::unistd::Uid::effective(), but this crate's nix dependency does not enable the user feature. cargo test -p buzz-dev-mcp sweep::tests -- --test-threads=1 fails at compile time with cannot find Uid in unistd.

  3. P2 — that permission test would not exercise the claimed removal-error path anyway. Chmodding the target directory to 000 makes its marker unreadable first, so the sweep increments skipped_no_marker and never reaches remove_dir_all. Injecting the remover gives deterministic coverage of the actual error branch and works across platforms/root CI.

I prepared local signed commit a78022beb that makes only the invalid-PID error deletable, covers other errors as unknown, and replaces the permission-dependent test with deterministic removal failure injection.

Validation on that commit:

  • cargo test -p buzz-dev-mcp -- --test-threads=1: 104 passed
  • cargo clippy -p buzz-dev-mcp --all-targets --all-features -- -D warnings: passed
  • cargo fmt --all -- --check and git diff --check: passed
  • Windows cross-check was attempted, but this macOS host lacks the MSVC SDK headers and lib.exe; the extracted error classifier is host-tested.

@cristiansotogarciaxatech

Copy link
Copy Markdown
Author

All three are real. Fixed in a6b15a1.

1. Windows error classification. You are right and this one is my fault for writing an else where I meant a whitelist. The module's whole contract is that only positive proof of death authorizes a delete, and then the probe itself guessed Dead on any failure that was not ERROR_ACCESS_DENIED. Resource exhaustion is not evidence a pid is absent. Now only ERROR_INVALID_PARAMETER returns Dead and everything else returns Unknown, which is the same direction every other uncertain case already went.

I pulled the classification out into classify_open_process_error(err: u32) so it can be asserted on directly rather than needing a real system error to reproduce. Covered for ERROR_INVALID_PARAMETER, ERROR_ACCESS_DENIED, ERROR_NOT_ENOUGH_MEMORY, ERROR_NO_SYSTEM_RESOURCES and 0.

Worth noting the existing dead_pid_marker_is_swept test still passes under the stricter rule, which is the confirmation that a spawned and reaped child really does come back as ERROR_INVALID_PARAMETER rather than something else.

2. The test did not compile. Confirmed, and I reproduced it rather than take it on trust. crates/buzz-dev-mcp/Cargo.toml:44 pins nix with default-features = false, features = ["signal", "process"], and Uid sits inside feature! { #![feature = "user"] } at nix-0.31.3/src/unistd.rs:48. No crate in the workspace enables user, so there is no feature unification to save it either. Compiling that exact call against that exact feature set:

error[E0433]: failed to resolve: could not find `Uid` in `unistd`
note: found an item that was configured out
  --> nix-0.31.3/src/unistd.rs:56:12
   |
49 | #![feature = "user"]
   |    ---------------- the item is gated behind the `user` feature

My local toolchain is x86_64-pc-windows-gnu and the test was #[cfg(unix)], so it never got compiled on the machine I wrote it on. I said in the PR description that I reviewed the Unix branch by hand instead of compiling it. This is exactly what that was worth.

3. The test would not have covered the branch anyway. Also correct, and it is worse than not covering it. Chmod 000 on the target directory makes the marker unreadable first, so read_owner_marker returns None, the sweep counts skipped_no_marker and returns before it ever reaches remove_dir_all. stats.errors stays 0, so assert_eq!(stats.errors, 1) fails outright. It was not a weak test, it was a broken one.

Replaced with an injected remover via sweep_stale_dirs_with, which drives the real failure branch with no dependence on platform, uid or filesystem behaviour. One change from your version: I fail two directories instead of one and assert errors == 2. read_dir gives no ordering guarantee, so failing a single entry cannot prove the sweep kept going after a failure. Failing both proves it in either order.

Verification

cargo test  (sweep module, x86_64-pc-windows-gnu)          8 passed, 0 failed
cargo clippy --all-targets -- -D warnings  (windows)       clean
cargo clippy --target x86_64-unknown-linux-gnu
             --all-targets -- -D warnings                  clean
rustfmt --check crates/buzz-dev-mcp/src/sweep.rs           clean

Caveat, stated plainly because I would rather flag it than have you find it. My mingw toolchain broke today in a way unrelated to this change, so I could not run the full cargo test -p buzz-dev-mcp the way I did for the first commit. What I ran instead compiles sweep.rs verbatim against an identical dependency set, including the same nix features, and runs its tests. The rest of the crate is untouched by this commit. The Unix branch is now type-checked for real against x86_64-unknown-linux-gnu rather than reviewed by eye, which is the part that actually mattered here.

Signed-off-by: Cristian Soto Garcia cristian@xa-tech.com

@ravarora2 ravarora2 added the triage-ready Appropriate for agentic review label Aug 18, 2026
@ravarora2

Copy link
Copy Markdown
Contributor

🤖 Request changes at exact head a6b15a171b95c19ddbd57cc9540f87cbcab0ca3f.

This cleanup is useful and needed: hard-killed buzz-dev-mcp processes currently leave shim and session directories behind, and issue #6025 shows the resulting disk growth is substantial. The proposed PID-marker startup sweep addresses that ordinary leak, but the current implementation has two demonstrated production failures and does not yet have causal coverage for its destructive filesystem behavior.

Blocker 1: a dead MCP-server PID does not prove the directory is unused

The marker records only the buzz-dev-mcp server PID (crates/buzz-dev-mcp/src/sweep.rs:48-67). On Unix, however, the MCP server and its shell commands do not share one process group:

  • buzz-agent starts the MCP server in its own process group (crates/buzz-agent/src/mcp.rs:738-769).
  • buzz-dev-mcp starts each shell command in another process group (crates/buzz-dev-mcp/src/shell.rs:169-183,677-680).
  • The command group is normally killed by a Rust Drop guard (shell.rs:697-738), but SIGKILL prevents that destructor from running.

The resulting sequence is possible:

MCP server is hard-killed
shell command remains alive
replacement MCP sees the old server PID as Dead
Dead.may_delete() authorizes remove_dir_all
the surviving command loses its shim/session directory

The deletion path is sweep.rs:105-123,295-300. The surviving command still has the shim directory first in PATH (shell.rs:172-176), and that directory contains buzz, rg, tree, the Nostr-backed Git helpers, and the temporary key file (shim.rs:36-45,112-148).

I reproduced this against the exact PR binary: after killing and reaping the parent, the shell child was still alive; the replacement startup removed the marked directory; the child's delayed shim invocation then failed with No such file or directory. The same child succeeded when the sweep was omitted.

Proposed solution: make deletion depend on proof that every directory user has exited, not only the original server PID. A durable lease/lock held by the server and every spawned command is the clearest option; descendant/process-group ownership can also work if it remains valid across crash and restart. On Windows, check and handle failures from SetInformationJobObject and AssignProcessToJobObject at shell.rs:785-793; their return values are currently ignored.

Required regression: start the real MCP server, launch a delayed command that later invokes a shim tool, hard-kill the server, restart it, and prove the live command retains the directory and succeeds. After that command exits, a later startup should reclaim the directory.

Blocker 2: an unexpected marker type can block every MCP startup

This PR newly adds a synchronous scan before the server begins serving (crates/buzz-dev-mcp/src/lib.rs:180-202). It scans std::env::temp_dir() for any directory named buzz-dev-mcp-* (sweep.rs:252-274) and reads its marker with:

std::fs::read_to_string(dir.join(MARKER_FILE_NAME))

at sweep.rs:74-75.

That call follows symlinks, accepts non-regular files, has no byte limit, and waits for EOF. The scanner validates that the outer entry is a directory, but it does not prove that the marker inside is a small regular file created by Buzz.

I reproduced two failures with the exact PR binary in an isolated temp root:

  1. .buzz-dev-mcp-owner was a FIFO.
  2. .buzz-dev-mcp-owner was a symlink to a FIFO.

In both cases the pre-startup scan blocked and the MCP server never became ready. A symlink to an unbounded device can similarly cause unbounded reads. This is a local denial-of-service/reliability issue, not remote code execution; on a shared /tmp, another local account can create the entry, and on a per-user temp root another same-user process or corruption can do so.

Proposed solution:

  • place Buzz-owned temporary directories under a securely created owner-only per-user parent;
  • open markers without following symlinks and in nonblocking mode;
  • validate the opened handle is a regular file;
  • reject markers over a small limit such as 256 bytes;
  • perform a bounded read;
  • skip the directory on every mismatch or read error so startup continues.

The validation should be performed on the opened handle to avoid a check-then-replace race.

Required tests: FIFO marker, symlink marker, device marker, and oversized regular marker. Each case must be skipped without blocking, and the real server must still become ready.

Blocker 3: the committed tests do not protect the destructive policy or production wiring

The tests in sweep.rs:321-524 create ordinary directories and regular marker files themselves, then call sweep_stale_dirs directly. They verify the helper's happy path, but not the real startup integration.

The full package suite passed 103/103 even after each of these mutations:

  • allowing Liveness::Unknown to authorize deletion at sweep.rs:105-108;
  • removing the marker write from Shim::install at shim.rs:32;
  • removing the session marker write at shell.rs:46;
  • removing the production startup sweep call at lib.rs:184.

That means the test suite can remain green if the fail-closed invariant is weakened or the feature is disconnected entirely.

Proposed solution:

  • add a direct policy test proving only Dead, never Alive or Unknown, reaches removal;
  • add a subprocess test through the real binary covering startup, both real marker writes, hard kill, restart, preservation of a live owner/command, and reclamation only after all users exit;
  • mutation-check the three production integration points above.

Verification

  • Full cargo test -p buzz-dev-mcp: 103 passed, 0 failed.
  • Native format, all-target/all-feature check, and Clippy with -D warnings passed.
  • The changed sweep.rs, including its Windows-only test, type-checked for x86_64-pc-windows-msvc. A full Windows crate build was blocked by unchanged native dependencies requiring Windows CRT/linker tooling.
  • An ordinary lifecycle probe confirmed the intended basic behavior: live owner directories were preserved, dead owner directories were reclaimed, and replacement directories were created.
  • The hard-kill/live-child and FIFO/symlink failures above were independently reproduced at this exact head.

Bottom line: retain the cleanup feature, but do not merge this head. Strengthen ownership so a dead parent cannot invalidate a live command, harden marker reads as untrusted filesystem input, and add production-boundary tests that fail when either safety property or the real wiring is removed.

@cristiansotogarciaxatech

Copy link
Copy Markdown
Author

All three are real. Fixed in 621c2d1.

1. A dead owner pid is not proof the directory is idle

You are right about the mechanism, and one detail makes it slightly worse than the sequence you wrote. set_process_group calls cmd.process_group(0), so on Unix the command is in its own process group and a SIGKILL of the server never reaches it in the first place. The Drop guard that would have killed the group is exactly what a SIGKILL skips. The survivor keeps the shim dir first on PATH and can call buzz, rg, tree or the git helpers out of it minutes later. Checking the server pid answers a question nobody asked.

So the marker is no longer the only thing that authorizes a delete. Every directory this crate creates now carries a lease as well, and removal needs both facts to be positive:

  1. the owner pid is confirmed dead, and
  2. the lease is confirmed unheld.

The lease is a file inside the directory. On Unix the server opens it, takes flock(LOCK_SH), and clears FD_CLOEXEC so every command it spawns inherits the descriptor. An flock belongs to the open file description rather than to a process, so an inherited descriptor holds the same lock. The lease therefore stays held while the server or any command descended from it is alive, and the kernel drops it when the last of them exits. That includes the SIGKILL case, where no user-space cleanup runs at all, which is the entire reason this is a kernel primitive and not a heartbeat file. The sweep probes with flock(LOCK_EX|LOCK_NB). Success proves every descendant is gone. Every other outcome is not a delete.

The order inside claim_dir is load-bearing. It takes the lease first and writes the marker second, because the sweep treats "no marker" as "never touch this directory". A marked directory therefore always has a lease behind it, and a directory whose lease could not be taken gets no marker and is off-limits to the sweep forever. That is a leak, and a leak is the failure this module picks every single time.

One residual I would rather flag than have you find. The inherited descriptor is a low number, so a surviving command that explicitly reuses that exact descriptor after the server has already died, something like exec 3>file landing on it, would close the last reference and release the lease early. Moving the descriptor above 20 with F_DUPFD closes that off at the cost of a small block of unsafe around a raw fd. I left it out because the case needs a dead server and a command that reassigns that specific number, but say the word and it goes in.

Windows is deliberately not symmetric, and here is the argument

The lease handle on Windows is opened denying FILE_SHARE_DELETE and is not marked inheritable. Two reasons.

First, the orphan case does not arise there. KillGroup puts each command in a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so a hard terminate of the server closes the last job handle and Windows kills the whole command tree with it. No survivor, so nothing for the pid check to be wrong about.

Second, inheriting the handle would make things worse rather than better. It would hold a file open inside the directory during normal shutdown, where a just-terminated child that has not finished exiting blocks TempDir's own cleanup. That turns every clean exit into a leak, which is a real cost paid every session to defend a case the job object already covers.

That leaves the hole you pointed at, because the job object is only a guarantee if it was actually established. CreateJobObjectW, SetInformationJobObject, AssignProcessToJobObject and child.raw_handle() are all checked now, and a failure is logged. Logging alone is not a fix though, so the failure also surrenders the claim. sweep::surrender_claim removes the ownership marker from the shim and session dirs, which permanently downgrades both to "never auto-reclaim". A command that cannot be tied to our lifetime costs disk. It does not cost a live command its binaries.

2. The marker is untrusted input, so it is read like untrusted input

Confirmed and fixed, and the same treatment now applies to the lease file, which has identical exposure and would otherwise have reintroduced the blocking read you found.

read_to_string is gone. Both files are opened through one hardened path:

  • O_NOFOLLOW, so a symlinked marker is refused instead of followed
  • O_NONBLOCK, so opening a FIFO or a device returns immediately instead of waiting for a peer that never arrives
  • fstat on the resulting handle, not the path, so a check-then-replace race cannot change what is being validated, and anything that is not a regular file is skipped
  • a 256 byte cap checked on the handle, then a bounded read of cap plus one byte anyway, because the file can grow between the fstat and the read
  • non-UTF-8 content skipped

Every one of those failures lands where a missing marker lands, which is leave the directory alone. Windows takes the same path minus O_NOFOLLOW, which does not exist there, so reparse points are rejected before the open instead. There are no filesystem FIFOs in that namespace, so nothing can block.

I did not move the temp directories under a per-user owner-only parent. That is a real hardening idea and worth doing, but it relocates on-disk state for every existing deployment, it is orthogonal to this sweep once markers are parsed as hostile, and it deserves its own PR where it can be argued on its own merits rather than ride in behind a cleanup fix.

3. Tests that fail when the feature is disconnected

This was the most useful part of your review. The old tests called sweep_stale_dirs directly, so they proved a helper worked and nothing about whether anything called it.

What is there now:

  • only_a_dead_owner_with_a_free_lease_authorizes_removal enumerates all nine states of the policy function. One of the nine authorizes removal. Every Unknown, every InUse, every Missing does not.
  • a_command_that_outlives_its_owner_keeps_the_directory is the regression for blocker 1. It takes a real claim, spawns a real child that inherits the lease, drops the owner's descriptor, which is what the kernel does on SIGKILL, then rewrites the marker with a dead pid so the pid check alone would authorize a delete. The directory has to survive. Then it kills the child and asserts the directory becomes reclaimable.
  • FIFO marker, symlink-to-FIFO marker, FIFO lease, oversized marker and directory-as-marker are covered. The three that could block run the sweep on a thread behind a 20 second channel timeout, so a regression fails on the timeout instead of hanging the test binary forever.
  • tests/startup_sweep.rs drives the real binary. It plants an orphan and a live-owner directory in a scratch temp root, starts buzz-dev-mcp with TMPDIR/TMP/TEMP pointed at it, waits for the orphan to be reclaimed and checks the live one survived, asserts the server claimed both of its own directories with a marker naming its pid and a lease beside it, then hard-kills it, starts a replacement, and asserts the replacement reclaims what the killed server left. Filesystem observation only.

Your mutation list, re-run against the new tests on x86_64-pc-windows-gnu:

Mutation Result
Remove the startup sweep call at lib.rs caught. startup_sweep fails, timed out waiting for the planted orphan to be reclaimed
Remove the claim from Shim::install caught. startup_sweep fails, timed out waiting for both dirs to be claimed
Remove the claim from SharedState::new caught. same test, same assertion
Let Liveness::Unknown authorize deletion caught. only_a_dead_owner_with_a_free_lease_authorizes_removal fails
Drop the lease check from the policy entirely caught. two failures, the policy enumeration and dead_owner_without_a_lease_file_is_not_swept
Revert the hardened marker read to read_to_string not caught on Windows, 14 passed. Nothing to catch it there, since a FIFO cannot exist in that namespace and a symlinked marker needs elevation to create. The tests that catch it are the #[cfg(unix)] ones, and see the verification note below for why I cannot run those

On the one you asked for that I did not build. Driving a real shell command through the MCP protocol to prove the production spawn path inherits the lease means speaking JSON-RPC over stdio and then racing the shell tool's own timeout, which kills the child the test needs alive. What I did instead exercises the property with a real second process and a real inherited descriptor. The gap between the two is process_group and stdio redirection, neither of which touches other descriptors. If you want the protocol-level version anyway I will write it, I just did not want to claim more coverage than the test actually gives.

Verification

Ran, and these are the numbers I saw:

cargo test -p buzz-dev-mcp -- --test-threads=1   (x86_64-pc-windows-gnu)
    lib                                          133 passed, 0 failed
    tests/startup_sweep.rs                         1 passed, 0 failed
cargo clippy -p buzz-dev-mcp --all-targets --all-features -- -D warnings   clean
cargo fmt -p buzz-dev-mcp -- --check                                       clean

Plus the Unix branch of sweep.rs, every #[cfg(unix)] test included, type-checked for x86_64-unknown-linux-gnu under clippy -D warnings.

Now the part I am not going to paper over. None of the Unix code in this commit has been executed anywhere yet. I develop on Windows, a full --target x86_64-unknown-linux-gnu build of the crate dies in aws-lc-rs for want of a Linux C compiler, so what I have on the Unix side is a type-check and not a test run. Normally CI would settle that, except Actions has not triggered on this head at all. It has one check suite, DCO. The previous head has three, and its Actions runs are stamped about a day after that push, so fork runs here look like they need someone to approve them. If you approve this one, the Unit Tests job now actually runs this crate and the flock behaviour at the centre of blocker 1 gets executed on Linux for the first time.

One thing I found on the way, which is why the Justfile is in the diff

just test-unit never ran this crate. It enumerates packages explicitly and buzz-dev-mcp is not one of them. I went through every cargo test and cargo nextest line in ci.yml to be sure: the backend-integration archive covers buzz-db, buzz-relay and buzz-test-client, and the only job on the whole matrix that runs this crate's tests is Windows Rust. On Linux this crate gets clippy and check and zero executed tests.

That is how my last round shipped a #[cfg(unix)] test that did not compile. It also means the Unix half of this module, the half that decides whether to delete a live agent's shim directory, would have shipped unexecuted a second time. So cargo nextest run -p buzz-dev-mcp is now in test-unit, carrying the same justification the buzz-backend-kubernetes line above it already carries. Happy to split it into its own PR if you would rather keep this one to the crate.

Signed-off-by: Cristian Soto Garcia cristian@xa-tech.com

@ravarora2

Copy link
Copy Markdown
Contributor

🤖 Re-review at exact head 621c2d1696e8332ebae6037705ac11f6feb67065: REQUEST CHANGES. Do not merge this head yet.

This revision materially improves the original proposal: marker reads are hardened against FIFO/symlink hangs, Windows job errors are handled, deletion policy coverage is fail-closed, and real startup wiring tests were added.

One production safety blocker remains on Unix. The cleanup decision assumes a surviving shell command retains the inherited lease descriptor (sweep.rs:301-337; shell.rs:747-758). That assumption does not hold on this supported macOS/Unix host:

  • Full cargo test -p buzz-dev-mcp stopped at 112 passed, 1 failed.
  • The committed sweep::tests::a_command_that_outlives_its_owner_keeps_the_directory expected LeaseState::InUse, but observed LeaseState::Free (sweep.rs:949-992).
  • A production-shaped real-MCP probe started a command, closed/reused ordinary inherited descriptors, hard-killed the MCP parent, and started a replacement. The replacement removed both old directories while the command was alive; its later absolute shim invocation failed with No such file or directory (status 127).

This is not a theoretical coverage concern: the current Unix lease can classify live work as unused and delete its shim/session directories. main leaks storage after hard kills, but it does not automatically delete state used by a running command. For destructive cleanup, the demonstrated live-work deletion is the higher-severity failure.

Recommended fix before merge:

  1. Keep the marker hardening, Windows handling, and new causal tests.
  2. Replace the ordinary inherited-FD lease with a Buzz-controlled lifetime mechanism that a command cannot accidentally close/reuse; a dedicated supervisor/lease-holder tied to the command process group is one viable design.
  3. Keep Unix deletion fail-closed (or report-only) until that proof is reliable.
  4. Commit and pass the real regression: start MCP -> start delayed command -> hard-kill MCP -> restart -> command still invokes its old shim -> cleanup occurs only after the command exits.
  5. Rebase/resolve conflicts: GitHub currently reports this head as DIRTY against main.

The cleanup feature is useful and needed, but the current head replaces a recoverable disk leak with a reproduced live-command failure. Please fix the lease lifetime and rerun the full package suite on Unix before merging.

@cristiansotogarciaxatech

Copy link
Copy Markdown
Author

Both findings were real. Fixed at bb9a7d7aa52520bbbdf855070f06c644affb64e3, rebased onto main, and the conflict is gone.

They have two different root causes though, and the difference matters, because only one of them was a defect in the code.

1. The failing test was broken, not the mechanism under it

a_command_that_outlives_its_owner_keeps_the_directory modelled the owner's death by dropping the lease guard. That is not what a SIGKILL does. nix's Flock::drop issues an explicit flock(fd, LOCK_UN) (nix-0.31.3/src/fcntl.rs:1042), and an flock lock lives on the open file description, which a forked child shares with its parent. The test unlocked the child's lock on its way past and then asserted the child still held it.

I reproduced your run before changing a line, on a macos-latest runner at the old head. 115 passed, 1 failed, left: Free, right: InUse, same test, same assertion. Run 32477311246. So the test was failing for a reason that had nothing to do with the production path, and it was never testing what its name claimed. Two rounds of review have now caught this file's tests being wrong rather than its logic. I have stopped writing tests that model failure by tidying up.

2. The inherited descriptor was the real defect and it is gone

Your probe is correct and I am not going to defend the design. An inherited descriptor lands in the command's own descriptor table and belongs to the command from that instant. bash hands out 3 upward for its own redirections. Plenty of programs close everything above 2 on startup. Either one silently drops the lease while the command runs on, the sweep then reads a free lease, and a live command loses its binaries. That trades a recoverable disk leak for an unrecoverable failure. Bad trade, correctly rejected.

What replaced it

Command lifetime now lives somewhere the command cannot reach. Each claimed directory holds a .buzz-dev-mcp-cmds registry with one entry per running command, named by the command's process group on Unix and its pid on Windows. shell::run reserves the entry before the spawn, renames it to the real handle after, and removes it when the command is reaped (sweep.rs:497-681, shell.rs:194-240).

Nothing the command does to its descriptors, its environment or its signals can retract an entry. Buzz writes it and Buzz removes it. A server killed before it can remove one leaves the entry behind, which keeps the directory rather than losing it.

Four things follow from that, and each is deliberate.

Removal now needs three proofs, not two. The owner pid is confirmed dead, the owner's lease is confirmed unheld, and every registered command is confirmed gone. All twenty seven combinations are enumerated in removal_needs_a_dead_owner_a_free_lease_and_an_idle_registry, with an assertion that exactly one of them may delete.

The lease is demoted to what it can actually prove. It answers "is the owning server itself still alive", which is the pid question without the pid-reuse hole. FD_CLOEXEC is left at its default now, so the descriptor never reaches a spawned command at all.

The unit of liveness on Unix is the process group, not a pid. A command is a shell that forks, and the shell can exit while what it started keeps running with the shim dir on PATH. killpg answers for the whole group and succeeds while any member is alive, including after the leader is gone. That is the same unit KillGroup already manages. a_group_whose_leader_exited_is_still_alive pins it. The leader reads Dead through pid_liveness and Alive through command_liveness, at the same moment, for the same number.

The registry is cross-platform even though Windows does not strictly need it. The job object still guarantees no orphan survives there, so this is belt and braces, but it means the registry code is executed on every platform instead of on one.

I did not take the report-only option you offered for Unix. The proof is now executed rather than argued, below, so shipping a no-op on macOS and Linux would be giving up the platform where the leak actually hurts.

One more thing came out of writing this. The kill guard and the registry entry were two separate bindings, and locals drop in reverse declaration order, so the entry naming a process group was being removed just before the guard killed that group. They are one tuple now, since tuple fields drop left to right, and there is a comment saying why (shell.rs:234-240).

The regression you asked for

a_command_that_outlives_a_hard_killed_server_keeps_its_shim_until_it_exits in tests/startup_sweep.rs, and it is your sequence with nothing modelled by hand. A real server, a real shell call over MCP, a SIGKILL of that server while the command runs, a real replacement, then the command reports back whether the shim binary it resolved before its parent died is still executable, and a third server proves the directories are reclaimed once it exits.

The part I want to point at is the control. A canary orphan is planted immediately before the replacement starts, and the test waits for the canary to disappear before asserting anything. Without it "the directories are still there" is indistinguishable from "the sweep has not got to them yet", and the test would pass with the sweep deleted entirely.

It is #[cfg(unix)] by design. On Windows the command is in a job object with KILL_ON_JOB_CLOSE, so killing the server kills the command and there is no orphan to protect.

Verification

My development machine is Windows and has no Unix on it. No WSL distro, no Linux container engine, and a cross build of the crate dies in aws-lc-rs for want of a Linux C compiler. Rather than send you a third head reviewed by eye, I ran the crate on real runners in my own fork's Actions, on this exact head.

Run 32479639441, cargo test -p buzz-dev-mcp plus cargo clippy -p buzz-dev-mcp --all-targets -- -D warnings:

Runner Unit Integration Clippy
ubuntu-latest 124 passed, 0 failed 2 passed, 0 failed clean
macos-latest 124 passed, 0 failed 2 passed, 0 failed clean
windows-latest 145 passed, 0 failed 1 passed, 0 failed clean

Green is cheap, so here is the part that is not. Run 32480277709 breaks the sweep three ways on purpose and asserts the suite notices.

Mutation Result
A busy registry authorizes deletion killed. 7 tests fail, including the policy enumeration and the orphaned-command regression
Commands are never registered killed. The integration test fails with the replacement deleted /tmp/.tmpkqv0vt/buzz-dev-mcp-4s91Y2 while a command was still using it
Liveness asks about the pid instead of the group killed. a_group_whose_leader_exited_is_still_alive fails

The second one is your production probe, reproduced as a test and then caught by it.

Two things I did not run. Fork pull request workflows on this repo still need a maintainer to approve them, so the checks on the PR itself are not something I can produce, which is why the runs above are fork-side. And cargo test -p buzz-dev-mcp does not execute locally on my box at all any more, because Windows Smart App Control blocks freshly linked proc-macro DLLs during the build. Every number in this comment comes from a runner, not from me.

Known bounds

Worth stating rather than having you find them.

  • A recycled pid or process group id makes a finished command read as alive. Cost is a leaked directory. There is no failure in the other direction, since an id that is provably gone cannot come back.
  • A command that deliberately setsids out of its process group escapes the registry. It also escapes KillGroup, so the sweep is exactly as good as this crate's existing process management and no worse.
  • If the server is killed in the window between reserving a slot and naming it, the placeholder stays and that directory is never auto-reclaimed. That is a leak, not a delete, and it is the direction this module errs in everywhere.

Signed-off-by: Cristian Soto Garcia cristian@xa-tech.com

Cristian Soto Garcia added 7 commits September 1, 2026 13:35
buzz-dev-mcp creates two tempfile::TempDir per server start (the shim
dir with buzz/rg/tree/git-helper copies, and the session dir), whose
cleanup runs in Drop. When the process is killed rather than exited
gracefully, Drop never runs and the directories are orphaned -
measured at 104 folders / 5.05 GB over 33 hours on one machine
(block#6025).

Each directory now gets a small ownership marker recording the
creating process's pid and creation time. At startup, before creating
its own directories, the server sweeps the system temp dir for
buzz-dev-mcp-prefixed entries and removes one only when its marker's
pid is positively confirmed dead (nix::sys::signal::kill on Unix,
OpenProcess/GetExitCodeProcess on Windows). Every uncertain case - no
marker, corrupt marker, pid alive, or liveness undeterminable - is
left alone, since a session can legitimately run for days and an
age-based sweep would risk deleting a live session's binaries out
from under it. Directories that predate this change have no marker
and are likewise left alone rather than guessed at with an age cutoff;
the sweep is best-effort end to end and never fails startup.

No new dependencies: liveness checks use nix (already a dependency,
signal feature) on Unix and windows-sys (already a dependency,
Win32_System_Threading feature) on Windows.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
Review follow-up on three defects in the startup sweep.

The Windows liveness probe mapped every OpenProcess failure except
ERROR_ACCESS_DENIED to Dead. Resource exhaustion and transient kernel
failures are not evidence that a pid is absent, so that could delete a
live session's shim directory out from under it, contradicting the
module's own fail-safe contract. Only ERROR_INVALID_PARAMETER, what
Windows returns when no process object exists for the pid, now yields
Dead. Everything else is Unknown. The classification moves into
classify_open_process_error so it can be asserted on directly instead
of requiring a real system error to reproduce.

The Unix-only permission test did not compile. It called
nix::unistd::Uid::effective(), but this crate pins nix with
default-features = false and features = ["signal", "process"], and Uid
is gated behind nix's "user" feature. No crate in the workspace enables
it. Confirmed by compiling the same call against the same feature set:
"could not find Uid in unistd ... the item is gated behind the user
feature".

That test would not have covered the branch it claimed even with the
feature enabled. Chmodding the target directory to 000 makes its
ownership marker unreadable first, so the sweep counts
skipped_no_marker and returns before reaching removal, leaving its
assert_eq!(stats.errors, 1) failing rather than merely vacuous. It is
replaced with an injected remover through sweep_stale_dirs_with, which
exercises the real removal-failure branch with no dependence on
platform, uid, or filesystem behaviour. Two directories are failed
rather than one, so the assertion proves the sweep continued past the
first failure whatever order read_dir returns entries in.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
…e owner pid

A dead server pid does not prove the directory is idle. On Unix the shell
tool spawns each command in its own process group, so a SIGKILL of the
server leaves the command running with the shim dir still first on PATH,
free to invoke buzz/rg/tree/the git helpers out of it later. The Drop guard
that would have killed the group is exactly what a SIGKILL skips.

Every directory this crate creates now carries a lease as well, a file the
creating process holds flock(LOCK_SH) on with FD_CLOEXEC cleared, so every
command it spawns inherits the descriptor. An flock belongs to the open file
description rather than to a process, so the lease stays held while the
server or any command descended from it is alive, and the kernel releases it
when the last of them exits. Removal now needs both facts to be positive,
owner pid confirmed dead and lease confirmed unheld. The lease is written
before the marker, so a marked directory always has a lease behind it and a
directory that could not be leased is never swept at all.

Windows keeps the lease handle uninherited on purpose. Commands there live
in a Job Object with KILL_ON_JOB_CLOSE, so a hard kill of the server takes
the whole tree with it, and an inherited handle would instead block
TempDir's own cleanup on the normal shutdown path and turn every clean exit
into a leak. The job object return values were being dropped on the floor,
so CreateJobObjectW, SetInformationJobObject and AssignProcessToJobObject
are all checked now, and a failure surrenders the ownership claim rather
than leaving behind a directory a later sweep could delete under a live
command.

The marker is also read as untrusted input now. It lives in a world-writable
directory on a typical Unix box, and read_to_string on it follows symlinks,
accepts any file type and waits for EOF, so a FIFO marker blocked startup
before the server ever served. The read is now O_NOFOLLOW plus O_NONBLOCK,
validated as a regular file on the opened handle rather than the path,
capped at 256 bytes and bounded on the read itself. Every failure lands
where a missing marker lands, which is leave the directory alone.

Tests cover the policy across all nine owner/lease states, an orphaned
command holding an inherited lease through the owner's death, FIFO and
symlinked and oversized markers on a timeout so a regression fails instead
of hanging, and a production-boundary test that drives the real binary
through startup, a hard kill and a restart. buzz-dev-mcp joins test-unit
because the Linux CI job never ran a single test from this crate, which is
how a cfg(unix) test that did not compile shipped last round.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
…escriptor the command owns

The inherited-descriptor lease does not survive contact with a real command.
It lands in the command's own descriptor table, where bash hands out 3 upward
for its redirections and plenty of programs close everything above 2 on
startup. Either drops the lease while the command runs on, and the sweep then
reads a free lease and deletes a live command's shim directory. That is worse
than the leak this PR set out to fix.

Command lifetime now lives in a registry the command cannot reach: a
.buzz-dev-mcp-cmds directory inside each claimed dir, one entry per running
command, named by the command's process group on Unix and its pid on Windows.
shell::run reserves the entry before the spawn, names it after, and drops it
when the command is reaped. Nothing a command does to its descriptors,
environment or signals can retract an entry, and a server killed before it can
deregister leaves the entry behind, which keeps the directory instead of
losing it.

Removal now needs three independent proofs rather than two: the owner pid is
dead, the owner's lease is unheld, and every registered command is gone. The
lease is demoted to what it can actually prove, which is that the owning
server itself is still alive, so FD_CLOEXEC is left alone and the descriptor
no longer reaches any child.

The group, not the pid, is the unit of liveness on Unix. A command is a shell
that forks, and the shell can exit while what it started keeps running with
the shim dir on PATH. killpg answers for the whole group, which is the same
unit KillGroup already manages.

a_command_that_outlives_its_owner_keeps_the_directory was not just failing on
macOS, it was never testing what it claimed. It modelled the owner's death by
dropping the lease guard, and nix's Flock::drop issues an explicit LOCK_UN
(nix-0.31.3/src/fcntl.rs:1042) which releases the lock on the open file
description the child shares, so the child's copy died with it. It now drives
the registry the way shell::run does and leaks the entry with mem::forget,
which is what a SIGKILL actually leaves behind.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
The unit tests prove the policy and the wiring tests prove the policy is
connected to something. Neither runs a command, and the failure this PR was
sent back for was a command losing its shim directory, so neither would have
caught it.

This one is the whole scenario with nothing modelled by hand: a real server, a
real shell tool call over MCP, a SIGKILL of that server while the command is
still running, and a real replacement doing its startup sweep with the orphan
alive. A canary orphan planted just before the replacement starts is the
control, so "the directories are still there" cannot be confused with "the
sweep has not run yet". The command reports back whether the shim binary it
resolved before its server died is still executable, and a third server proves
the directories are reclaimed once the command finally exits.

Unix only by design. On Windows the command lives in a job object with
KILL_ON_JOB_CLOSE, so killing the server kills the command and there is no
orphan to protect.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
A registry handle is a process group id on Unix, and a test process is not a
group leader under a test runner, so killpg(getpid()) is ESRCH and two of the
new tests asserted Busy against a handle that reads as dead. Both now use the
process's own group.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
Two separate bindings drop in reverse declaration order, so the registry entry
was being removed while the process group it names was still up. One tuple
fixes the order and says so, since the alternative is a comment nobody reads
next to code that looks fine.

Also gates the integration test's MCP helpers to Unix, where its only caller
lives, and updates the Justfile note to the mechanism that replaced the
inherited lock.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 571c1902d0ca55cfd4ccf6b91eeb731909cc10be...abfca774fa2922ad8133f3cb4749bd19611d4bea.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review abfca774fa2922ad8133f3cb4749bd19611d4bea to authorize a new review.
Any previous review applies only to its recorded range.

Running `cargo fmt` outside hermit picked up rustfmt 1.9.0-stable
(8bab26f4f6 2026-07-14) from the default stable toolchain, while the
repo pins 1.95.0, whose rustfmt is 1.9.0-stable (59807616e1
2026-04-14). Same version string, different build, and they disagree on
closure and method-chain wrapping. Six sites were red under the pinned
build, one in sweep.rs and five in startup_sweep.rs.

sweep.rs is whitespace only. startup_sweep.rs is whitespace plus one
edit rustfmt makes on its own, dropping the braces around a
single-expression closure and adding the trailing comma that goes with
it. Stripped of all whitespace the two files differ from the reviewed
head by exactly one opening brace, one closing brace and one comma.
No behaviour change.

Signed-off-by: Cristian Soto Garcia <cristian@xa-tech.com>
@cristiansotogarciaxatech

Copy link
Copy Markdown
Author

Rebased onto main at 571c1902 and force-pushed to abfca774. The PR had drifted into conflict on the Justfile, and clearing it turned up something I got wrong earlier that you should hear about first.

The correction

I told you on 17 and 19 August that fmt was clean on this crate. That was wrong, and the fault is my instrument rather than the toolchain.

Bare cargo fmt on my machine resolves to the default stable toolchain, rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14). This repo pins 1.95.0, whose rustfmt is 1.9.0-stable (59807616e1 2026-04-14). Same version string, different build, and they disagree about closure and method-chain wrapping. Under the pinned build six sites were red, one in sweep.rs and five in startup_sweep.rs. Nothing caught it because just fmt-check has never actually run against this branch. Fork Actions needs approval here, so the only check this PR has ever carried is DCO.

Here is the red run, which I am linking rather than deleting: https://github.com/cristiansotogarciaxatech/buzz/actions/runs/33503349813

Fixed in the style commit. Being exact about what that commit does, because "formatting only" is a claim worth checking. sweep.rs is whitespace only. startup_sweep.rs is whitespace plus one edit rustfmt makes on its own, dropping the braces around a single-expression closure and adding the trailing comma that goes with it. Strip all whitespace from both files and the difference from the head you reviewed is exactly one opening brace, one closing brace and one comma.

The conflict

Both sides append to the test-unit recipe. Main added the buzz-relay --lib and buzz-acp --lib blocks, mine adds cargo nextest run -p buzz-dev-mcp. I kept both, in that order. The buzz-backend-kubernetes line my comment points at is still above it, so that cross-reference still reads true.

Nothing else moved. These four are byte-identical to the head you reviewed at bb9a7d7a:

file blob
Cargo.toml 55f6e634c7a0
src/lib.rs 644a8a0f4e9a
src/shell.rs bc554ad6d3b4
src/shim.rs d05b4e9f0c8a

Green, on the pinned toolchain

https://github.com/cristiansotogarciaxatech/buzz/actions/runs/33503601132

That run sits at 13cc81d0. Its parent carries the same tree as the PR head, 2e7e8e23, and the only file that differs between them is the workflow itself.

  • just fmt-check passes.
  • cargo clippy -p buzz-dev-mcp --all-targets -- -D warnings is clean.
  • cargo nextest run -p buzz-dev-mcp on ubuntu-latest, 126 tests run, 126 passed, 0 skipped. That is the Unix half this PR exists to cover, executed.

The pre-rebase head is parked at archive/6029-pre-rebase-bb9a7d7a in my fork if you want to diff against exactly what you reviewed. GitHub now reports the PR mergeable again.

@cristiansotogarciaxatech

Copy link
Copy Markdown
Author

Scope note, because this PR is going to get triaged as a duplicate of #4732 and #5944 and it is not one.

Those two both add taskkill /T /F to AcpClient::shutdown so the agent's whole tree dies on Windows instead of only the direct child. Both are correct and one of them should land. Neither reclaims a single byte of the disk this PR is about, and landing either one without a sweep makes the leak worse than it is today.

The reason is Drop. Verified at 3c7f288c:

  • crates/buzz-dev-mcp/src/shim.rs:17-19,26, the shim directory is a tempfile::TempDir, removed in its Drop.
  • crates/buzz-dev-mcp/src/shell.rs:29,41, the session directory is the same.
  • crates/buzz-dev-mcp/src/lib.rs:183-185, the graceful path. service.waiting() returns when the stdio transport closes, async_main returns, those locals drop, the directories go with them. That is the only thing that removes them today.

taskkill /F is TerminateProcess. No destructors, no unwinding, no atexit. A process killed that way cannot clean up after itself, and it makes no difference whether it was killed on its own or swept up as part of a tree.

So here is what actually changes when one of those PRs lands. Today shutdown() on Windows reaches the direct child and stops, so a buzz-dev-mcp sitting under the agent usually survives it, reads EOF on stdin and exits normally, which is the path that removes the directories. It plainly does not always get there, #6025 is 5 GB in two days of proof that it does not. Add /T and it never gets there. The process is inside the kill, gets terminated, and leaves a shim directory and a session directory behind on every shutdown.

That turns an intermittent leak into a deterministic one, once per shutdown, on a path the harness takes on every respawn.

Cause and residue, not two swings at the same defect. Kill the tree with #4732 or #5944, reclaim what the kill leaves behind with the startup sweep here. Landing the tree-kill on its own is the worst of the available outcomes and it would look like a fix while the disk keeps filling.

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

Labels

triage-ready Appropriate for agentic review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

buzz-dev-mcp never cleans up its temp folders, 5 GB in two days on one machine

3 participants