mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## Why Linux descriptor cleanup used allocating directory enumeration after `fork`. The child needs allocation-free cleanup while preserving Rust's spawn-error channel until `exec` succeeds. ## What changed - Mark unrelated descriptors close-on-exec with `close_range(CLOSE_RANGE_CLOEXEC)`, leaving stdio and explicitly preserved descriptors' flags untouched. - Fall back to stack-backed `getdents64` enumeration of the child's descriptor table when `close_range` fails. - Document best-effort cleanup: failures allow launch to proceed and may leave unrelated descriptors inherited. ## Testing Add Linux tests for descriptor inheritance, unsorted and duplicate allowlists, unchanged parent descriptors, fallback cleanup above a lowered descriptor limit, preserved exec-error reporting, and successful launch when both cleanup paths fail. Retain MCP coverage for descriptor exclusion in servers and descendants. GitOrigin-RevId: 13abb95f82557324c7b40feb36bd6f806d60674e
130 lines
4.5 KiB
Rust
130 lines
4.5 KiB
Rust
//! Linux descriptor cleanup after fork, using only stack storage and syscalls.
|
|
//! Stdio and explicitly preserved FDs remain usable. CLOEXEC keeps Rust's
|
|
//! spawn-error channel alive until exec succeeds. Cleanup is best-effort:
|
|
//! failures do not prevent launch and may leave unrelated descriptors inherited.
|
|
|
|
use std::ffi::CStr;
|
|
use std::io;
|
|
use std::os::fd::RawFd;
|
|
|
|
pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
|
|
// Mark rather than close: std::process still needs its CLOEXEC error
|
|
// pipe if exec fails. Do not alter flags on explicitly preserved FDs.
|
|
let mut first = 3_u32;
|
|
loop {
|
|
// The keep-list need not be sorted. Walking its gaps leaves each kept
|
|
// FD's flags untouched, without allocating a sorted copy after fork.
|
|
let next = preserved_fds
|
|
.iter()
|
|
.copied()
|
|
.filter_map(|fd| u32::try_from(fd).ok())
|
|
.filter(|&fd| fd >= first)
|
|
.min();
|
|
let last = next.map_or(u32::MAX, |fd| fd - 1);
|
|
if first <= last
|
|
// SAFETY: close_range has no pointers and changes only this child's FDs.
|
|
&& unsafe {
|
|
libc::syscall(
|
|
libc::SYS_close_range,
|
|
first,
|
|
last,
|
|
libc::CLOSE_RANGE_CLOEXEC,
|
|
)
|
|
} != 0
|
|
{
|
|
break;
|
|
}
|
|
match next {
|
|
Some(fd) => first = fd + 1,
|
|
None => return,
|
|
}
|
|
}
|
|
// Older kernels (or seccomp policies) may reject close_range. Read
|
|
// this child's descriptor table without libc's allocating DIR API.
|
|
// Preserve best-effort launch behavior if the fallback also fails. Logging
|
|
// here could allocate or lock after fork; writing to child stderr may block.
|
|
close_from_proc(preserved_fds);
|
|
}
|
|
|
|
fn mark_cloexec(fd: RawFd, preserved_fds: &[RawFd]) {
|
|
if fd <= libc::STDERR_FILENO || preserved_fds.contains(&fd) {
|
|
return;
|
|
}
|
|
// SAFETY: fcntl operates on the child's descriptors without allocating.
|
|
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
|
if flags == -1 {
|
|
return;
|
|
}
|
|
if flags & libc::FD_CLOEXEC == 0 {
|
|
// SAFETY: only adds CLOEXEC; existing flags and the descriptor remain intact.
|
|
unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
|
|
}
|
|
}
|
|
|
|
fn close_from_proc(preserved_fds: &[RawFd]) {
|
|
use std::os::fd::AsRawFd;
|
|
use std::os::fd::FromRawFd;
|
|
use std::os::fd::OwnedFd;
|
|
|
|
// Open after fork: a proc directory opened in the parent would enumerate
|
|
// the parent's changing descriptor table instead of this child's snapshot.
|
|
// SAFETY: the path is NUL-terminated and open does not allocate.
|
|
let raw = unsafe {
|
|
libc::open(
|
|
c"/proc/self/fd".as_ptr(),
|
|
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
|
|
)
|
|
};
|
|
if raw == -1 {
|
|
return;
|
|
}
|
|
// SAFETY: open returned a new owned descriptor; dropping it only calls close.
|
|
let directory = unsafe { OwnedFd::from_raw_fd(raw) };
|
|
let mut buffer = [0_u8; 4096];
|
|
loop {
|
|
// SAFETY: getdents64 writes at most buffer.len() bytes into stack storage.
|
|
let count = unsafe {
|
|
libc::syscall(
|
|
libc::SYS_getdents64,
|
|
directory.as_raw_fd(),
|
|
buffer.as_mut_ptr(),
|
|
buffer.len(),
|
|
)
|
|
};
|
|
if count == -1 {
|
|
if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
|
|
continue;
|
|
}
|
|
return;
|
|
}
|
|
if count == 0 {
|
|
return;
|
|
}
|
|
let mut entries = &buffer[..count as usize];
|
|
// Linux getdents64 records have two 64-bit fields, a u16 record length,
|
|
// one type byte, then the NUL-terminated name (independent of libc ABI).
|
|
while !entries.is_empty() {
|
|
if entries.len() < 20 {
|
|
return;
|
|
}
|
|
let length = u16::from_ne_bytes([entries[16], entries[17]]) as usize;
|
|
if length < 20 || length > entries.len() {
|
|
return;
|
|
}
|
|
if let Ok(name) = CStr::from_bytes_until_nul(&entries[19..length])
|
|
&& let Ok(name) = name.to_str()
|
|
&& let Ok(fd) = name.parse::<RawFd>()
|
|
{
|
|
// A failure on one descriptor must not prevent cleanup of
|
|
// the remaining descriptors.
|
|
mark_cloexec(fd, preserved_fds);
|
|
}
|
|
entries = &entries[length..];
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "linux_fds_tests.rs"]
|
|
mod tests;
|