Skip to content

Commit 352e267

Browse files
brianmacyntrrgc
andcommitted
fix(server): close inherited fds after daemonizing to avoid pipe deadlock
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 (#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 #1011. Supersedes #2314. Co-authored-by: Alicia Boya García <aboya@igalia.com>
1 parent 7cd4f2c commit 352e267

5 files changed

Lines changed: 184 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ test-case = "3.3.1"
149149
thirtyfour = "0.36"
150150

151151
[target.'cfg(unix)'.dependencies]
152+
close_fds = "0.3.2"
152153
daemonix = "0.1"
153154

154155
[target.'cfg(not(target_os = "freebsd"))'.dependencies.libmount]

src/bin/sccache-dist/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ fn run(command: Command) -> Result<i32> {
219219
}
220220
};
221221

222-
daemonize()?;
222+
// sccache-dist has already built a reqwest client that owns
223+
// runtime/epoll fds, so do NOT sweep inherited fds here.
224+
daemonize(None, false)?;
223225
let scheduler = Scheduler::new();
224226
let http_scheduler = dist::http::Scheduler::new(
225227
public_addr,

src/commands.rs

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -131,26 +131,6 @@ fn run_server_process(startup_timeout: Option<Duration>) -> Result<ServerStartup
131131
})
132132
}
133133

134-
#[cfg(not(windows))]
135-
fn redirect_stderr(f: File) {
136-
use libc::dup2;
137-
use std::os::unix::io::IntoRawFd;
138-
// Ignore errors here.
139-
unsafe {
140-
dup2(f.into_raw_fd(), 2);
141-
}
142-
}
143-
144-
#[cfg(windows)]
145-
fn redirect_stderr(f: File) {
146-
use std::os::windows::io::IntoRawHandle;
147-
use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
148-
// Ignore errors here.
149-
unsafe {
150-
SetStdHandle(STD_ERROR_HANDLE, f.into_raw_handle() as _);
151-
}
152-
}
153-
154134
/// Create the log file and return an error if cannot be created
155135
fn create_error_log() -> Result<File> {
156136
trace!("Create the log file");
@@ -170,13 +150,6 @@ fn create_error_log() -> Result<File> {
170150
Ok(f)
171151
}
172152

173-
/// If `SCCACHE_ERROR_LOG` is set, redirect stderr to it.
174-
fn redirect_error_log(f: File) -> Result<()> {
175-
debug!("redirecting stderr into {:?}", f);
176-
redirect_stderr(f);
177-
Ok(())
178-
}
179-
180153
/// Re-execute the current executable as a background server.
181154
#[cfg(windows)]
182155
fn run_server_process(startup_timeout: Option<Duration>) -> Result<ServerStartup> {
@@ -757,13 +730,14 @@ pub fn run_command(cmd: Command) -> Result<i32> {
757730
Command::InternalStartServer => {
758731
trace!("Command::InternalStartServer");
759732
if env::var("SCCACHE_ERROR_LOG").is_ok() {
733+
// Fold the error-log redirect into daemonize so stderr is set
734+
// up as part of daemonizing. create_error_log() can still
735+
// report failure here, before we daemonize.
760736
let f = create_error_log()?;
761-
// Can't report failure here, we're already daemonized.
762-
daemonize()?;
763-
redirect_error_log(f)?;
737+
daemonize(Some(f), true)?;
764738
} else {
765739
// We aren't asking for a log file
766-
daemonize()?;
740+
daemonize(None, true)?;
767741
}
768742
server::start_server(config, &get_addr())?;
769743
}

src/util.rs

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -897,18 +897,74 @@ impl Hasher for HashToDigest<'_> {
897897
}
898898
}
899899

900-
/// Pipe `cmd`'s stdio to `/dev/null`, unless a specific env var is set.
900+
/// Close file descriptors inherited from the client (fd >= 3), releasing the
901+
/// build pipes whose lingering write-ends cause mozilla/sccache#1011.
902+
///
903+
/// Must only be called immediately after daemonizing (see `daemonize`).
904+
#[cfg(not(windows))]
905+
fn close_client_inherited_fds() {
906+
use std::env;
907+
908+
// Escape hatch: allow disabling the fd sweep for debugging.
909+
if env::var("SCCACHE_NO_FD_HYGIENE").ok().as_deref() == Some("1") {
910+
return;
911+
}
912+
913+
// On macOS/BSD close_fds closes each descriptor directly (async-signal-safe);
914+
// we intentionally close rather than set-CLOEXEC because no exec follows and
915+
// the inherited pipe write-ends must actually be released to fix #1011.
916+
//
917+
// SAFETY: we are immediately after daemonix's fork, so exactly one thread
918+
// exists; no live Rust object owns an fd >= 3 here -- daemonix has already
919+
// dup2'd the log (or /dev/null) onto stderr and dropped the original File
920+
// plus closed its /dev/null fd -- and the listening socket and
921+
// SCCACHE_STARTUP_NOTIFY connection are opened later. Closing 3.. only
922+
// releases fds inherited from the client (ninja jobserver / cmake|tee pipes).
923+
unsafe {
924+
close_fds::close_open_fds(3, &[]);
925+
}
926+
}
927+
928+
/// Daemonize the current process, optionally redirecting stderr to `log_file`.
929+
///
930+
/// If `log_file` is `Some`, the daemon's stderr is redirected to that file;
931+
/// otherwise stderr goes to `/dev/null` (the pre-existing default).
932+
///
933+
/// When `close_inherited_fds` is true, all file descriptors >= 3 are closed
934+
/// immediately after daemonizing (before the server binds its socket). The
935+
/// cache server is lazily fork+exec'd by a compile-job client and inherits the
936+
/// client's open fds -- e.g. ninja's jobserver pipe or a `cmake ... | tee`
937+
/// stdout pipe. Because the long-lived daemon keeps those write-ends open, the
938+
/// writer never observes EOF, producing a 0%-CPU deadlock
939+
/// (mozilla/sccache#1011). Callers that own live fds at daemonize time (e.g.
940+
/// sccache-dist, which has already built a reqwest client owning runtime/epoll
941+
/// fds) must pass `false`, since blindly closing those fds would be unsound.
942+
///
943+
/// When `SCCACHE_NO_DAEMON=1` the process does not daemonize and stays a
944+
/// foreground child attached to the client, so inherited fds are deliberately
945+
/// NOT closed (the #1011 hang is then only theoretically reachable in that
946+
/// debug mode). Setting `SCCACHE_NO_FD_HYGIENE=1` is an escape hatch that skips
947+
/// the fd sweep even when daemonizing.
901948
#[cfg(not(windows))]
902-
pub fn daemonize() -> Result<()> {
949+
pub fn daemonize(log_file: Option<File>, close_inherited_fds: bool) -> Result<()> {
903950
use crate::jobserver::discard_inherited_jobserver;
904-
use daemonix::Daemonize;
951+
use daemonix::{Daemonize, Stdio};
905952
use std::env;
906953
use std::mem;
907954

908955
match env::var("SCCACHE_NO_DAEMON") {
909956
Ok(ref val) if val == "1" => {}
910957
_ => {
911-
Daemonize::new().start().context("failed to daemonize")?;
958+
let stderr = log_file
959+
.map(|f| Stdio::from(f.into_file()))
960+
.unwrap_or_else(Stdio::devnull);
961+
Daemonize::new()
962+
.stderr(stderr)
963+
.start()
964+
.context("failed to daemonize")?;
965+
if close_inherited_fds {
966+
close_client_inherited_fds();
967+
}
912968
}
913969
}
914970

@@ -984,12 +1040,27 @@ pub fn daemonize() -> Result<()> {
9841040
}
9851041
}
9861042

987-
/// This is a no-op on Windows.
1043+
/// There is no daemon to fork on Windows, so this only redirects stderr to
1044+
/// `log_file` (if any). No inherited-fd closing is needed on Windows, so
1045+
/// `_close_inherited_fds` is unused.
9881046
#[cfg(windows)]
989-
pub fn daemonize() -> Result<()> {
1047+
pub fn daemonize(log_file: Option<File>, _close_inherited_fds: bool) -> Result<()> {
1048+
if let Some(log_file) = log_file {
1049+
redirect_stderr(log_file);
1050+
}
9901051
Ok(())
9911052
}
9921053

1054+
#[cfg(windows)]
1055+
fn redirect_stderr(f: File) {
1056+
use std::os::windows::io::IntoRawHandle;
1057+
use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
1058+
// Ignore errors here.
1059+
unsafe {
1060+
SetStdHandle(STD_ERROR_HANDLE, f.into_file().into_raw_handle() as _);
1061+
}
1062+
}
1063+
9931064
/// Disable connection pool to avoid broken connection between runtime
9941065
///
9951066
/// # TODO
@@ -1980,4 +2051,91 @@ mod tests {
19802051
.unwrap();
19812052
assert_ne!(h1, h2);
19822053
}
2054+
2055+
/// Regression test for mozilla/sccache#1011: the daemon must close file
2056+
/// descriptors inherited from the client (fd >= 3) so the write-ends of
2057+
/// pipes like ninja's jobserver or `cmake ... | tee` see EOF.
2058+
///
2059+
/// We fork so the fd-closing sweep does not affect the test harness's own
2060+
/// descriptors. The child performs only async-signal-safe work: the sweep
2061+
/// itself, an `fcntl(F_GETFD)` probe on the inherited write-end (which must
2062+
/// return -1 with errno EBADF once closed), and a probe on fd 2 (stderr,
2063+
/// which must survive the sweep -- guarding an off-by-one in `minfd`).
2064+
#[cfg(unix)]
2065+
#[test]
2066+
fn test_daemonize_closes_inherited_fds() {
2067+
// Create a pipe; both ends are fds >= 3 in the test process.
2068+
let mut fds = [0 as libc::c_int; 2];
2069+
// SAFETY: `fds` is a valid two-element array for libc::pipe.
2070+
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
2071+
assert_eq!(rc, 0, "pipe() failed");
2072+
let (read_fd, write_fd) = (fds[0], fds[1]);
2073+
assert!(
2074+
read_fd >= 3 && write_fd >= 3,
2075+
"expected inherited-style fds"
2076+
);
2077+
2078+
// SAFETY: fork() in a test; the child only does async-signal-safe work.
2079+
let pid = unsafe { libc::fork() };
2080+
assert!(pid >= 0, "fork() failed");
2081+
2082+
if pid == 0 {
2083+
// Child: close all fds >= 3 (as daemonize does), then verify the
2084+
// inherited write-end is closed (fcntl -> -1 with errno EBADF) and
2085+
// that stderr (fd 2) survives the sweep. Async-signal-safe only:
2086+
// no allocation, no env reads, no Rust runtime.
2087+
// SAFETY: async-signal-safe operations only.
2088+
unsafe {
2089+
close_fds::close_open_fds(3, &[]);
2090+
2091+
// fd 2 (stderr) must survive the sweep (guards a minfd off-by-one).
2092+
if libc::fcntl(2, libc::F_GETFD) < 0 {
2093+
libc::_exit(2);
2094+
}
2095+
2096+
// The inherited write-end must now be closed: fcntl fails with EBADF.
2097+
let ret = libc::fcntl(write_fd, libc::F_GETFD);
2098+
let errno = {
2099+
#[cfg(any(target_os = "linux", target_os = "android"))]
2100+
{
2101+
*libc::__errno_location()
2102+
}
2103+
#[cfg(not(any(target_os = "linux", target_os = "android")))]
2104+
{
2105+
*libc::__error()
2106+
}
2107+
};
2108+
libc::_exit(if ret == -1 && errno == libc::EBADF {
2109+
0
2110+
} else {
2111+
1
2112+
});
2113+
}
2114+
}
2115+
2116+
// Parent: reap the child and check its exit status.
2117+
let mut status: libc::c_int = 0;
2118+
// SAFETY: valid pid and status pointer.
2119+
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
2120+
assert_eq!(waited, pid, "waitpid() failed");
2121+
2122+
// Close our own copies of the pipe fds.
2123+
// SAFETY: fds are valid and owned by this process.
2124+
unsafe {
2125+
libc::close(read_fd);
2126+
libc::close(write_fd);
2127+
}
2128+
2129+
assert!(
2130+
libc::WIFEXITED(status),
2131+
"child did not exit normally (status={status})"
2132+
);
2133+
// Exit codes: 0 = success; 1 = inherited fd not closed (or wrong errno);
2134+
// 2 = fd 2 (stderr) was incorrectly closed by the sweep.
2135+
assert_eq!(
2136+
libc::WEXITSTATUS(status),
2137+
0,
2138+
"fd hygiene sweep behaved incorrectly (child exit code)"
2139+
);
2140+
}
19832141
}

0 commit comments

Comments
 (0)