Skip to content

Commit d988f40

Browse files
reubenoclaude
andcommitted
refactor(openfiles): address review feedback on fd sharing
Make OpenFile->Stdio conversion fallible (TryFrom) so descriptor duplication failures surface instead of silently using /dev/null; remove the now-infallible try_clone and the TryCloneToStdio helper; gate the fd_management compat tests to non-wasi; tidy comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 26eed99 commit d988f40

6 files changed

Lines changed: 57 additions & 100 deletions

File tree

brush-core/src/commands.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ pub fn compose_std_command<S: AsRef<OsStr>, SE: extensions::ShellExtensions>(
221221
match context.try_fd(OpenFiles::STDIN_FD) {
222222
Some(OpenFile::Stdin(_)) | None => (),
223223
Some(stdin_file) => {
224-
let as_stdio: Stdio = stdin_file.into();
224+
let as_stdio: Stdio = stdin_file.try_into()?;
225225
cmd.stdin(as_stdio);
226226
}
227227
}
@@ -230,7 +230,7 @@ pub fn compose_std_command<S: AsRef<OsStr>, SE: extensions::ShellExtensions>(
230230
match context.try_fd(OpenFiles::STDOUT_FD) {
231231
Some(OpenFile::Stdout(_)) | None => (),
232232
Some(stdout_file) => {
233-
let as_stdio: Stdio = stdout_file.into();
233+
let as_stdio: Stdio = stdout_file.try_into()?;
234234
cmd.stdout(as_stdio);
235235
}
236236
}
@@ -239,7 +239,7 @@ pub fn compose_std_command<S: AsRef<OsStr>, SE: extensions::ShellExtensions>(
239239
match context.try_fd(OpenFiles::STDERR_FD) {
240240
Some(OpenFile::Stderr(_)) | None => {}
241241
Some(stderr_file) => {
242-
let as_stdio: Stdio = stderr_file.into();
242+
let as_stdio: Stdio = stderr_file.try_into()?;
243243
cmd.stderr(as_stdio);
244244
}
245245
}
@@ -723,12 +723,9 @@ pub(crate) async fn invoke_shell_function(
723723
&context.params,
724724
)?;
725725

726-
// Invoke the function. We pass the context's open files through directly rather than
727-
// cloning them; cloning would duplicate every open file descriptor (e.g. via dup()) for
728-
// the duration of the function body. With deeply nested function calls -- as in large
729-
// scripts like nvm -- those duplicated descriptors accumulate and can exhaust the
730-
// process-wide file descriptor limit. The function body shares the same descriptors as
731-
// its caller, matching how a function executes in the current shell process.
726+
// A function executes within the current shell process and shares its caller's open files,
727+
// so the parameters are passed through by shared reference rather than cloned. The shared
728+
// borrow also guarantees the body cannot disturb the caller's open-file table.
732729
let result = body.execute(context.shell, &context.params).await;
733730

734731
// We've come back out, reflect it.

brush-core/src/interp.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,7 +1737,7 @@ pub(crate) async fn setup_redirect(
17371737
let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified);
17381738

17391739
if let Some(f) = params.try_fd(shell, *fd) {
1740-
let target_file = f.try_clone()?;
1740+
let target_file = f.clone();
17411741

17421742
params.open_files.set_fd(fd_num, target_file);
17431743
} else {
@@ -1781,7 +1781,7 @@ pub(crate) async fn setup_redirect(
17811781

17821782
// Duplicate the fd.
17831783
let target_file = if let Some(f) = params.try_fd(shell, source_fd_num) {
1784-
f.try_clone()?
1784+
f.clone()
17851785
} else {
17861786
return Err(error::ErrorKind::BadFileDescriptor(source_fd_num).into());
17871787
};
@@ -1817,7 +1817,7 @@ pub(crate) async fn setup_redirect(
18171817
subshell_cmd,
18181818
)?;
18191819

1820-
let target_file = substitution_file.try_clone()?;
1820+
let target_file = substitution_file.clone();
18211821
params.open_files.set_fd(substitution_fd, substitution_file);
18221822

18231823
let fd_num = specified_fd_num
@@ -1895,7 +1895,7 @@ fn setup_redirect_output_and_error_to(
18951895
)
18961896
})?;
18971897

1898-
let stderr_file = stdout_file.try_clone()?;
1898+
let stderr_file = stdout_file.clone();
18991899

19001900
params.open_files.set_fd(OpenFiles::STDOUT_FD, stdout_file);
19011901
params.open_files.set_fd(OpenFiles::STDERR_FD, stderr_file);

brush-core/src/openfiles.rs

Lines changed: 27 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ pub trait Stream: std::io::Read + std::io::Write + Send + Sync {
3131

3232
/// Represents a file open in a shell context.
3333
///
34-
/// File-backed and pipe-backed variants store their handles behind an [`Arc`] so that cloning
35-
/// an `OpenFile` (which happens whenever the shell forks off a subshell, command substitution,
36-
/// background job, or function call context) merely bumps a reference count instead of issuing
37-
/// a `dup(2)` syscall. Because brush runs subshells as in-process tasks (rather than via
38-
/// `fork(2)` like a traditional shell), every duplicated descriptor consumes a slot in the
39-
/// single process-wide descriptor table; sharing avoids exhausting that table during deeply
40-
/// nested or highly concurrent execution. A real duplicate is only materialized when a
41-
/// descriptor must be handed to an external child process (see [`OpenFile::try_clone_to_owned`]).
34+
/// File- and pipe-backed variants hold their handle behind an [`Arc`], so cloning an `OpenFile`
35+
/// shares the underlying descriptor by reference count. The shell opens a fresh context for each
36+
/// subshell, command substitution, background job, and function call and runs them as in-process
37+
/// tasks against one process-wide descriptor table (rather than via `fork(2)` like a traditional
38+
/// shell); sharing the descriptor keeps deeply nested or highly concurrent execution from
39+
/// exhausting that table. A descriptor is duplicated for real only when an independently owned
40+
/// copy is needed to hand to an external child process — see [`OpenFile::try_clone_to_owned`] and
41+
/// the `Stdio` conversion below.
4242
pub enum OpenFile {
4343
/// The original standard input this process was started with.
4444
Stdin(std::io::Stdin),
@@ -133,17 +133,6 @@ impl std::fmt::Display for OpenFile {
133133
}
134134

135135
impl OpenFile {
136-
/// Tries to duplicate the open file.
137-
///
138-
/// For file- and pipe-backed open files this shares the underlying handle by reference
139-
/// count rather than issuing a `dup(2)`; the resulting `OpenFile` refers to the very same
140-
/// kernel descriptor (matching the shared open-file-description semantics that `dup` would
141-
/// have provided). Use [`OpenFile::try_clone_to_owned`] when an independent descriptor is
142-
/// genuinely required (e.g. to hand to a child process).
143-
pub fn try_clone(&self) -> Result<Self, std::io::Error> {
144-
Ok(self.clone())
145-
}
146-
147136
/// Converts the open file into an `OwnedFd`. For shared file/pipe handles this materializes
148137
/// a real duplicate via `dup(2)` so the caller receives an independently owned descriptor.
149138
#[cfg(unix)]
@@ -240,54 +229,25 @@ impl From<std::io::PipeWriter> for OpenFile {
240229
}
241230
}
242231

243-
impl From<OpenFile> for Stdio {
244-
fn from(open_file: OpenFile) -> Self {
245-
// File/pipe handles are shared (behind an `Arc`), so we cannot move the underlying
246-
// descriptor out; instead we duplicate it to obtain an owned descriptor for the child.
247-
// This is the one place a real `dup` is expected: when wiring up an external process's
248-
// standard streams. If the duplication fails (e.g. descriptor exhaustion), fall back to
249-
// a null device rather than panicking.
250-
fn dup_to_stdio<T: TryCloneToStdio>(handle: &T) -> Stdio {
251-
handle.try_clone_to_stdio().unwrap_or_else(|_| Stdio::null())
252-
}
232+
impl TryFrom<OpenFile> for Stdio {
233+
type Error = error::Error;
253234

235+
fn try_from(open_file: OpenFile) -> Result<Self, Self::Error> {
236+
// File and pipe handles are shared behind an `Arc`, so the descriptor cannot be moved
237+
// out; duplicate it to give the child an independently owned descriptor. Duplication can
238+
// fail (e.g. under descriptor exhaustion), so the conversion is fallible and the error is
239+
// surfaced to the caller rather than silently degrading the child's streams.
254240
match open_file {
255-
OpenFile::Stdin(_) => Self::inherit(),
256-
OpenFile::Stdout(_) => Self::inherit(),
257-
OpenFile::Stderr(_) => Self::inherit(),
258-
OpenFile::File(f) => dup_to_stdio(f.as_ref()),
259-
OpenFile::PipeReader(r) => dup_to_stdio(r.as_ref()),
260-
OpenFile::PipeWriter(w) => dup_to_stdio(w.as_ref()),
261-
// NOTE: Custom streams cannot be converted to `Stdio`; we do our best here
262-
// and return a null device instead.
263-
OpenFile::Stream(_) => Self::null(),
241+
OpenFile::Stdin(_) | OpenFile::Stdout(_) | OpenFile::Stderr(_) => Ok(Self::inherit()),
242+
OpenFile::File(f) => Ok(f.try_clone()?.into()),
243+
OpenFile::PipeReader(r) => Ok(r.try_clone()?.into()),
244+
OpenFile::PipeWriter(w) => Ok(w.try_clone()?.into()),
245+
// Custom streams have no descriptor to hand to a child process.
246+
OpenFile::Stream(_) => Ok(Self::null()),
264247
}
265248
}
266249
}
267250

268-
/// Helper trait to duplicate a handle into an owned `Stdio` in a cross-platform way.
269-
trait TryCloneToStdio {
270-
fn try_clone_to_stdio(&self) -> std::io::Result<Stdio>;
271-
}
272-
273-
impl TryCloneToStdio for std::fs::File {
274-
fn try_clone_to_stdio(&self) -> std::io::Result<Stdio> {
275-
Ok(self.try_clone()?.into())
276-
}
277-
}
278-
279-
impl TryCloneToStdio for std::io::PipeReader {
280-
fn try_clone_to_stdio(&self) -> std::io::Result<Stdio> {
281-
Ok(self.try_clone()?.into())
282-
}
283-
}
284-
285-
impl TryCloneToStdio for std::io::PipeWriter {
286-
fn try_clone_to_stdio(&self) -> std::io::Result<Stdio> {
287-
Ok(self.try_clone()?.into())
288-
}
289-
}
290-
291251
impl std::io::Read for OpenFile {
292252
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
293253
match self {
@@ -300,8 +260,8 @@ impl std::io::Read for OpenFile {
300260
)),
301261
// The handle is shared behind an `Arc`; read through a shared reference (`&File`
302262
// and `&PipeReader` both implement `Read`).
303-
Self::File(f) => (&**f).read(buf),
304-
Self::PipeReader(reader) => (&**reader).read(buf),
263+
Self::File(f) => f.as_ref().read(buf),
264+
Self::PipeReader(reader) => reader.as_ref().read(buf),
305265
Self::PipeWriter(_) => Err(std::io::Error::other(
306266
error::ErrorKind::OpenFileNotReadable("pipe writer"),
307267
)),
@@ -320,11 +280,11 @@ impl std::io::Write for OpenFile {
320280
Self::Stderr(f) => f.write(buf),
321281
// The handle is shared behind an `Arc`; write through a shared reference (`&File`
322282
// and `&PipeWriter` both implement `Write`).
323-
Self::File(f) => (&**f).write(buf),
283+
Self::File(f) => f.as_ref().write(buf),
324284
Self::PipeReader(_) => Err(std::io::Error::other(
325285
error::ErrorKind::OpenFileNotWritable("pipe reader"),
326286
)),
327-
Self::PipeWriter(writer) => (&**writer).write(buf),
287+
Self::PipeWriter(writer) => writer.as_ref().write(buf),
328288
Self::Stream(s) => s.write(buf),
329289
}
330290
}
@@ -334,9 +294,9 @@ impl std::io::Write for OpenFile {
334294
Self::Stdin(_) => Ok(()),
335295
Self::Stdout(f) => f.flush(),
336296
Self::Stderr(f) => f.flush(),
337-
Self::File(f) => (&**f).flush(),
297+
Self::File(f) => f.as_ref().flush(),
338298
Self::PipeReader(_) => Ok(()),
339-
Self::PipeWriter(writer) => (&**writer).flush(),
299+
Self::PipeWriter(writer) => writer.as_ref().flush(),
340300
Self::Stream(s) => s.flush(),
341301
}
342302
}

brush-core/src/shell/fs.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ impl<SE: crate::extensions::ShellExtensions> crate::Shell<SE> {
204204
&& let Ok(fd_num) = filename.to_string_lossy().to_string().parse::<ShellFd>()
205205
&& let Some(open_file) = params.try_fd(self, fd_num)
206206
{
207-
return open_file.try_clone();
207+
return Ok(open_file.clone());
208208
}
209209

210210
Ok(options.open(path_to_open)?.into())

brush-core/src/shell/io.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ impl<SE: extensions::ShellExtensions> crate::Shell<SE> {
6464
};
6565

6666
// If we have a valid trace file, write to it.
67-
if let Some(trace_file) = trace_file
68-
&& let Ok(mut trace_file) = trace_file.try_clone()
69-
{
67+
if let Some(mut trace_file) = trace_file {
7068
let _ = writeln!(trace_file, "{prefix}{}", command.as_ref());
7169
}
7270
}

brush-shell/tests/cases/compat/fd_management.yaml

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,25 @@ name: "File descriptor management"
33
# exhaust the process-wide file-descriptor table.
44
#
55
# brush runs subshells, command substitutions, background jobs, and function
6-
# call contexts as in-process tasks rather than via fork(2). Each such context
7-
# inherits the caller's open files. If those open files are duplicated (via
8-
# dup(2)) on every clone, deeply nested or highly concurrent scripts accumulate
9-
# descriptors super-linearly and can exhaust the descriptor table -- which is
10-
# exactly what broke `nvm ls`. A real shell (bash) gives each forked subshell
11-
# its own descriptor table, so it stays well within the limit.
6+
# call contexts as in-process tasks rather than via fork(2), so they all share a
7+
# single process-wide descriptor table. Each context inherits the caller's open
8+
# files; sharing those descriptors -- rather than giving each context its own
9+
# copy -- is what keeps deeply nested or highly concurrent scripts within the
10+
# limit. A real shell gives each forked subshell its own descriptor table and so
11+
# stays well within the limit too.
1212
#
13-
# Each case lowers the descriptor limit with `ulimit -n` and then runs a
14-
# workload that, with the old duplicate-on-clone behavior, blew past it
15-
# (producing "Too many open files" / "failed to duplicate open file" errors and
16-
# truncated output). With proper descriptor sharing the workload stays
17-
# comfortably within the limit, matching bash. The limits are chosen so the
18-
# buggy behavior exceeds them with a wide margin while the correct behavior
19-
# leaves several-fold headroom.
13+
# Each case lowers the descriptor limit with `ulimit -n` and runs a workload that
14+
# stays within the limit only when inherited descriptors are shared across
15+
# contexts; if each context copied them, the workload would exceed the limit and
16+
# fail with "Too many open files" and truncated output. The limits leave the
17+
# correct (sharing) behavior several-fold headroom. These cases assume Unix
18+
# `ulimit -n` semantics.
2019
cases:
2120
# Concurrent fan-out: every backgrounded job is alive simultaneously (they are
22-
# all launched before `wait`), so duplicating the inherited descriptors per job
23-
# multiplies the live descriptor count by the number of jobs.
21+
# all launched before `wait`), so copying the inherited descriptors per job
22+
# would multiply the live descriptor count by the number of jobs.
2423
- name: "Many concurrent background jobs each using command substitution"
24+
incompatible_platforms: ["wasi"]
2525
args:
2626
- "-c"
2727
- |
@@ -37,6 +37,7 @@ cases:
3737
# /dev/null via redirects -- the precise shape that exhausted descriptors in
3838
# `nvm ls`.
3939
- name: "Subshell of backgrounded jobs with redirects piped to a filter"
40+
incompatible_platforms: ["wasi"]
4041
args:
4142
- "-c"
4243
- |
@@ -49,9 +50,10 @@ cases:
4950
( for i in $(seq 1 50); do work "$i" 2>/dev/null & done; wait ) | sort | wc -l | tr -d ' '
5051
5152
# Nested depth: each command-substitution level keeps its ancestors' contexts
52-
# alive while it runs, so duplicating inherited descriptors grows the live
53+
# alive while it runs, so copying inherited descriptors would grow the live
5354
# count with nesting depth.
5455
- name: "Deeply nested command substitution with redirects stays within the fd limit"
56+
incompatible_platforms: ["wasi"]
5557
args:
5658
- "-c"
5759
- |

0 commit comments

Comments
 (0)