Skip to content

fix(server): close inherited fds after daemonizing to avoid pipe deadlock (supersedes #2314) - #2771

Open
brianmacy wants to merge 1 commit into
mozilla:mainfrom
brianmacy:fd-hygiene-daemonize
Open

fix(server): close inherited fds after daemonizing to avoid pipe deadlock (supersedes #2314)#2771
brianmacy wants to merge 1 commit into
mozilla:mainfrom
brianmacy:fd-hygiene-daemonize

Conversation

@brianmacy

Copy link
Copy Markdown

Summary

Fixes the 0%-CPU daemon deadlock in #1011.

The persistent cache server is lazily fork+exec'd by a compile-job client
and inherits that 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 end never observes EOF, and the build wedges
at 0% CPU (reliably reproducible under a ninja build that pipes through tee,
e.g. an ONNX Runtime link step).

Fix

After daemonizing — and before the server binds its socket — close all inherited
file descriptors >= 3 via the close_fds
crate.

  • Scoped to the cache server (Command::InternalStartServer). sccache-dist
    has already constructed a reqwest client that owns runtime/epoll fds by the
    time it daemonizes, so blindly closing fds there would be unsound — it opts out.
  • daemonize() now takes (Option<File>, bool): the error-log stderr redirect
    is folded into daemonize (configured via daemonix's Stdio) instead of a
    separate post-daemonize step, and the bool selects whether to run the sweep.
  • Opt-outs: SCCACHE_NO_DAEMON=1 (foreground debug) 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.

Test

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)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 main and adapted to
the daemonize → daemonix migration (hence the daemonize(Option<File>, bool)
shape and the close_fds 0.3.2 dependency). Co-authored accordingly.

Closes #1011. Supersedes #2314.

@codecov-commenter

codecov-commenter commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.50000% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.87%. Comparing base (7cd4f2c) to head (352e267).

Files with missing lines Patch % Lines
src/util.rs 62.36% 35 Missing ⚠️
src/bin/sccache-dist/main.rs 0.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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>
@brianmacy
brianmacy force-pushed the fd-hygiene-daemonize branch from f598437 to 352e267 Compare July 22, 2026 08:58
@brianmacy

brianmacy commented Jul 22, 2026

Copy link
Copy Markdown
Author

Re: the one red check — it is unrelated to this PR and its fix does not belong here.

test ubuntu-22.04 rust 1.85.0 dist-tests is failing on current main as well, for a reason introduced entirely outside this changeset: a newly published libc crate.

The specific issue

tests/dist.rs depends on libc = "0.2.169" (caret → newest 0.2.x). libc 0.2.187+ restructured its sources under src/new/…, using #[path] module attributes that traverse ../../. Raising the dist server to SCCACHE_LOG=trace (it runs at debug in CI, and the sandboxed compiler's output is logged at trace! in build.rs, so this was previously invisible) shows the real failure:

error: couldn't read .../libc-0.2.189/src/new/glibc/sysdeps/nptl/bits/../../x86/nptl/bits/struct_mutex.rs:
       No such file or directory (os error 2)
  --> .../libc-0.2.189/src/new/glibc/sysdeps/nptl/mod.rs:26:5

sccache-dist collects Rust inputs from rustc's dep-info and untars them on the builder. The intermediate directory the ../../ path climbs through has no dep-info-listed file, so it is never recreated on the remote builder and rustc cannot open the file → dist_errors: 1, no outputs, local fallback. Non-dist builds pass because the full source tree is present locally.

Publish times match the pass→fail boundary on main exactly:

libc published (UTC)
last green main dist-tests 2026-07-20 20:49
0.2.187 2026-07-20 23:48
0.2.188 2026-07-21 09:38
first red main dist-tests 2026-07-21 11:53
0.2.189 2026-07-21 21:33

Scope

This 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 #[path]/.. traversal, or pinning libc in the dist test — is a separate upstream concern and should not block or be bundled into this PR.

(Correcting my earlier comment, which loosely called this a "flake" — it is deterministic, just environment/dependency-driven rather than caused by any code here.)

@sylvestre

Copy link
Copy Markdown
Collaborator

in the future, could you please keep your comments a bit shorter?
the LLM long comments aren't super helpful ;)

887 added a commit to 887/breed-claude that referenced this pull request Aug 5, 2026
…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.
887 added a commit to 887/breed-claude that referenced this pull request Aug 5, 2026
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.
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.

Hang / deadlock while compiling?

3 participants