fix(server): close inherited fds after daemonizing to avoid pipe deadlock (supersedes #2314) - #2771
fix(server): close inherited fds after daemonizing to avoid pipe deadlock (supersedes #2314)#2771brianmacy wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2771 +/- ##
==========================================
- Coverage 70.90% 70.87% -0.04%
==========================================
Files 71 71
Lines 40393 40479 +86
==========================================
+ Hits 28642 28689 +47
- Misses 11751 11790 +39 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…lock The persistent cache server is lazily fork+exec'd by a compile-job client and inherits the client's open file descriptors, including ninja's jobserver pipe and the `cmake ... | tee` stdout pipe. Because the long-lived daemon keeps those write-ends open, the writer never observes EOF, producing a 0%-CPU deadlock (mozilla#1011). After daemonizing, close all inherited file descriptors >= 3 (before the server binds its socket) via the close_fds crate. The sweep is scoped to the cache server (Command::InternalStartServer): sccache-dist has already built a reqwest client that owns runtime/epoll fds at daemonize time, so closing arbitrary fds there would be unsound -- it opts out. daemonize() now takes (Option<File>, bool): the error-log stderr redirect is folded into daemonize (configured through daemonix's Stdio) instead of a separate post-daemonize step, and the bool selects whether to run the fd sweep. SCCACHE_NO_DAEMON=1 (foreground debug mode) skips daemonizing and the sweep; SCCACHE_NO_FD_HYGIENE=1 is an escape hatch that skips only the sweep. On Windows there is no daemon fork, so daemonize() just redirects stderr. Adds a Unix regression test that forks, runs close_open_fds(3, &[]) in the child, and asserts an inherited pipe fd is closed (fcntl F_GETFD returns -1 with errno EBADF) while stderr (fd 2) survives the sweep. Closes mozilla#1011. Supersedes mozilla#2314. Co-authored-by: Alicia Boya García <aboya@igalia.com>
f598437 to
352e267
Compare
|
Re: the one red check — it is unrelated to this PR and its fix does not belong here.
The specific issue
sccache-dist collects Rust inputs from rustc's dep-info and untars them on the builder. The intermediate directory the Publish times match the pass→fail boundary on
ScopeThis PR touches only the cache-server daemon's FD handling; the failure is entirely server-side in the sccache-dist builder. The fix — either sccache-dist input reconstruction for (Correcting my earlier comment, which loosely called this a "flake" — it is deterministic, just environment/dependency-driven rather than caused by any code here.) |
|
in the future, could you please keep your comments a bit shorter? |
…rver mozilla/sccache#2771 (open, unreleased as of 0.17.0): the sccache server is lazily fork+exec'd by whichever compile-job client first needs it, and inherits that client's file descriptors — including cargo's jobserver pipe. The daemon holds the write end open forever, the writer never sees EOF, and the build deadlocks AT 0.0% CPU with its output frozen mid-Compiling. It is indistinguishable from a slow compile by looking at it: 5h43m + 3h32m + 1h41m lost in one session before it was diagnosed. Discipline could not fix this because the recovery action IS the bug: wedge -> kill -9 the server -> re-run cargo -> cargo LAZILY SPAWNS a new server, inheriting THIS cargo's jobserver pipe -> wedged again All three wedges were that cycle, and the remedy was written into two files between the second and the third without preventing it. A warning is invisible at the point of the mistake. A hook is not merely a convenient place for the remedy — it is the only correct one. The single property that fixes this is WHO STARTS THE SERVER: one started by a process owning no jobserver has no build pipe to hold, and a hook is spawned by Claude Code rather than by cargo, so a server it starts is clean by construction. The same command typed inside a build is not. On `cargo …` it probes (11 ms healthy) and, if absent or wedged, stops and starts the server from the hook process, then ALLOWS the build — there is only one correct action, so making the author pick it would be ceremony. It separately refuses `pkill sccache` / `kill -9 $(pgrep sccache)`, pointing at `sccache --stop-server`. Every Unix, deliberately NOT macOS-only: the mechanism is POSIX fd inheritance, #2771's reproducer is a ninja+tee build, and its fix is cfg(unix). Windows no-ops — #2771: "On Windows there is no daemon fork". Not to be confused with sccache#221, a genuinely macOS-specific pthread deadlock with a similar symptom. 15 gate tests, both directions. The first version substring-matched and blocked its own test case describing the trap (`echo "never pkill sccache"`); it now only fires in command position, so documenting the hazard cannot trip the gate that documents it. Also fixes a pre-existing hole in tests/gate.sh: its sandbox copied $HOOKS/*.py, which excludes the deliberately-unlinked _shellscan.py, so rg-flag-gate — first in the order — always died on import and every "which gate is named" assertion reported rg-flag-gate regardless of which was corrupted. The suite was passing 31/32 while silently not testing what it claimed. Now 32/32, through the symlink and from the repo. Delete this gate when sccache 0.18+ ships #2771.
The server half of this gate (lazy fork+exec inheriting cargo's jobserver pipe, mozilla/sccache#2771) was real but did not explain why the wedge kept coming back after every clean restart. This is why. Killing a stuck build leaves that build's per-compilation `sccache rustc …` CLIENT wrappers alive. They hold their connection, and every later restart rejoins a pool that still contains them. Measured mid-session: 41731 03:08:25 /opt/homebrew/bin/sccache <- the server 62677 01:11:59 sccache <- orphan, 1h11m 87358 02:47:59 sccache <- orphan, 2h47m Two orphans from two previously-killed builds, WHILE THE SERVER ANSWERED --show-stats PERFECTLY. Orphans are invisible to a health probe, which is exactly why the first version of this gate reported healthy through four separate wedges. They are invisible to the obvious search too: their comm is `sccache`, so `pgrep -f 'cargo|rustc'` — the natural check after killing a build — shows nothing. `pgrep -x sccache` shows them instantly. That one blind spot cost hours. The rule: an orphan is a live sccache process that is NOT the port listener, reaped only when no compile driver is running anywhere. With no build in flight there are no legitimate clients by construction, so the reaper cannot race one — the interlock is what makes it safe, not a guess about process age. Server identity comes from the PORT, never from comm: the server rendered with an absolute path and its clients bare above, but that is an accident of how each was launched, and killing the server by mistake re-triggers the lazy-respawn wedge this file exists to prevent. Listener unidentifiable -> kill nothing. The scan runs unconditionally on cargo commands rather than only on an unhealthy probe, because gating it on probe health reproduces the blind spot above. Cost: 26 ms per cargo command healthy (11 ms probe + 15 ms pgrep), measured; the 98 ms lsof is reached only when a stale client already exists. Nothing at all on non-cargo commands. Tests 21/21 (was 15), gate suite still 32/32. The six new cases are unit-level because the shapes the reaper must NOT act on cannot be staged as real processes without risking a live build, and those are the ones that matter: a live build's clients are spared, an unidentifiable listener kills nothing, a lone server never reaches lsof, and an orphan that exits between scan and kill is tolerated.
Summary
Fixes the 0%-CPU daemon deadlock in #1011.
The persistent cache server is lazily
fork+exec'd by a compile-job clientand inherits that client's open file descriptors — including ninja's jobserver
pipe and the
cmake ... | teestdout pipe. Because the long-lived daemon keepsthose write-ends open, the writer end never observes EOF, and the build wedges
at 0% CPU (reliably reproducible under a
ninjabuild that pipes throughtee,e.g. an ONNX Runtime link step).
Fix
After daemonizing — and before the server binds its socket — close all inherited
file descriptors
>= 3via theclose_fdscrate.
Command::InternalStartServer).sccache-disthas already constructed a
reqwestclient that owns runtime/epoll fds by thetime it daemonizes, so blindly closing fds there would be unsound — it opts out.
daemonize()now takes(Option<File>, bool): the error-log stderr redirectis folded into
daemonize(configured via daemonix'sStdio) instead of aseparate post-daemonize step, and the
boolselects whether to run the sweep.SCCACHE_NO_DAEMON=1(foreground debug) skips daemonizing andthe sweep;
SCCACHE_NO_FD_HYGIENE=1is an escape hatch that skips only thesweep. On Windows there is no daemon fork, so
daemonize()just redirects stderr.Test
Adds a Unix regression test that forks, runs
close_open_fds(3, &[])in thechild, and asserts an inherited pipe fd is closed (
fcntl(F_GETFD)→EBADF)while stderr (fd 2) survives the sweep.
This has also been running in our own CI at scale (a fork build carrying this
change) across all six release targets with no regressions, and it eliminates
the ONNX-link deadlock we hit consistently without it.
Credit
This finishes off @ntrrgc's #2314, rebased onto current
mainand adapted tothe
daemonize→ daemonix migration (hence thedaemonize(Option<File>, bool)shape and the
close_fds 0.3.2dependency). Co-authored accordingly.Closes #1011. Supersedes #2314.