Skip to content

Commit 15b86f1

Browse files
bartlomiejuclaude
andcommitted
fix(ext/node): support numeric FDs in child_process stdio array
Now that `fs.openSync` returns real OS file descriptors (#33039), numeric values in child_process stdio arrays should be treated as raw fds rather than Deno resource IDs. This re-lands the functionality from #32959 (reverted in #33017) with the correct fd-based approach. - Rename `StdioOrRid` to `StdioOrFd`, replace `Rid(ResourceId)` with `Fd(i32)` - `as_stdio()` dups the fd directly (Unix: `libc::dup`, Windows: handle clone) instead of looking up the resource table - `extra_stdio` now accepts `StdioOrFd` so numeric fds work beyond stdin/stdout/stderr - Hardcoded `Rid(1)`/`Rid(2)` for inherit become `Fd(1)`/`Fd(2)` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 593bb1c commit 15b86f1

File tree

3 files changed

+155
-88
lines changed

3 files changed

+155
-88
lines changed

ext/node/polyfills/internal/child_process.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ function toDenoStdio(
748748
return "inherit";
749749
}
750750
if (typeof pipe === "number") {
751-
/* Assume it's a rid returned by fs APIs */
751+
/* Real OS file descriptor, e.g. from fs.openSync() */
752752
return pipe;
753753
}
754754

ext/process/lib.rs

Lines changed: 131 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use std::collections::HashMap;
77
use std::ffi::OsString;
88
use std::io::Write;
99
#[cfg(unix)]
10+
use std::os::unix::io::FromRawFd;
11+
#[cfg(unix)]
1012
use std::os::unix::prelude::ExitStatusExt;
1113
#[cfg(unix)]
1214
use std::os::unix::process::CommandExt;
@@ -35,7 +37,6 @@ use deno_io::ChildStderrResource;
3537
use deno_io::ChildStdinResource;
3638
use deno_io::ChildStdoutResource;
3739
use deno_io::IntoRawIoHandle;
38-
use deno_io::fs::FileResource;
3940
use deno_os::SignalError;
4041
use deno_permissions::PathQueryDescriptor;
4142
use deno_permissions::PermissionsContainer;
@@ -79,12 +80,12 @@ impl Stdio {
7980
}
8081

8182
#[derive(Copy, Clone, Eq, PartialEq)]
82-
pub enum StdioOrRid {
83+
pub enum StdioOrFd {
8384
Stdio(Stdio),
84-
Rid(ResourceId),
85+
Fd(i32),
8586
}
8687

87-
impl<'de> Deserialize<'de> for StdioOrRid {
88+
impl<'de> Deserialize<'de> for StdioOrFd {
8889
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
8990
where
9091
D: serde::Deserializer<'de>,
@@ -93,47 +94,71 @@ impl<'de> Deserialize<'de> for StdioOrRid {
9394
let value = Value::deserialize(deserializer)?;
9495
match value {
9596
Value::String(val) => match val.as_str() {
96-
"inherit" => Ok(StdioOrRid::Stdio(Stdio::Inherit)),
97-
"piped" => Ok(StdioOrRid::Stdio(Stdio::Piped)),
98-
"null" => Ok(StdioOrRid::Stdio(Stdio::Null)),
97+
"inherit" => Ok(StdioOrFd::Stdio(Stdio::Inherit)),
98+
"piped" => Ok(StdioOrFd::Stdio(Stdio::Piped)),
99+
"null" => Ok(StdioOrFd::Stdio(Stdio::Null)),
99100
"ipc_for_internal_use" => {
100-
Ok(StdioOrRid::Stdio(Stdio::IpcForInternalUse))
101+
Ok(StdioOrFd::Stdio(Stdio::IpcForInternalUse))
101102
}
102103
val => Err(serde::de::Error::unknown_variant(
103104
val,
104105
&["inherit", "piped", "null"],
105106
)),
106107
},
107-
Value::Number(val) => match val.as_u64() {
108-
Some(val) if val <= ResourceId::MAX as u64 => {
109-
Ok(StdioOrRid::Rid(val as ResourceId))
108+
Value::Number(val) => match val.as_i64() {
109+
Some(val) if val >= 0 && val <= i32::MAX as i64 => {
110+
Ok(StdioOrFd::Fd(val as i32))
110111
}
111-
_ => Err(serde::de::Error::custom("Expected a positive integer")),
112+
_ => Err(serde::de::Error::custom(
113+
"Expected a non-negative integer file descriptor",
114+
)),
112115
},
113116
_ => Err(serde::de::Error::custom(
114-
r#"Expected a resource id, "inherit", "piped", or "null""#,
117+
r#"Expected a file descriptor, "inherit", "piped", or "null""#,
115118
)),
116119
}
117120
}
118121
}
119122

120-
impl StdioOrRid {
121-
pub fn as_stdio(
122-
&self,
123-
state: &mut OpState,
124-
) -> Result<StdStdio, ProcessError> {
123+
impl StdioOrFd {
124+
pub fn as_stdio(&self) -> Result<StdStdio, ProcessError> {
125125
match &self {
126-
StdioOrRid::Stdio(val) => Ok(val.as_stdio()),
127-
StdioOrRid::Rid(rid) => {
128-
Ok(FileResource::with_file(state, *rid, |file| {
129-
file.as_stdio().map_err(deno_error::JsErrorBox::from_err)
130-
})?)
126+
StdioOrFd::Stdio(val) => Ok(val.as_stdio()),
127+
StdioOrFd::Fd(fd) => {
128+
#[cfg(unix)]
129+
{
130+
// Safety: we dup the fd so the original remains open for the caller
131+
let new_fd = unsafe { libc::dup(*fd) };
132+
if new_fd < 0 {
133+
return Err(ProcessError::Io(std::io::Error::last_os_error()));
134+
}
135+
// Safety: new_fd is a valid, freshly duplicated file descriptor
136+
Ok(unsafe {
137+
StdStdio::from(std::os::unix::io::OwnedFd::from_raw_fd(new_fd))
138+
})
139+
}
140+
#[cfg(windows)]
141+
{
142+
// Convert CRT file descriptor to OS handle, clone it, and wrap in Stdio
143+
let handle = unsafe { libc::get_osfhandle(*fd as _) };
144+
if handle == -1 {
145+
return Err(ProcessError::Io(std::io::Error::last_os_error()));
146+
}
147+
let borrowed = unsafe {
148+
std::os::windows::io::BorrowedHandle::borrow_raw(
149+
handle as std::os::windows::io::RawHandle,
150+
)
151+
};
152+
let owned =
153+
borrowed.try_clone_to_owned().map_err(ProcessError::Io)?;
154+
Ok(StdStdio::from(owned))
155+
}
131156
}
132157
}
133158
}
134159

135160
pub fn is_ipc(&self) -> bool {
136-
matches!(self, StdioOrRid::Stdio(Stdio::IpcForInternalUse))
161+
matches!(self, StdioOrFd::Stdio(Stdio::IpcForInternalUse))
137162
}
138163
}
139164

@@ -248,7 +273,7 @@ pub struct SpawnArgs {
248273

249274
input: Option<JsBuffer>,
250275

251-
extra_stdio: Vec<Stdio>,
276+
extra_stdio: Vec<StdioOrFd>,
252277
detached: bool,
253278
needs_npm_process_state: bool,
254279
#[cfg(unix)]
@@ -349,9 +374,9 @@ pub enum ProcessError {
349374
#[derive(Deserialize)]
350375
#[serde(rename_all = "camelCase")]
351376
pub struct ChildStdio {
352-
stdin: StdioOrRid,
353-
stdout: StdioOrRid,
354-
stderr: StdioOrRid,
377+
stdin: StdioOrFd,
378+
stdout: StdioOrFd,
379+
stderr: StdioOrFd,
355380
}
356381

357382
#[derive(ToV8)]
@@ -515,16 +540,16 @@ fn create_command(
515540
} else if args.input.is_some() {
516541
command.stdin(StdStdio::piped());
517542
} else {
518-
command.stdin(args.stdio.stdin.as_stdio(state)?);
543+
command.stdin(args.stdio.stdin.as_stdio()?);
519544
}
520545

521546
command.stdout(match args.stdio.stdout {
522-
StdioOrRid::Stdio(Stdio::Inherit) => StdioOrRid::Rid(1).as_stdio(state)?,
523-
value => value.as_stdio(state)?,
547+
StdioOrFd::Stdio(Stdio::Inherit) => StdioOrFd::Fd(1).as_stdio()?,
548+
value => value.as_stdio()?,
524549
});
525550
command.stderr(match args.stdio.stderr {
526-
StdioOrRid::Stdio(Stdio::Inherit) => StdioOrRid::Rid(2).as_stdio(state)?,
527-
value => value.as_stdio(state)?,
551+
StdioOrFd::Stdio(Stdio::Inherit) => StdioOrFd::Fd(2).as_stdio()?,
552+
value => value.as_stdio()?,
528553
});
529554

530555
#[cfg(unix)]
@@ -578,27 +603,34 @@ fn create_command(
578603
for (i, stdio) in args.extra_stdio.into_iter().enumerate() {
579604
// index 0 in `extra_stdio` actually refers to fd 3
580605
// because we handle stdin,stdout,stderr specially
581-
let fd = (i + 3) as i32;
582-
// TODO(nathanwhit): handle inherited, but this relies on the parent process having
583-
// fds open already. since we don't generally support dealing with raw fds,
584-
// we can't properly support this
585-
if matches!(stdio, Stdio::Piped) {
586-
let (fd1, fd2) = deno_io::bi_pipe_pair_raw()?;
587-
fds_to_dup.push((fd2, fd));
588-
fds_to_close.push(fd2);
589-
let rid = state.resource_table.add(
590-
match deno_io::BiPipeResource::from_raw_handle(fd1) {
591-
Ok(v) => v,
592-
Err(e) => {
593-
log::warn!("Failed to open bidirectional pipe for fd {fd}: {e}");
594-
extra_pipe_rids.push(None);
595-
continue;
596-
}
597-
},
598-
);
599-
extra_pipe_rids.push(Some(rid));
600-
} else {
601-
extra_pipe_rids.push(None);
606+
let target_fd = (i + 3) as i32;
607+
match stdio {
608+
StdioOrFd::Stdio(Stdio::Piped) => {
609+
let (fd1, fd2) = deno_io::bi_pipe_pair_raw()?;
610+
fds_to_dup.push((fd2, target_fd));
611+
fds_to_close.push(fd2);
612+
let rid = state.resource_table.add(
613+
match deno_io::BiPipeResource::from_raw_handle(fd1) {
614+
Ok(v) => v,
615+
Err(e) => {
616+
log::warn!(
617+
"Failed to open bidirectional pipe for fd {target_fd}: {e}"
618+
);
619+
extra_pipe_rids.push(None);
620+
continue;
621+
}
622+
},
623+
);
624+
extra_pipe_rids.push(Some(rid));
625+
}
626+
StdioOrFd::Fd(fd) => {
627+
// Dup the caller's fd onto the target fd slot in the child
628+
fds_to_dup.push((fd, target_fd));
629+
extra_pipe_rids.push(None);
630+
}
631+
_ => {
632+
extra_pipe_rids.push(None);
633+
}
602634
}
603635
}
604636

@@ -670,29 +702,41 @@ fn create_command(
670702
for (i, stdio) in args.extra_stdio.into_iter().enumerate() {
671703
// index 0 in `extra_stdio` actually refers to fd 3
672704
// because we handle stdin,stdout,stderr specially
673-
let fd = (i + 3) as i32;
674-
// TODO(nathanwhit): handle inherited, but this relies on the parent process having
675-
// fds open already. since we don't generally support dealing with raw fds,
676-
// we can't properly support this
677-
if matches!(stdio, Stdio::Piped) {
678-
let (fd1, fd2) = deno_io::bi_pipe_pair_raw()?;
679-
handles_to_close.push(fd2);
680-
let rid = state.resource_table.add(
681-
match deno_io::BiPipeResource::from_raw_handle(fd1) {
682-
Ok(v) => v,
683-
Err(e) => {
684-
log::warn!("Failed to open bidirectional pipe for fd {fd}: {e}");
685-
extra_pipe_rids.push(None);
686-
continue;
687-
}
688-
},
689-
);
690-
command.extra_handle(Some(fd2));
691-
extra_pipe_rids.push(Some(rid));
692-
} else {
693-
// no handle, push an empty handle so we need get the right fds for following handles
694-
command.extra_handle(None);
695-
extra_pipe_rids.push(None);
705+
let target_fd = (i + 3) as i32;
706+
match stdio {
707+
StdioOrFd::Stdio(Stdio::Piped) => {
708+
let (fd1, fd2) = deno_io::bi_pipe_pair_raw()?;
709+
handles_to_close.push(fd2);
710+
let rid = state.resource_table.add(
711+
match deno_io::BiPipeResource::from_raw_handle(fd1) {
712+
Ok(v) => v,
713+
Err(e) => {
714+
log::warn!(
715+
"Failed to open bidirectional pipe for fd {target_fd}: {e}"
716+
);
717+
extra_pipe_rids.push(None);
718+
continue;
719+
}
720+
},
721+
);
722+
command.extra_handle(Some(fd2));
723+
extra_pipe_rids.push(Some(rid));
724+
}
725+
StdioOrFd::Fd(fd) => {
726+
// Convert CRT fd to OS handle and pass to child
727+
let handle = unsafe { libc::get_osfhandle(fd as _) };
728+
if handle != -1 {
729+
command.extra_handle(Some(handle as _));
730+
} else {
731+
command.extra_handle(None);
732+
}
733+
extra_pipe_rids.push(None);
734+
}
735+
_ => {
736+
// no handle, push an empty handle so we get the right fds for following handles
737+
command.extra_handle(None);
738+
extra_pipe_rids.push(None);
739+
}
696740
}
697741
}
698742

@@ -1116,8 +1160,8 @@ fn op_spawn_sync(
11161160
state: &mut OpState,
11171161
#[serde] args: SpawnArgs,
11181162
) -> Result<SpawnOutput, ProcessError> {
1119-
let stdout = matches!(args.stdio.stdout, StdioOrRid::Stdio(Stdio::Piped));
1120-
let stderr = matches!(args.stdio.stderr, StdioOrRid::Stdio(Stdio::Piped));
1163+
let stdout = matches!(args.stdio.stdout, StdioOrFd::Stdio(Stdio::Piped));
1164+
let stderr = matches!(args.stdio.stderr, StdioOrFd::Stdio(Stdio::Piped));
11211165
let input = args.input.clone();
11221166
let (mut command, _, _, _) =
11231167
create_command(state, args, "Deno.Command().outputSync()")?;
@@ -1219,11 +1263,11 @@ mod deprecated {
12191263
cwd: Option<String>,
12201264
env: Vec<(String, String)>,
12211265
#[from_v8(serde)]
1222-
stdin: StdioOrRid,
1266+
stdin: StdioOrFd,
12231267
#[from_v8(serde)]
1224-
stdout: StdioOrRid,
1268+
stdout: StdioOrFd,
12251269
#[from_v8(serde)]
1226-
stderr: StdioOrRid,
1270+
stderr: StdioOrFd,
12271271
}
12281272

12291273
struct ChildResource {
@@ -1296,20 +1340,20 @@ mod deprecated {
12961340
}
12971341

12981342
// TODO: make this work with other resources, eg. sockets
1299-
c.stdin(run_args.stdin.as_stdio(state)?);
1343+
c.stdin(run_args.stdin.as_stdio()?);
13001344
c.stdout(
13011345
match run_args.stdout {
1302-
StdioOrRid::Stdio(Stdio::Inherit) => StdioOrRid::Rid(1),
1346+
StdioOrFd::Stdio(Stdio::Inherit) => StdioOrFd::Fd(1),
13031347
value => value,
13041348
}
1305-
.as_stdio(state)?,
1349+
.as_stdio()?,
13061350
);
13071351
c.stderr(
13081352
match run_args.stderr {
1309-
StdioOrRid::Stdio(Stdio::Inherit) => StdioOrRid::Rid(2),
1353+
StdioOrFd::Stdio(Stdio::Inherit) => StdioOrFd::Fd(2),
13101354
value => value,
13111355
}
1312-
.as_stdio(state)?,
1356+
.as_stdio()?,
13131357
);
13141358

13151359
// We want to kill child when it's closed

tests/unit_node/child_process_test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,3 +1365,26 @@ Deno.test(function spawnSyncReturnsPid() {
13651365
assertEquals(typeof ret.pid, "number");
13661366
assert(ret.pid > 0);
13671367
});
1368+
1369+
Deno.test(async function spawnWithNumericFdInStdioArray() {
1370+
const fs = await import("node:fs");
1371+
const tmpFile = Deno.makeTempFileSync();
1372+
try {
1373+
const fd = fs.openSync(tmpFile, "w");
1374+
const child = spawn(Deno.execPath(), [
1375+
"eval",
1376+
"/* child just exits successfully */",
1377+
], {
1378+
stdio: ["ignore", "pipe", "pipe", fd],
1379+
});
1380+
const { promise, resolve } = Promise.withResolvers<void>();
1381+
child.on("close", (code: number) => {
1382+
assertEquals(code, 0);
1383+
resolve();
1384+
});
1385+
await promise;
1386+
fs.closeSync(fd);
1387+
} finally {
1388+
Deno.removeSync(tmpFile);
1389+
}
1390+
});

0 commit comments

Comments
 (0)