Skip to content

feat(go): streaming-stdio Popen — Process with per-stream StdioMode (RFC #67)#124

Merged
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/popen-go
Jul 5, 2026
Merged

feat(go): streaming-stdio Popen — Process with per-stream StdioMode (RFC #67)#124
congwang-mk merged 2 commits into
multikernel:mainfrom
dzerik:feat/popen-go

Conversation

@dzerik

@dzerik dzerik commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Go ergonomic layer over the C ABI sandlock_popen (merged in #120), mirroring the FFI and Python bindings. Part of the RFC #67 popen series (core #118, FFI #120).

Sandbox.Popen(stdio Stdio, cmd...) (*Process, error) forks the confined child with per-stream stdio — the streaming counterpart of Spawn. Each stream set to StdioPiped is handed back on the *Process as an *os.File (Stdin/Stdout/Stderr); inherit/null streams leave it nil. The zero Stdio inherits all three (identical to Spawn).

  • StdioMode (Inherit=0/Piped=1/Null=2) shares the stable ABI discriminants; an out-of-range mode is rejected before the child is spawned.
  • os.NewFile takes ownership of each piped fd, so closing the file closes the fd — no leaks (via Close, the SetFinalizer reap, or the *os.File's own finalizer). On a null handle the FFI leaves the out-fds at -1, so no stream is wrapped on the error path.
  • Wait closes a still-open piped Stdin first to deliver EOF (so a reader child like cat exits), then waits and frees the handle. Close closes all piped streams regardless of handle state and reaps the child.
  • No wait-timeout: unlike the Python binding, Kill (process-group signal by PID) already interrupts a blocked Wait from another goroutine, so the undrained-pipe hang has a built-in escape hatch.

Tests mirror the FFI/Python popen suites: stdout streaming, stdin/stdout roundtrip through cat, wait-closes-unclosed-stdin (watchdog), both stdout+stderr piped, null yields no stream, invalid mode rejected, Kill interrupts a blocked Wait, Close closes streams + reaps, finalizer reaps an abandoned process, Close-after-Wait. README documents Popen/Stdio. -race clean.

Refs #67

…RFC multikernel#67)

Go ergonomic layer over the C ABI sandlock_popen, mirroring the FFI and Python
bindings. Sandbox.Popen(stdio, cmd...) forks the confined child with per-stream
stdio and returns a live *Process — the streaming counterpart of Spawn. Each
stream set to StdioPiped is handed back on the Process as an *os.File
(Stdin/Stdout/Stderr) the caller reads/writes while the child runs; inherit/null
streams leave it nil. The zero Stdio inherits all three (identical to Spawn).

  - StdioMode (Inherit=0/Piped=1/Null=2) shares the stable ABI discriminants;
    an out-of-range mode is rejected before the child is spawned.
  - os.NewFile takes ownership of each piped fd, so closing the file closes the
    fd — the fds never leak (via Close, the SetFinalizer reap, or the *os.File's
    own finalizer). On a null handle the FFI leaves the out-fds at -1, so no
    stream is wrapped on the error path.
  - Wait closes a still-open piped Stdin first to deliver EOF (so a reader child
    like cat exits), then waits and frees the handle. Close closes all piped
    streams regardless of handle state and reaps the child.
  - No wait-timeout is added: unlike the Python binding, Kill (process-group
    signal by PID) already interrupts a blocked Wait from another goroutine, so
    the undrained-pipe hang has a built-in escape hatch.

Tests mirror the FFI/Python popen suites: stdout streaming, stdin/stdout
roundtrip through cat, wait-closes-unclosed-stdin (watchdog), both stdout+stderr
piped, null yields no stream, invalid mode rejected, Kill interrupts a blocked
Wait, Close closes streams + reaps. README documents Popen/Stdio.

Refs multikernel#67
Comment thread go/sandlock_linux.go
// the duration, so no other handle operation aliases it.
//
// Wait closes a still-open piped Stdin to deliver EOF, so do not write to Stdin
// from another goroutine while Wait runs — the write would race a closing fd.

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.

Doc gap: nothing says the Result of a Popen'd process carries no captured output. The Python binding documents this in both popen and Process.wait; here neither Wait's doc nor the README's Popen bullet mentions it, and the README still describes Wait as returning a "captured Result". A user coming from Run will read res.Stdout and get an empty slice with no hint why (the bytes went to p.Stdout, possibly already drained). One sentence here and one in the README fixes it.

Comment thread go/sandlock_linux.go
// before Wait, or a child that reads to EOF (e.g. cat) never exits and Wait
// blocks. Likewise drain a piped Stdout/Stderr before Wait, or a child that
// fills the pipe buffer blocks on write. As an escape hatch, Kill from another
// goroutine interrupts a blocked Wait; Close reaps the child and closes the

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.

Kill-after-exit idempotency now differs across the three bindings. FFI sandlock_handle_kill returns 0 for an already-exited process, Python Process.kill() is a silent no-op after wait(), but Go's Kill() returns ErrNotRunning once the handle is freed (signal checks p.h == nil before the ESRCH mapping can apply). Since this doc actively promotes "Kill from another goroutine" as the escape hatch, the watchdog-races-normal-exit case (Wait returns just as the watchdog fires Kill) yields an error in Go where the other two bindings say success. Pre-existing signal semantics, but worth aligning while the contract is being written down: Kill on an already-reaped Process should arguably be nil too.

Comment thread go/sandbox.go

// Stdio selects the wiring of a Popen'd process's three standard streams. The
// zero value wires all three as StdioInherit, matching Spawn.
type Stdio struct {

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 gap: the zero-Stdio-equals-Spawn contract is stated in three places (here, the Popen doc, the README) but never exercised. TestPopenNullStdoutNoStream is the closest test. A cheap Popen(sandlock.Stdio{}, ...) test asserting all three stream fields are nil and the child runs to success would pin the ABI default (StdioInherit == 0) that the zero-value contract relies on.

…zero Stdio

- Kill: a process already reaped by Wait now returns nil (like ESRCH),
  not ErrNotRunning — matching the FFI (sandlock_handle_kill returns 0)
  and the Python binding (no-op after wait()). Keeps the "Kill from
  another goroutine" escape hatch race-free when a Kill fires just as
  Wait reaps the child. Only the reaped case (h == nil) collapses to nil;
  a live handle with no pid stays an error, so a genuine no-op kill is
  never reported as success (and killpg(-0) is never reachable).

- Wait doc + README: state that a Popen'd process's Result carries the
  exit status only; piped output was streamed to Stdout/Stderr and
  inherited/null output went to the parent fd or /dev/null, so (unlike
  Run) Result.Stdout/Result.Stderr are always empty.

- Tests: zero-Stdio-equals-Spawn (all three streams inherited, no caller
  stream, child succeeds) pins the StdioInherit==0 zero-value contract;
  Kill-after-Wait-is-nil covers the idempotency alignment above.
@dzerik

dzerik commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — addressed all three:

  • Kill idempotency: Kill() on an already-reaped Process now returns nil, like ESRCH. But only the reaped case (h == nil) collapses to nil — I kept pid <= 0 on a live handle as an error, so a genuine no-op kill isn't reported as success (and killpg(-0) stays unreachable). So the "Kill from another goroutine" escape hatch is race-free: a Kill firing just as Wait reaps the child returns nil, while a real failure still surfaces. Added a Kill-after-Wait test.
  • Doc: Wait's doc and the README now state the Result carries the exit status only — piped output went to Stdout/Stderr and inherited/null output to the parent fd or /dev/null, so (unlike Run) Result.Stdout/Result.Stderr are always empty. Qualified it so it's accurate for the inherit/null cases too, not just piped.
  • Zero-Stdio test: added TestPopenZeroStdioInheritsAllLikeSpawnPopen(Stdio{}, ...) asserts all three stream fields are nil and the child runs to success, pinning the StdioInherit == 0 zero-value contract.

Force-pushed; CI green.

@congwang-mk congwang-mk merged commit ecd2c17 into multikernel:main Jul 5, 2026
13 checks passed
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