@@ -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.
845921func (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.
872957func (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.
9121004func (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.
9151012func (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) {
9571067func (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