feat(python): streaming-stdio popen — Sandbox.popen + Process (RFC #67)#123
Conversation
…ltikernel#67) Python ergonomic layer over the C ABI sandlock_popen / sandlock_handle_kill: Sandbox.popen(cmd, stdin/stdout/stderr=StdioMode) returns a live Process whose PIPED streams are unbuffered binary file objects the caller reads/writes while the child runs; INHERIT/NULL streams are None. Streams default to INHERIT (parity with subprocess.Popen) — pass StdioMode.PIPED for the streams you drive. Process is a context manager that kills+reaps the child and closes the streams on exit (kill before wait, so an undrained pipe cannot hang teardown). wait() closes a still-open piped stdin first so a reader child (cat) sees EOF, then caches its Result (idempotent, no double-free). kill() SIGKILLs the group idempotently. On a failed fd handoff, Process.__init__ closes any fds it opened and popen() frees the handle — nothing leaks on the error path. - wait(timeout): bounds the wait via sandlock_handle_wait_timeout; on timeout the child is killed and a non-success Result is returned (parity with Sandbox.run's timeout), so an undrained-pipe wait can never hang forever. - __del__ safety net: a Process dropped without wait()/context reaps the child and emits a ResourceWarning (best-effort, never raises) instead of silently leaking the handle and wedging the Sandbox. - stdio modes are normalized/validated with StdioMode(x): a bad mode raises a clear ValueError in Python instead of an opaque null handle across the FFI; the int discriminant is still accepted. - Result from wait() carries no stdout/stderr (streamed to the fds) — documented on popen() and wait(). StdioMode/Process are exported from the package. Tests mirror the FFI suite and add destructively-verified coverage for the load-bearing invariants: stdout streaming, stdin/stdout roundtrip through cat, wait()-delivers-EOF to an unclosed piped stdin (watchdog), wait(timeout) kills-and-returns (watchdog), both stdout and stderr piped at once, stderr piped, Null yields no stream, kill lifecycle, context-manager reap + stream close + sandbox reuse, __del__ reaps an abandoned process, idempotent cached wait, int-discriminant accepted, unknown mode rejected, second-process guard. Refs multikernel#67
congwang-mk
left a comment
There was a problem hiding this comment.
Review focused on high-level API design. The API shape is right: popen returning a Process, StdioMode as the ABI-stable IntEnum, INHERIT defaults matching subprocess.Popen, validation before the FFI boundary, and unusually honest deadlock documentation. Tests are genuinely destructive (watchdogs, GC reaping). Four inline comments below; the first one (handle ownership) is the load-bearing design decision and the only one I would hold the merge on.
| :meth:`wait` afterwards to collect the (non-success) exit status.""" | ||
| from ._sdk import _lib | ||
|
|
||
| handle = self._sandbox._handle |
There was a problem hiding this comment.
Design: Process borrows Sandbox._handle instead of owning its handle. Every Process method resolves the handle through self._sandbox._handle at call time (here, plus wait at 1334, __exit__ at 1375, __del__ at 1399), so a Process means "whatever child the sandbox is running right now", not "the child I spawned". Once the sandbox is reused (which this PR supports and tests), a stale Process aliases the new child:
p1 = sb.popen(["a"]); p1.wait() # frees handle, sb._handle = None
p2 = sb.popen(["b"]) # sb._handle = b's handle
p1.kill() # SIGKILLs b's process groupA second p1.wait() is worse: the cached-result shortcut only fires when sb._handle is None, so with p2 live it waits on and frees p2's handle and overwrites p1._result with b's result; p2.wait() then raises "process is not running". And the plain rebind loop p = sb.popen(a); p.wait(); p = sb.popen(b) installs b's handle before the old p is decref'd, so old p.__del__ (its guard checks only sb._handle is None, not self._result) warns, SIGKILLs, and reaps the brand-new child.
Suggested fix: move the handle onto Process (owned for its lifetime), and keep on Sandbox only a busy marker for the one-process-per-sandbox rule plus pid/is_running. That makes stale-Process aliasing structurally impossible instead of guarded. The minimal patch (early return in kill/wait/__del__ when self._result is not None) closes these scenarios but leaves the aliasing latent for the next method someone adds.
| stderr=stderr, | ||
| ) | ||
|
|
||
| def popen( |
There was a problem hiding this comment.
API overlap: Sandbox.wait()/kill()/pause()/resume() also drive a popen handle, with conflicting semantics. Because the popen handle lives in the same _handle slot the create/start/spawn lifecycle uses, sb.wait() (line 820) "works" on a popen'd sandbox, but: it does not close a piped stdin (a cat child hangs it forever), it returns empty captured stdout/stderr, and it frees the handle behind the Process's back, so a later proc.wait() raises RuntimeError for a child that exited normally. Nothing steers users away from this trap. This falls out of the handle-ownership comment at line 1308: if Process owns the handle, sb.wait() on a popen'd sandbox fails loudly (or delegates) instead of half-working.
| pass | ||
|
|
||
| try: | ||
| timeout_ms = int(timeout * 1000) if timeout else 0 |
There was a problem hiding this comment.
timeout=0 and sub-millisecond timeouts wait indefinitely. int(timeout * 1000) if timeout else 0 maps timeout=0 and e.g. timeout=0.0005 to 0, which the FFI treats as wait-forever, the opposite of the caller's intent. Should be if timeout is not None, clamping sub-ms values up to 1. Copied from run() (line 561), so it is parity with an existing bug; worth fixing the idiom here rather than duplicating it, with a follow-up for run().
| handle = self._sandbox._handle | ||
| if handle is None: | ||
| return | ||
| _lib.sandlock_handle_kill(handle) |
There was a problem hiding this comment.
kill() discards the FFI error return. sandlock_handle_kill distinguishes already-exited (0, fine per its contract) from genuine failure (-1), but the return value is dropped here, so a real kill failure is indistinguishable from success and the caller proceeds to a wait() that may block forever. Raise (or at least warn) on -1.
…ethod aliasing
Handle ownership (load-bearing): a popen() Process now owns its handle for
its lifetime instead of resolving sandbox._handle at call time. Previously
every Process method (kill/wait/__del__/pid) read the sandbox's current
handle, so a stale Process aliased whatever child the sandbox ran next:
`p1.kill()` after the sandbox was reused SIGKILLed p2, and a stale
`p1.wait()` reaped p2 and left it "not running".
- Process takes and stores the handle (and caches its pid); all methods
act on self._handle and set it to None once freed. The handle/pid are
taken only AFTER the fds are wrapped, so an fdopen failure leaves
ownership with popen()'s caller — no double free via __del__.
- Sandbox keeps only a weak busy marker (self._process, a weakref) so
the one-process-per-sandbox rule and pid/is_running/ports still see
the live child, while abandoning the Process still triggers its
__del__ reap rather than the sandbox pinning it alive.
- Sandbox.wait/pause/resume/kill fail loudly when a popen() Process owns
the handle (manage it through the Process).
Concurrency: Process guards its handle with a lock and a `waiting` flag.
wait() runs the blocking native wait without the lock; kill() signals the
process group by cached pid (like the sandbox and the Go binding), not
through the handle, so the documented "kill from another thread while
wait() blocks" pattern is race-free — no kill on a handle wait() is
freeing. kill() raises OSError on a genuine failure (ProcessLookupError,
i.e. already-exited, is a no-op) and is a silent no-op once reaped.
Also: Process.wait / Sandbox.run clamp a finite timeout up to 1ms so
`timeout=0` and sub-millisecond values don't collapse to 0 (= wait
forever); None still waits forever.
Tests: stale-Process-does-not-alias-reused-sandbox; sandbox lifecycle
methods reject a popen Process; wait(timeout=0) returns instead of
hanging; kill from another thread interrupts a blocked wait.
|
Thanks — the handle-ownership comment was the load-bearing one, and you're right that the minimal guard would have left the aliasing latent. Made the structural change instead:
Also moved the handle assignment to after the fd wrapping in Force-pushed; CI green. |
congwang-mk
left a comment
There was a problem hiding this comment.
Thanks for the quick followup. Some additional reviews below:
| if self._handle is None: | ||
| """Child PID while running, None otherwise. Reflects a running child from | ||
| either the sandbox lifecycle or a live :meth:`popen` :class:`Process`.""" | ||
| handle = self._live_handle() |
There was a problem hiding this comment.
Is it safe to use self.handle without _lock?
|
|
||
| # SIGKILL the handle's process group without freeing it (so wait can still | ||
| # collect the exit status). 0 on success, -1 on error. | ||
| _lib.sandlock_handle_kill.restype = ctypes.c_int |
|
One more question: does |
…p dead binding
- checkpoint(): a popen() Process owns the handle, so checkpoint (SIGSTOP +
ptrace freeze) now rejects with _reject_if_popen() instead of reaching
past the Process for a handle that is None on the sandbox — consistent
with wait/pause/resume/kill.
- Sandbox.pid / Sandbox.ports: delegate to the Process (Process.pid is
lock-guarded + cached; new Process.ports() reads the handle under the
lock and reports {} while a wait() holds it). Previously both did a raw
FFI read of the Process's handle via _live_handle(), racing a concurrent
wait() free. Extracted _read_port_mappings() shared by both.
- Removed the now-unused sandlock_handle_kill ctypes binding: Process.kill
signals the group by pid (killpg), so the FFI kill has no caller.
Tests: checkpoint() rejects a popen Process; Sandbox.pid/ports delegate to
the live Process and return None/{} after it is reaped.
|
Thanks — got all three:
|
Python ergonomic layer over the C ABI
sandlock_popen/sandlock_handle_kill(merged in #120). Third in the RFC #67 popen series: core (#118) → FFI (#120) → Python (this PR).Sandbox.popen(cmd, stdin/stdout/stderr=StdioMode)returns a liveProcesswhosePIPEDstreams are unbuffered binary file objects the caller reads/writes while the child runs;INHERIT/NULLstreams areNone. Streams default toINHERIT(parity withsubprocess.Popen).Processis a context manager:__exit__kills+reaps the child (kill before wait, so an undrained pipe cannot hang teardown) and closes the streams.wait(timeout=None)bounds the wait viasandlock_handle_wait_timeout; on timeout the child is killed and a non-successResultreturned (parity withSandbox.run), so the wait cannot hang forever.wait()closes a still-open piped stdin first (so a reader child likecatsees EOF), caches itsResult(idempotent, no double-free), and frees the handle.__del__safety net: aProcessdropped withoutwait()/context reaps the child and emits aResourceWarning(best-effort) instead of leaking the handle.StdioMode(x)— a bad mode raises a clearValueErrorin Python rather than an opaque null handle across the FFI.Process.__init__closes any fds it opened andpopen()frees the handle — nothing leaks on the error path.Tests mirror the FFI suite and add destructively-verified coverage: stdout streaming, stdin/stdout roundtrip through
cat, wait()-delivers-EOF-to-unclosed-stdin (watchdog), wait(timeout) kills-and-returns (watchdog), both stdout+stderr piped, Null yields no stream, kill lifecycle, context-manager reap + sandbox reuse, del reaps an abandoned process, idempotent cached wait, unknown-mode rejection, second-process guard.Refs #67