feat(core): streaming-stdio popen → Process with per-stream StdioMode#118
Conversation
congwang-mk
left a comment
There was a problem hiding this comment.
Automated review of the streaming-stdio popen change. The refactor is clean and capture/inherit behavior is preserved byte-for-byte; three findings below (one correctness, one cleanup, one test flake).
| // Fail closed: if /dev/null can't be opened (or dup2 fails), | ||
| // close the fd rather than leaving the inherited one in place — | ||
| // the workload gets EBADF, never the supervisor's stdin. | ||
| let fd = unsafe { libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_RDONLY) }; |
There was a problem hiding this comment.
Bug: Null self-closes the target fd when the supervisor already closed that std fd (fail-closed guarantee violated).
This arm does let fd = open("/dev/null"); if fd<0 || dup2(fd,0)<0 { close(0) } if fd>=0 { close(fd) }. If the supervisor was started with fd 0 closed (common for daemonized embedders), open returns the lowest free fd = 0 itself. dup2(0,0) is a POSIX no-op returning 0 (not <0), so the error branch is skipped; then if fd>=0 { close(fd) } runs close(0), closing the very fd that was supposed to be /dev/null. The workload then gets EBADF on stdin instead of /dev/null — exactly the hardening path the comment promises.
The identical defect is in the stdout (line 1602, fd 1) and stderr (line 1622, fd 2) arms. The same dup2(fd,fd) no-op also hits the Piped arms: a pipe end opened O_CLOEXEC and allocated onto a pre-closed target fd keeps CLOEXEC set, so the stream vanishes at execve. Tests miss this because the cargo harness always has 0/1/2 open.
Fix: guard the source == target case — skip the close, and for the piped case clear CLOEXEC on the target.
There was a problem hiding this comment.
Fixed at the root rather than the symptom (fde8d83). Instead of special-casing dup2(fd,fd), each piped source is now relocated to a fresh high fd via F_DUPFD_CLOEXEC (>=3) before being dup2'd down onto its 0/1/2 target, and the original CLOEXEC ends are mem::forget'd (they close at execve) so Drop can't close a fd we reassigned. That removes the src==target case — and the sibling stale-snapshot / cross-stream clobber hazards — as a class. dup2 is now checked and fails closed like Null. Coverage: the relocate path runs in every piped test e2e, plus a keystone unit test on relocate_high (returns >=3, CLOEXEC, same description). The exact closed-std-fd trigger isn't deterministically reproducible (the control PipePair claims the low fds first), so I left that as a documented limitation rather than a flaky test.
|
|
||
| if let Some((_, ref stdout_w)) = stdout_r { | ||
| unsafe { libc::dup2(stdout_w.as_raw_fd(), 1) }; | ||
| // stdin (fd 0): Piped → child reads the pipe's read end; Null → /dev/null. |
There was a problem hiding this comment.
Cleanup: the three stdin/stdout/stderr arms duplicate the security-sensitive fail-closed sequence.
The arms are copy-paste differing only in O_RDONLY/O_WRONLY and target fd 0/1/2; the open/dup2/close fail-closed block is written three times (~55 lines of inline unsafe). The suite already needed test_popen_stderr_piped specifically to guard a copy-paste fd-1-vs-2 swap. A fn wire_stdio(mode, target, pipe_end, open_flags) (or a 3-row table) collapses this into one audited path — and the fix for the Null/dup2(fd,fd) bug above then lands in one place instead of three.
There was a problem hiding this comment.
Done — collapsed into one wire_child_stdio helper plus relocate_high, so the fail-closed / fd logic lives in a single audited path. (fde8d83)
| // sb (and child borrow) dropped here → Sandbox::drop kills the group. | ||
| } | ||
| // Give the kill + init-reap a moment to settle. | ||
| std::thread::sleep(std::time::Duration::from_millis(200)); |
There was a problem hiding this comment.
Test flake: fixed 200ms sleep races zombie reaping.
After the Sandbox drop SIGKILLs the group, the backgrounded sleep 100 grandchild becomes a zombie until init/subreaper reaps it, and kill(pid, 0) returns 0 for an un-reaped zombie. On a loaded CI box where reaping takes >200ms, assert!(!alive) fails spuriously (and, less likely, a reused PID inside the window could mask a real leak). Prefer polling for ESRCH with a timeout over a single fixed sleep.
There was a problem hiding this comment.
Fixed — replaced the fixed 200ms sleep with a poll for ESRCH (up to 5s), since the grandchild is a zombie until init reaps it and kill(pid,0) returns 0 for a zombie. (fde8d83)
4f19f72 to
c1b20f3
Compare
Add Sandbox::popen, the first piece of the streaming-stdio process API (RFC multikernel#67). It generalizes the do_create capture/inherit boolean into a per-stream StdioMode { Inherit, Piped, Null } and returns a live Process whose Piped stream ends the caller owns (take_stdin/stdout/stderr), so a confined process can be driven over stdio while alive (MCP/LSP/REPL/ JSON-RPC) — which capture-mode run cannot express. - StdioMode is #[repr(u32)] with pinned discriminants so the FFI/Python bindings (later PRs) bind a stable C enum. - do_create is now a thin wrapper over do_create_stdio, so run/spawn/create and their interactive variants keep byte-identical capture/inherit behavior. - Child-side fd wiring is collision-safe. The post-fork path must survive a supervisor started with 0/1/2 closed (common for daemons): pipe2 can then allocate a pipe end onto a std fd, so a naive dup2 would alias the target, let a sibling stream clobber an end still needed, or act on a stale raw-fd snapshot. Each Piped source is first relocated to a fresh high fd (>= 3) via F_DUPFD_CLOEXEC, then dup2'd down onto its 0/1/2 target through one audited helper; the original O_CLOEXEC ends are mem::forget'd (they close at execve) so their Drop can't close a fd we reassigned. dup2 failure fails closed. - Null wires the stream to /dev/null and fails closed: if it cannot be opened the fd is closed, never left inherited from the supervisor. - Piped stdin adds a parent-held write end; wait() drops an untaken one so a child reading stdin still reaches EOF instead of hanging. A *taken* stdin the caller must close before wait() (documented; same contract as std). - popen does not poll for execve (a streaming reader blocks until bytes arrive, and the poll would spuriously time out on a fast-exiting child). - Process borrows the Sandbox (it does not own the process like std::process::Child); the child is killed and reaped by Sandbox::drop or Process::kill (whole process group). #[must_use] flags an abandoned handle. Folding the legacy spawn/wait into Process is deferred to a later phase. Tests (multi-threaded runtime — a blocking pipe read must not starve the seccomp supervisor, documented on popen): stdin+stdout roundtrip, stdout-only, stderr-only, all-three piped, Null stdout proven to be /dev/null, Null stdin EOF, untaken-stdin EOF, explicit kill + non-success status, whole-group kill-on-drop reaching a forked grandchild (polls for ESRCH to avoid racing zombie reaping), second-popen-on-used-Sandbox rejected, and a regression that capture-mode run still buffers stdout. Refs multikernel#67
c1b20f3 to
fde8d83
Compare
First of a small PR series implementing the streaming-stdio process API
(RFC #67): core here, then the FFI surface, then the Python wrapper.
What
Sandbox::runis capture-only — it buffers stdout/stderr into aRunResultafter the process exits. That cannot drive a process whose protocol is
request/response over stdio while it is alive (an MCP or LSP server, a REPL,
JSON-RPC). This adds
Sandbox::popen, which generalizes the internaldo_createcapture/inherit boolean into a per-streamStdioMode { Inherit, Piped, Null }and returns a liveProcesswhosePipedstream ends the caller owns viatake_stdin/take_stdout/take_stderr.do_createis now a thin wrapper overdo_create_stdio, sorun/spawn/createand their interactive variants keep byte-identicalcapture/inherit behavior.
StdioModeis#[repr(u32)]with pinned discriminants so the FFI/Pythonlayers can bind a stable C enum.
Nullwires the stream to/dev/nulland fails closed: if/dev/nullcannot be opened (or
dup2fails) the fd is closed, never left inheritedfrom the supervisor.
wait()so a childreading stdin still reaches EOF instead of hanging.
popendoes not poll forexecve(a streaming reader blocks until bytesarrive; the poll would spuriously time out on a fast-exiting child).
Processborrows theSandboxrather than owning the process (unlikestd::process::Child); the child is killed and reaped bySandbox::dropor
Process::kill(whole process group), and the type is#[must_use].Folding the legacy
spawn/waitintoProcessis deferred to keep this PRsmall.
Tests
Run on a multi-threaded runtime — a blocking pipe read must not starve the
seccomp-notify supervisor (documented on
popen):cat;echo, stderr-only piped (guards the fd-2 vs fd-1 arm);Nullproven to be/dev/null(the child stats its own fd 1 — fails ifNullfell back to inherit or a pipe);wait;grandchild (reaped via init);
runstill buffers stdout.cargo test -p sandlock-coreis green (lib + integration). Pre-existingtest_cow/test_handlersfailures on my host reproduce onmainand areunrelated.
Refs #67