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 ;)

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