Skip to content

Commit ecd2c17

Browse files
authored
Merge pull request #124 from dzerik/feat/popen-go
feat(go): streaming-stdio Popen — Process with per-stream StdioMode (RFC #67)
2 parents 7428860 + 187bf51 commit ecd2c17

4 files changed

Lines changed: 486 additions & 6 deletions

File tree

go/README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ func (s *Sandbox) Run(ctx context.Context, cmd ...string) (*Result, error)
119119
func (s *Sandbox) RunInteractive(ctx context.Context, cmd ...string) (int, error)
120120
func (s *Sandbox) DryRun(ctx context.Context, cmd ...string) (*DryRunResult, error)
121121
func (s *Sandbox) Spawn(cmd ...string) (*Process, error)
122+
func (s *Sandbox) Popen(stdio Stdio, cmd ...string) (*Process, error)
122123
```
123124

124125
- **Run** captures stdout/stderr and waits. A `ctx` deadline kills the process
@@ -129,6 +130,25 @@ func (s *Sandbox) Spawn(cmd ...string) (*Process, error)
129130
filesystem `Changes` it would have made, and discards them. Requires
130131
`Workdir`.
131132
- **Spawn** starts a process without waiting, returning a `*Process`.
133+
- **Popen** is the streaming counterpart of Spawn: each stream set to
134+
`StdioPiped` is handed back on the `*Process` as an `*os.File`
135+
(`Stdin`/`Stdout`/`Stderr`) you read/write while the child runs. The zero
136+
`Stdio` inherits all three (identical to Spawn). Close a piped `Stdin` before
137+
`Wait` (or let `Wait` close it for you) so a reader child sees EOF; drain a
138+
piped `Stdout`/`Stderr` before `Wait`, or `Kill` from another goroutine to
139+
interrupt a blocked `Wait`. `Wait` returns a `Result` with the exit status
140+
only — a Popen'd process sends piped output to the `Stdout`/`Stderr` fields
141+
(inherited/null streams go to the parent fd or `/dev/null`), so (unlike `Run`)
142+
`Result.Stdout`/`Result.Stderr` are always empty.
143+
144+
```go
145+
p, _ := sb.Popen(sandlock.Stdio{Stdin: sandlock.StdioPiped, Stdout: sandlock.StdioPiped}, "cat")
146+
defer p.Close()
147+
p.Stdin.Write([]byte("hi\n"))
148+
p.Stdin.Close() // EOF so cat exits
149+
out, _ := io.ReadAll(p.Stdout) // "hi\n"
150+
res, _ := p.Wait()
151+
```
132152

133153
### Dynamic policy callbacks
134154

@@ -177,7 +197,12 @@ func (p *Process) Pause() error // SIGSTOP to the process group
177197
func (p *Process) Resume() error // SIGCONT
178198
func (p *Process) Kill() error // SIGKILL
179199
func (p *Process) Ports() (map[int]int, error) // virtual→real, with PortRemap
180-
func (p *Process) Close() error // release the handle (kills if running)
200+
func (p *Process) Close() error // release the handle (kills if running), close piped streams
201+
202+
// Popen only: caller-owned pipe ends, non-nil per stream wired StdioPiped.
203+
p.Stdin // *os.File
204+
p.Stdout // *os.File
205+
p.Stderr // *os.File
181206
```
182207

183208
### Confine the current process

go/sandbox.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,29 @@ type Result struct {
285285
Stderr []byte // captured standard error
286286
}
287287

288+
// StdioMode selects how one of a Popen'd process's standard streams is wired.
289+
// The values are the stable ABI discriminants shared with the C/Rust core.
290+
type StdioMode uint32
291+
292+
const (
293+
// StdioInherit shares the parent's fd (the child writes to the same
294+
// terminal/file). It is the zero value, so an unset stream inherits.
295+
StdioInherit StdioMode = 0
296+
// StdioPiped connects the stream to a pipe; Popen hands the caller the
297+
// owning end as an *os.File on the returned Process.
298+
StdioPiped StdioMode = 1
299+
// StdioNull connects the stream to /dev/null.
300+
StdioNull StdioMode = 2
301+
)
302+
303+
// Stdio selects the wiring of a Popen'd process's three standard streams. The
304+
// zero value wires all three as StdioInherit, matching Spawn.
305+
type Stdio struct {
306+
Stdin StdioMode
307+
Stdout StdioMode
308+
Stderr StdioMode
309+
}
310+
288311
// ChangeKind classifies a filesystem change observed during a dry run.
289312
type ChangeKind byte
290313

go/sandlock_linux.go

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"context"
2323
"encoding/json"
2424
"fmt"
25+
"os"
2526
"runtime"
2627
"strings"
2728
"sync"
@@ -799,6 +800,13 @@ type Process struct {
799800
h *C.sandlock_handle_t
800801
pid int
801802
waiting bool // a Wait owns the handle; other handle ops must defer to it
803+
804+
// Caller-owned stdio for a process started by Popen. Each is non-nil only
805+
// for a stream wired StdioPiped; it owns the pipe fd (closing the file
806+
// closes the fd). Nil for Spawn'd processes and for inherit/null streams.
807+
Stdin *os.File
808+
Stdout *os.File
809+
Stderr *os.File
802810
}
803811

804812
// Spawn forks the sandboxed child, installs the policy, and releases it to
@@ -838,10 +846,78 @@ func (s *Sandbox) Spawn(cmd ...string) (*Process, error) {
838846
return p, nil
839847
}
840848

849+
// fdFile wraps an fd owned by the caller (handed back by sandlock_popen) as an
850+
// *os.File that closes the fd when closed or finalized, or nil for the -1
851+
// sentinel of an inherit/null stream.
852+
func fdFile(fd C.int, name string) *os.File {
853+
if fd < 0 {
854+
return nil
855+
}
856+
return os.NewFile(uintptr(fd), name)
857+
}
858+
859+
// Popen forks the sandboxed child with per-stream stdio and returns a live
860+
// Process without waiting — the streaming counterpart of Spawn. For each stream
861+
// set to StdioPiped, the matching Process field (Stdin/Stdout/Stderr) is an
862+
// *os.File the caller reads/writes while the child runs; inherit/null streams
863+
// leave it nil. The zero Stdio inherits all three (identical to Spawn).
864+
//
865+
// Deadlock warning (as with os/exec pipes): a piped Stdin is yours — close it
866+
// before Wait, or a child that reads to EOF (e.g. cat) never exits and Wait
867+
// blocks. Likewise drain a piped Stdout/Stderr before Wait, or a child that
868+
// fills the pipe buffer blocks on write. As an escape hatch, Kill from another
869+
// goroutine interrupts a blocked Wait; Close reaps the child and closes the
870+
// streams. Wait closes a still-open piped Stdin for you to deliver EOF.
871+
func (s *Sandbox) Popen(stdio Stdio, cmd ...string) (*Process, error) {
872+
if len(cmd) == 0 {
873+
return nil, fmt.Errorf("sandlock: empty command")
874+
}
875+
for _, m := range []StdioMode{stdio.Stdin, stdio.Stdout, stdio.Stderr} {
876+
if m > StdioNull {
877+
return nil, fmt.Errorf("sandlock: invalid StdioMode %d", uint32(m))
878+
}
879+
}
880+
policyPtr, err := s.buildPolicy()
881+
if err != nil {
882+
return nil, err
883+
}
884+
defer C.sandlock_sandbox_free(policyPtr)
885+
886+
argv, err := cArgv(cmd)
887+
if err != nil {
888+
return nil, err
889+
}
890+
defer freeArgv(argv)
891+
ap, ac := argvPtr(argv)
892+
name := s.cName()
893+
defer freeName(name)
894+
895+
fdIn, fdOut, fdErr := C.int(-1), C.int(-1), C.int(-1)
896+
h := C.sandlock_popen(
897+
policyPtr, name, ap, ac,
898+
C.uint32_t(stdio.Stdin), C.uint32_t(stdio.Stdout), C.uint32_t(stdio.Stderr),
899+
&fdIn, &fdOut, &fdErr,
900+
)
901+
if h == nil {
902+
return nil, fmt.Errorf("sandlock: popen failed")
903+
}
904+
p := &Process{
905+
h: h,
906+
pid: int(C.sandlock_handle_pid(h)),
907+
Stdin: fdFile(fdIn, "sandlock-stdin"),
908+
Stdout: fdFile(fdOut, "sandlock-stdout"),
909+
Stderr: fdFile(fdErr, "sandlock-stderr"),
910+
}
911+
runtime.SetFinalizer(p, (*Process).finalize)
912+
return p, nil
913+
}
914+
841915
// finalize is the SetFinalizer cleanup for a Process abandoned without
842916
// Wait/Close. It can only run once the Process is unreachable, which implies
843917
// no Wait is in flight (a blocked Wait keeps the Process reachable), so the
844-
// handle is not concurrently borrowed and is safe to free here.
918+
// handle is not concurrently borrowed and is safe to free here. The piped
919+
// streams are intentionally left alone: once the Process is unreachable so are
920+
// they, and each *os.File's own finalizer closes its fd — no fd leaks.
845921
func (p *Process) finalize() {
846922
p.mu.Lock()
847923
defer p.mu.Unlock()
@@ -862,23 +938,39 @@ func (p *Process) Pid() int {
862938
return p.pid
863939
}
864940

865-
// Wait blocks until the process exits, returns its captured Result, and
866-
// releases the handle. After Wait the Process is no longer running.
941+
// Wait blocks until the process exits, returns its Result, and releases the
942+
// handle. After Wait the Process is no longer running.
943+
//
944+
// The Result carries the exit status only. Unlike Run, a Popen'd process sends
945+
// any StdioPiped output to the caller through the Stdout/Stderr fields (and
946+
// StdioInherit/StdioNull output to the inherited fd or /dev/null), so it is
947+
// never captured into Result.Stdout/Result.Stderr — read piped bytes off
948+
// p.Stdout/p.Stderr, not off the Result.
867949
//
868950
// The blocking native wait runs without holding the mutex so that Kill (and
869951
// Pause/Resume), which signal the process group by PID, can run concurrently
870952
// and interrupt it. The waiting flag reserves exclusive use of the handle for
871953
// the duration, so no other handle operation aliases it.
954+
//
955+
// Wait closes a still-open piped Stdin to deliver EOF, so do not write to Stdin
956+
// from another goroutine while Wait runs — the write would race a closing fd.
872957
func (p *Process) Wait() (*Result, error) {
873958
p.mu.Lock()
874959
if p.h == nil || p.waiting {
875960
p.mu.Unlock()
876961
return nil, ErrNotRunning
877962
}
878963
h := p.h
964+
stdin := p.Stdin
879965
p.waiting = true
880966
p.mu.Unlock()
881967

968+
// Deliver EOF to a still-open piped stdin so a reader child (e.g. cat) can
969+
// exit; otherwise the native wait blocks forever. Harmless if already closed.
970+
if stdin != nil {
971+
_ = stdin.Close()
972+
}
973+
882974
r := C.sandlock_handle_wait(h)
883975

884976
p.mu.Lock()
@@ -911,11 +1003,29 @@ func (p *Process) Pause() error { return p.signal(syscall.SIGSTOP) }
9111003
// Resume sends SIGCONT to the sandbox process group.
9121004
func (p *Process) Resume() error { return p.signal(syscall.SIGCONT) }
9131005

914-
// Kill sends SIGKILL to the sandbox process group.
1006+
// Kill sends SIGKILL to the sandbox process group. It is idempotent: a process
1007+
// that already exited (ESRCH) or was already reaped by Wait (ErrNotRunning) is
1008+
// not an error — killing it is a no-op success, matching the FFI
1009+
// (sandlock_handle_kill returns 0) and the Python binding (silent no-op after
1010+
// wait()). This keeps the "Kill from another goroutine" escape hatch race-free:
1011+
// a Kill that fires just as Wait reaps the child still returns nil.
9151012
func (p *Process) Kill() error {
9161013
err := p.signal(syscall.SIGKILL)
9171014
if err == syscall.ESRCH {
918-
return nil
1015+
return nil // child already exited (still unreaped) — no-op
1016+
}
1017+
if err == ErrNotRunning {
1018+
// signal() returns ErrNotRunning for two causes: the handle was already
1019+
// reaped by Wait (h == nil) — an idempotent no-op — or a live handle with
1020+
// no pid. Only the reaped case is a success; a missing pid is a genuine
1021+
// error we must surface (and must never turn into killpg(-0), which would
1022+
// signal our own process group).
1023+
p.mu.Lock()
1024+
reaped := p.h == nil
1025+
p.mu.Unlock()
1026+
if reaped {
1027+
return nil
1028+
}
9191029
}
9201030
return err
9211031
}
@@ -957,6 +1067,13 @@ func (p *Process) Ports() (map[int]int, error) {
9571067
func (p *Process) Close() error {
9581068
p.mu.Lock()
9591069
defer p.mu.Unlock()
1070+
// Release caller-owned pipe fds regardless of handle state: a Close after
1071+
// Wait still needs to close the piped stdout/stderr the caller drained.
1072+
for _, f := range []*os.File{p.Stdin, p.Stdout, p.Stderr} {
1073+
if f != nil {
1074+
_ = f.Close()
1075+
}
1076+
}
9601077
if p.h == nil {
9611078
return nil
9621079
}

0 commit comments

Comments
 (0)