Skip to content

feat(python): streaming-stdio popen — Sandbox.popen + Process (RFC #67)#123

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

feat(python): streaming-stdio popen — Sandbox.popen + Process (RFC #67)#123
congwang-mk merged 3 commits into
multikernel:mainfrom
dzerik:feat/popen-python

Conversation

@dzerik

@dzerik dzerik commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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

  • Process is 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 via sandlock_handle_wait_timeout; on timeout the child is killed and a non-success Result returned (parity with Sandbox.run), so the wait cannot hang forever.
  • wait() closes a still-open piped stdin first (so a reader child like cat sees EOF), caches its Result (idempotent, no double-free), and frees the handle.
  • __del__ safety net: a Process dropped without wait()/context reaps the child and emits a ResourceWarning (best-effort) instead of leaking the handle.
  • stdio modes are normalized/validated with StdioMode(x) — a bad mode raises a clear ValueError in Python rather than an opaque null handle across the FFI.
  • On a failed fd handoff, Process.__init__ closes any fds it opened and popen() 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

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

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.

Comment thread python/src/sandlock/sandbox.py Outdated
:meth:`wait` afterwards to collect the (non-success) exit status."""
from ._sdk import _lib

handle = self._sandbox._handle

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.

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 group

A 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(

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.

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.

Comment thread python/src/sandlock/sandbox.py Outdated
pass

try:
timeout_ms = int(timeout * 1000) if timeout else 0

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.

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

Comment thread python/src/sandlock/sandbox.py Outdated
handle = self._sandbox._handle
if handle is None:
return
_lib.sandlock_handle_kill(handle)

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() 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.
@dzerik dzerik force-pushed the feat/popen-python branch from 2de200c to 3ac2df6 Compare July 4, 2026 09:36
@dzerik

dzerik commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • Process owns its handle for its lifetime; every method acts on self._handle and never re-resolves through the sandbox. Sandbox keeps only a busy marker — and it's a weakref, so the one-process-per-sandbox rule and pid/is_running/ports still see the live child, while abandoning a Process still triggers its __del__ reap instead of the sandbox pinning it alive. Your three aliasing scenarios (stale kill() hitting the next child, stale wait() reaping it, the rebind-loop __del__) are now structurally impossible; added a regression test for the reuse case.
  • sb.wait()/kill()/pause()/resume() on a popen'd sandbox now fail loudly (they raise, directing you to the Process) instead of freeing/signalling behind the Process's back.
  • timeout=0 / sub-ms: clamp a finite timeout up to 1 ms so it no longer collapses to 0 (= wait forever); None still waits forever. Fixed the same idiom in run() while there.
  • kill() return: rather than checking sandlock_handle_kill's return, I switched Process.kill to signal the process group by a cached pid (like Sandbox.kill and the Go binding). That was needed anyway — driving the kill through the handle races wait() freeing it from another thread (the documented watchdog pattern). Process now guards the handle with a lock + waiting flag; the blocking wait runs without the lock so a kill from another thread interrupts it safely. A genuine kill failure raises (an already-exited group is a no-op). Added a concurrent kill-during-wait test.

Also moved the handle assignment to after the fd wrapping in Process.__init__, so an fdopen failure leaves ownership with popen()'s caller and can't double-free via __del__.

Force-pushed; CI green.

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

Thanks for the quick followup. Some additional reviews below:

Comment thread python/src/sandlock/sandbox.py Outdated
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()

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.

Is it safe to use self.handle without _lock?

Comment thread python/src/sandlock/_sdk.py Outdated

# 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

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.

Defined but not used?

@congwang-mk

Copy link
Copy Markdown
Contributor

One more question: does checkpoint() need to be updated too? I don't see it in the diff.

…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.
@dzerik

dzerik commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — got all three:

  • checkpoint() — right, it needed the same guard. It now _reject_if_popen()s (SIGSTOP + ptrace freeze would fight the streaming Process for the handle), consistent with wait/pause/resume/kill.
  • self._handle without _lock (pid:563) — good catch. Sandbox.pid/ports were doing a raw FFI read of the Process's handle via _live_handle(), racing a concurrent wait() free. Both now delegate to the Process — Process.pid is lock-guarded + cached, and a new lock-guarded Process.ports() reports {} while a wait() holds the handle (mirroring the Go waiting guard).
  • _sdk.py:338 — right, sandlock_handle_kill is dead after Process.kill switched to killpg; removed the binding.

@congwang-mk congwang-mk merged commit 7428860 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