Skip to content

feat(core): streaming-stdio popen → Process with per-stream StdioMode#118

Merged
congwang-mk merged 1 commit into
multikernel:mainfrom
dzerik:feat/popen-rust-core
Jun 30, 2026
Merged

feat(core): streaming-stdio popen → Process with per-stream StdioMode#118
congwang-mk merged 1 commit into
multikernel:mainfrom
dzerik:feat/popen-rust-core

Conversation

@dzerik

@dzerik dzerik commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

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::run is capture-only — it buffers stdout/stderr into a RunResult
after 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 internal
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 via take_stdin/take_stdout/take_stderr.

  • 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.
  • StdioMode is #[repr(u32)] with pinned discriminants so the FFI/Python
    layers can bind a stable C enum.
  • Null wires the stream to /dev/null and fails closed: if /dev/null
    cannot be opened (or dup2 fails) the fd is closed, never left inherited
    from the supervisor.
  • A piped stdin the caller never takes is closed by wait() so a child
    reading stdin still reaches EOF instead of hanging.
  • popen does not poll for execve (a streaming reader blocks until bytes
    arrive; the poll would spuriously time out on a fast-exiting child).
  • Process borrows the Sandbox rather than owning the process (unlike
    std::process::Child); the child is killed and reaped by Sandbox::drop
    or Process::kill (whole process group), and the type is #[must_use].
    Folding the legacy spawn/wait into Process is deferred to keep this PR
    small.

Tests

Run on a multi-threaded runtime — a blocking pipe read must not starve the
seccomp-notify supervisor (documented on popen):

  • stdin+stdout roundtrip through cat;
  • stdout-only echo, stderr-only piped (guards the fd-2 vs fd-1 arm);
  • Null proven to be /dev/null (the child stats its own fd 1 — fails if
    Null fell back to inherit or a pipe);
  • an untaken piped stdin still reaches EOF at wait;
  • dropping the owner kills the whole process group, reaching a forked
    grandchild (reaped via init);
  • a regression that capture-mode run still buffers stdout.

cargo test -p sandlock-core is green (lib + integration). Pre-existing
test_cow / test_handlers failures on my host reproduce on main and are
unrelated.

Refs #67

@congwang-mk congwang-mk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread crates/sandlock-core/src/sandbox.rs Outdated
// 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) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/sandlock-core/src/sandbox.rs Outdated

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@dzerik dzerik force-pushed the feat/popen-rust-core branch 2 times, most recently from 4f19f72 to c1b20f3 Compare June 30, 2026 07:11
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants