|
11 | 11 | import inspect |
12 | 12 | import re |
13 | 13 | from dataclasses import dataclass, field |
14 | | -from enum import Enum |
| 14 | +from enum import Enum, IntEnum |
15 | 15 | from typing import TYPE_CHECKING, Callable, Mapping, Sequence |
16 | 16 |
|
17 | 17 | if TYPE_CHECKING: |
@@ -92,6 +92,20 @@ class BranchAction(Enum): |
92 | 92 | KEEP = "keep" # Leave branch as-is (caller decides) |
93 | 93 |
|
94 | 94 |
|
| 95 | +class StdioMode(IntEnum): |
| 96 | + """Per-stream stdio wiring for :meth:`Sandbox.popen`. |
| 97 | +
|
| 98 | + The values are the stable ABI discriminants shared with the C/Rust core. |
| 99 | + """ |
| 100 | + |
| 101 | + INHERIT = 0 |
| 102 | + """Share the supervisor's own fd (child writes to the same terminal/file).""" |
| 103 | + PIPED = 1 |
| 104 | + """Connect to a pipe; the caller owns the returned end via :class:`Process`.""" |
| 105 | + NULL = 2 |
| 106 | + """Connect the stream to ``/dev/null``.""" |
| 107 | + |
| 108 | + |
95 | 109 | @dataclass(frozen=True) |
96 | 110 | class Change: |
97 | 111 | """A single filesystem change detected by dry-run.""" |
@@ -836,6 +850,85 @@ def wait(self): |
836 | 850 | stderr=stderr, |
837 | 851 | ) |
838 | 852 |
|
| 853 | + def popen( |
| 854 | + self, |
| 855 | + cmd: Sequence[str], |
| 856 | + stdin: StdioMode = StdioMode.INHERIT, |
| 857 | + stdout: StdioMode = StdioMode.INHERIT, |
| 858 | + stderr: StdioMode = StdioMode.INHERIT, |
| 859 | + ) -> "Process": |
| 860 | + """Spawn a confined process with per-stream stdio and return a live |
| 861 | + :class:`Process` whose piped streams the caller reads/writes while it runs. |
| 862 | +
|
| 863 | + The streaming counterpart of :meth:`run`: instead of buffering output |
| 864 | + until the child exits, each stream set to :attr:`StdioMode.PIPED` is |
| 865 | + handed back as a file object on the returned ``Process`` (``.stdin`` / |
| 866 | + ``.stdout`` / ``.stderr``); ``INHERIT``/``NULL`` streams are ``None``. |
| 867 | + Use it to drive a request/response protocol over stdio while the process |
| 868 | + is alive (an MCP or LSP server, a REPL, JSON-RPC). |
| 869 | +
|
| 870 | + All streams default to :attr:`StdioMode.INHERIT` (parity with |
| 871 | + :class:`subprocess.Popen`); pass :attr:`StdioMode.PIPED` for exactly the |
| 872 | + streams you want to drive. |
| 873 | +
|
| 874 | + Deadlock warning (as with :class:`subprocess.Popen`): if you write to a |
| 875 | + piped ``stdin`` you own it -- close it before :meth:`Process.wait` or a |
| 876 | + child that reads to EOF (e.g. ``cat``) never exits and the wait blocks |
| 877 | + forever; likewise drain a piped ``stdout``/``stderr`` before waiting or a |
| 878 | + child that fills the pipe buffer blocks on write. ``Process`` is a |
| 879 | + context manager that closes the streams and reaps the child on exit. |
| 880 | +
|
| 881 | + The ``Result`` returned by :meth:`Process.wait` carries *no* captured |
| 882 | + ``stdout``/``stderr`` (they were streamed to you as fds) — read the output |
| 883 | + off ``proc.stdout``/``proc.stderr``, not off the result. |
| 884 | +
|
| 885 | + Raises: |
| 886 | + RuntimeError: If a process is already running, or the spawn failed. |
| 887 | + ValueError: If a stdio argument is not a valid :class:`StdioMode`. |
| 888 | + """ |
| 889 | + import ctypes |
| 890 | + from ._sdk import _lib, _make_argv |
| 891 | + |
| 892 | + if self._handle is not None: |
| 893 | + raise RuntimeError("sandbox is already running") |
| 894 | + |
| 895 | + # Normalize/validate up front: StdioMode(x) accepts a StdioMode or its int |
| 896 | + # discriminant and raises ValueError on anything else, so a bad mode fails |
| 897 | + # clearly in Python instead of as an opaque null handle across the FFI. |
| 898 | + stdin, stdout, stderr = StdioMode(stdin), StdioMode(stdout), StdioMode(stderr) |
| 899 | + |
| 900 | + native = self._ensure_native() |
| 901 | + argv, argc = _make_argv(list(cmd)) |
| 902 | + resolved_name = self._resolve_name() |
| 903 | + |
| 904 | + fd_in = ctypes.c_int(-1) |
| 905 | + fd_out = ctypes.c_int(-1) |
| 906 | + fd_err = ctypes.c_int(-1) |
| 907 | + self._handle = _lib.sandlock_popen( |
| 908 | + native.ptr, |
| 909 | + _encode(resolved_name), |
| 910 | + argv, |
| 911 | + argc, |
| 912 | + int(stdin), |
| 913 | + int(stdout), |
| 914 | + int(stderr), |
| 915 | + ctypes.byref(fd_in), |
| 916 | + ctypes.byref(fd_out), |
| 917 | + ctypes.byref(fd_err), |
| 918 | + ) |
| 919 | + if not self._handle: |
| 920 | + raise RuntimeError("sandlock_popen failed") |
| 921 | + |
| 922 | + try: |
| 923 | + return Process(self, fd_in.value, fd_out.value, fd_err.value) |
| 924 | + except BaseException: |
| 925 | + # Wrapping the fds failed after the child was spawned. Process.__init__ |
| 926 | + # already closed any fds it opened; free the handle (which reaps the |
| 927 | + # child) and clear it so the Sandbox is left clean. |
| 928 | + _lib.sandlock_handle_free(self._handle) |
| 929 | + self._handle = None |
| 930 | + raise |
| 931 | + |
839 | 932 | def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunResult": |
840 | 933 | """Dry-run: run a command, collect filesystem changes, then discard. |
841 | 934 |
|
@@ -1149,3 +1242,172 @@ def _resolve_syscall(key) -> int: |
1149 | 1242 | f"syscall key must be a name (str) or number (int), " |
1150 | 1243 | f"got {type(key).__name__}" |
1151 | 1244 | ) |
| 1245 | + |
| 1246 | + |
| 1247 | +class Process: |
| 1248 | + """A live confined process with caller-owned stdio, returned by |
| 1249 | + :meth:`Sandbox.popen`. |
| 1250 | +
|
| 1251 | + ``.stdin`` / ``.stdout`` / ``.stderr`` are unbuffered binary file objects |
| 1252 | + for streams opened with :attr:`StdioMode.PIPED`, else ``None``. The process |
| 1253 | + keeps running until :meth:`wait` (or :meth:`kill`) is called, or the |
| 1254 | + ``Process`` context manager exits. Prefer the context manager, which closes |
| 1255 | + the streams and reaps the child even on error:: |
| 1256 | +
|
| 1257 | + with sandbox.popen(["cat"], stdin=StdioMode.PIPED, |
| 1258 | + stdout=StdioMode.PIPED) as proc: |
| 1259 | + proc.stdin.write(b"hi\\n") |
| 1260 | + proc.stdin.close() # EOF so cat exits |
| 1261 | + data = proc.stdout.read() |
| 1262 | + result = proc.wait() |
| 1263 | + """ |
| 1264 | + |
| 1265 | + def __init__(self, sandbox: "Sandbox", stdin_fd: int, stdout_fd: int, stderr_fd: int): |
| 1266 | + import os |
| 1267 | + |
| 1268 | + self._sandbox = sandbox |
| 1269 | + self._result = None |
| 1270 | + self.stdin = self.stdout = self.stderr = None |
| 1271 | + # os.fdopen takes ownership of each fd: closing the stream closes the fd, |
| 1272 | + # so a Process that is closed/exited never leaks the pipe ends. Wrap the |
| 1273 | + # three fds so that if a later fdopen raises, the streams already opened |
| 1274 | + # are closed and the not-yet-wrapped raw fds are closed too — no fd is |
| 1275 | + # leaked on the error path. |
| 1276 | + specs = ((stdin_fd, "wb"), (stdout_fd, "rb"), (stderr_fd, "rb")) |
| 1277 | + opened: list = [] |
| 1278 | + try: |
| 1279 | + for fd, mode in specs: |
| 1280 | + opened.append(os.fdopen(fd, mode, buffering=0) if fd >= 0 else None) |
| 1281 | + except BaseException: |
| 1282 | + for stream in opened: |
| 1283 | + if stream is not None: |
| 1284 | + try: |
| 1285 | + stream.close() |
| 1286 | + except OSError: |
| 1287 | + pass |
| 1288 | + for fd, _mode in specs[len(opened):]: |
| 1289 | + if fd >= 0: |
| 1290 | + try: |
| 1291 | + os.close(fd) |
| 1292 | + except OSError: |
| 1293 | + pass |
| 1294 | + raise |
| 1295 | + self.stdin, self.stdout, self.stderr = opened |
| 1296 | + |
| 1297 | + @property |
| 1298 | + def pid(self) -> int | None: |
| 1299 | + """The child PID while running, else ``None``.""" |
| 1300 | + return self._sandbox.pid |
| 1301 | + |
| 1302 | + def kill(self) -> None: |
| 1303 | + """SIGKILL the child's entire process group. Idempotent: a process that |
| 1304 | + already exited is not an error. The handle stays valid -- call |
| 1305 | + :meth:`wait` afterwards to collect the (non-success) exit status.""" |
| 1306 | + from ._sdk import _lib |
| 1307 | + |
| 1308 | + handle = self._sandbox._handle |
| 1309 | + if handle is None: |
| 1310 | + return |
| 1311 | + _lib.sandlock_handle_kill(handle) |
| 1312 | + |
| 1313 | + def wait(self, timeout: float | None = None) -> "Result": |
| 1314 | + """Wait for the child to exit and return its :class:`Result`. |
| 1315 | +
|
| 1316 | + A still-open piped ``stdin`` is closed first so a child that reads to EOF |
| 1317 | + can exit; piped ``stdout``/``stderr`` stay open for the caller to finish |
| 1318 | + reading. Frees the underlying handle; a second call returns the cached |
| 1319 | + result. |
| 1320 | +
|
| 1321 | + The returned ``Result`` carries no ``stdout``/``stderr`` (those were |
| 1322 | + streamed to you as fds — read them off ``.stdout``/``.stderr``). |
| 1323 | +
|
| 1324 | + Args: |
| 1325 | + timeout: Maximum seconds to wait. On timeout the child is killed and |
| 1326 | + a non-success ``Result`` is returned (same semantics as |
| 1327 | + :meth:`Sandbox.run`'s ``timeout``) — the wait never hangs. ``None`` |
| 1328 | + (default) waits indefinitely. Note: without a ``timeout``, a piped |
| 1329 | + ``stdout``/``stderr`` you have not drained can block the child on a |
| 1330 | + full pipe and hang the wait forever; pass a ``timeout`` or drain first. |
| 1331 | + """ |
| 1332 | + from ._sdk import _lib, Result |
| 1333 | + |
| 1334 | + handle = self._sandbox._handle |
| 1335 | + if handle is None: |
| 1336 | + if self._result is not None: |
| 1337 | + return self._result |
| 1338 | + raise RuntimeError("process is not running") |
| 1339 | + |
| 1340 | + # Deliver EOF to a still-open piped stdin so a reader child can exit and |
| 1341 | + # the wait below does not block forever. |
| 1342 | + if self.stdin is not None and not self.stdin.closed: |
| 1343 | + try: |
| 1344 | + self.stdin.close() |
| 1345 | + except OSError: |
| 1346 | + pass |
| 1347 | + |
| 1348 | + try: |
| 1349 | + timeout_ms = int(timeout * 1000) if timeout else 0 |
| 1350 | + result_p = _lib.sandlock_handle_wait_timeout(handle, timeout_ms) |
| 1351 | + finally: |
| 1352 | + _lib.sandlock_handle_free(handle) |
| 1353 | + self._sandbox._handle = None |
| 1354 | + |
| 1355 | + if not result_p: |
| 1356 | + self._result = Result( |
| 1357 | + success=False, exit_code=-1, error="sandlock_handle_wait failed" |
| 1358 | + ) |
| 1359 | + return self._result |
| 1360 | + |
| 1361 | + exit_code = _lib.sandlock_result_exit_code(result_p) |
| 1362 | + success = _lib.sandlock_result_success(result_p) |
| 1363 | + # stdout/stderr were handed to the caller as fds, so the RunResult holds |
| 1364 | + # none — read them off the streams, not the Result. |
| 1365 | + _lib.sandlock_result_free(result_p) |
| 1366 | + self._result = Result(success=bool(success), exit_code=exit_code) |
| 1367 | + return self._result |
| 1368 | + |
| 1369 | + def __enter__(self) -> "Process": |
| 1370 | + return self |
| 1371 | + |
| 1372 | + def __exit__(self, exc_type, exc_val, exc_tb) -> None: |
| 1373 | + # Terminate and reap a still-running child so no confined process is left |
| 1374 | + # behind, then close every stream we own. |
| 1375 | + if self._sandbox._handle is not None: |
| 1376 | + try: |
| 1377 | + self.kill() |
| 1378 | + except Exception: |
| 1379 | + pass |
| 1380 | + try: |
| 1381 | + self.wait() |
| 1382 | + except Exception: |
| 1383 | + pass |
| 1384 | + for stream in (self.stdin, self.stdout, self.stderr): |
| 1385 | + if stream is not None and not stream.closed: |
| 1386 | + try: |
| 1387 | + stream.close() |
| 1388 | + except OSError: |
| 1389 | + pass |
| 1390 | + |
| 1391 | + def __del__(self): |
| 1392 | + # Safety net for a Process dropped without wait()/context: a live handle |
| 1393 | + # would otherwise leak the confined child + its runtime and wedge the |
| 1394 | + # Sandbox in "already running". Reap it, warning like subprocess.Popen so |
| 1395 | + # the missing wait()/`with` is visible. __del__ must never raise, and may |
| 1396 | + # run during interpreter shutdown when imports/globals are gone — so this |
| 1397 | + # is strictly best-effort inside a broad guard. |
| 1398 | + try: |
| 1399 | + if getattr(self, "_sandbox", None) is None or self._sandbox._handle is None: |
| 1400 | + return |
| 1401 | + import warnings |
| 1402 | + |
| 1403 | + warnings.warn( |
| 1404 | + "Process was not waited on or used as a context manager; " |
| 1405 | + "reaping the confined child. Use `with sandbox.popen(...)` or " |
| 1406 | + "call wait().", |
| 1407 | + ResourceWarning, |
| 1408 | + stacklevel=2, |
| 1409 | + ) |
| 1410 | + self.kill() |
| 1411 | + self.wait() |
| 1412 | + except Exception: |
| 1413 | + pass |
0 commit comments