Skip to content

Commit dd1139c

Browse files
committed
Add a CI-checked uvloop and anyio-on-asyncio compatibility test run
Turn the event-loops.md compatibility claims into a verifiable contract: a new tests/test_event_loops.py parametrized over the loop, an optional event-loops dependency-group, and a dedicated CI job separate from the main test matrix.
2 parents 317414d + e7903b9 commit dd1139c

15 files changed

Lines changed: 909 additions & 28 deletions

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- `Command.on_stdout_line(callback)` / `Command.on_stderr_line(callback)`: a
12+
`Callable[[str], None]` invoked with every decoded line as it is produced —
13+
the way to give the **synchronous** surface (`.output()`/`.run()`) live
14+
progress observation during an otherwise-blocking call, without losing the
15+
full capture. Also fires on the async verbs and on a streamed run
16+
(`start()`/`astart()` + `stdout_lines()`/`output_events()`); at most one
17+
handler per stream (a repeat call replaces the previous one); a raising
18+
callback is reported via `sys.unraisablehook` rather than propagated or
19+
breaking the run. Inert under `stdout("inherit")`/`stdout("null")` (resp.
20+
`stderr(...)`) and, for `on_stdout_line` only, under `output_bytes()` (which
21+
captures stdout raw, bypassing the line pump — stderr still goes through it,
22+
so `on_stderr_line` still fires there). See
23+
`docs/streaming.md#live-per-line-callbacks`.
1124
- A `benchmarks/` suite (`pytest-benchmark`, new `bench` dependency-group)
1225
measuring spawn+capture overhead against `subprocess`/`asyncio.subprocess`,
1326
`ProcessGroup` start/exit, line-streaming throughput, and `output_all`
@@ -34,6 +47,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3447
nested `outcome`, so it now mirrors `Outcome` fully — matching `code` and
3548
`exited_zero`, which were already exposed directly — instead of requiring
3649
`finished.outcome.timed_out` / `finished.outcome.signal`.
50+
- `Command.stdin_file(path)` — feed the child's stdin from a file, streamed in
51+
chunks by the crate rather than read whole into a Python `bytes` object, for
52+
large inputs (a `psql` dump, a `tar` archive, a multi-gigabyte log). Like
53+
most other builder methods (`stdout_tee`/`stderr_tee` are the deliberate
54+
exception), it does not touch the filesystem at build time — the path is
55+
opened lazily at spawn, so a missing/unreadable file surfaces as the generic
56+
`ProcessError` from the run/output verb, not `FileNotFoundError`. Reusable
57+
across retries/re-runs, like `stdin_bytes`/`stdin_text`; the usual "last
58+
stdin method wins" rule applies alongside `stdin_bytes()`/`stdin_text()`/
59+
`keep_stdin_open()`.
60+
- `ProcessResult` and `BytesResult` gain `diagnostic: str | None` (stderr if it
61+
carries text, otherwise stdout, otherwise `None` — the same preference order
62+
as `NonZeroExit`/`Timeout`/`Signalled.diagnostic` on the exceptions) and
63+
`outcome: Outcome` (the same value `RunProfile.outcome` and the checking-verb
64+
exceptions expose). A result held as data (`output()`/`output_bytes()`
65+
without `ensure_success()`) no longer requires re-deriving these by hand.
66+
(An `output_contains_any` convenience was considered alongside these and
67+
rejected: the underlying `processkit` crate has no such method, so it
68+
wouldn't be parity with the crate or the exceptions like `diagnostic`/
69+
`outcome` are — and it's a one-liner callers can already write themselves
70+
via `combined`, e.g. `any(s in result.combined for s in needles)`.)
3771

3872
### Changed
3973
- `CliClient(default_env_fn=...)` now validates that every value in the

‎docs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ handy before you ship: it collects every per-OS caveat in one place.
4646
| [Coming from subprocess](migrating.md) | Side-by-side translation of `subprocess` / `asyncio.subprocess` patterns, the exception mapping, and the whole-tree containment the stdlib can't give |
4747
| [Running commands](commands.md) | The `Command` builder end to end — args, env/sandboxing, stdin, stdout/stderr redirection, encodings, output caps, timeouts, privileges — and every consuming verb (`output`, `run`, `probe`, …) with its error semantics |
4848
| [Process groups](process-groups.md) | Kill-on-drop containment: creating groups, spawning, teardown, whole-tree signals, suspend/resume, member listing, resource limits, stats |
49+
| [Sandboxing untrusted tools](sandboxing.md) | The agent/LLM-tool recipe: locked-down env → bounded output → group resource limits → timeout → teardown, a checklist, and an honest threat model (what this does and does not protect against) |
4950
| [Streaming & interactive I/O](streaming.md) | `astart()` and the live `RunningProcess`: line streaming, interactive stdin, readiness probes (`wait_for_line` / `wait_for_port` / `wait_until`), per-run profiling |
5051
| [Pipelines](pipelines.md) | Shell-free command pipelines — chain with `.pipe()` or the pipe operator: wiring, pipefail attribution, chain timeouts, binary tails |
5152
| [Timeouts & cancellation](timeouts-and-cancellation.md) | How a deadline is *captured* vs when it raises, interrupting a blocked sync call (Ctrl+C), and asyncio cancellation that reaps the whole tree |

‎docs/commands.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,24 @@ Command("sha256sum").stdin_bytes(b"\x00\x01\x02").run()
144144
The payload is written on a background task, so a large input can't deadlock
145145
against the child's own output; the pipe is closed afterward to signal EOF.
146146

147+
For a large input already sitting in a file — a database dump piped into `psql`,
148+
an archive fed to `tar`, a multi-gigabyte log run through a filter — use
149+
`stdin_file(path)` instead of reading the file into Python `bytes` yourself. The
150+
file streams straight to the child's stdin in chunks, so it never has to fit in
151+
Python memory:
152+
153+
```python
154+
Command("psql", ["mydb"]).stdin_file("dump.sql").run()
155+
Command("tar", ["-xf", "-"]).stdin_file("archive.tar").cwd("/tmp/extract").run()
156+
```
157+
158+
`stdin_file()` doesn't touch the filesystem when you call it — the path is
159+
opened lazily when the command actually spawns, so a not-yet-existing path is
160+
not an error there. If the file turns out to be missing or unreadable once the
161+
command runs, that surfaces as a generic `ProcessError` from the run/output
162+
verb (not `FileNotFoundError`), since the child process has, by then, already
163+
spawned successfully.
164+
147165
For a conversational, request/response exchange — write a line, read the answer,
148166
repeat — call `keep_stdin_open()` and drive the process through the streaming API
149167
instead. *Deeper: [Streaming & interactive I/O](streaming.md).*

‎docs/cookbook.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ out = Command("tr", ["a-z", "A-Z"]).stdin_text("hello\n").run() # "HELLO"
9393
Command("sha256sum").stdin_bytes(b"\x00\x01\x02").run()
9494
```
9595

96+
## Feed a large file to stdin without loading it into memory
97+
98+
```python
99+
# Streams straight from disk to the child — no full read into Python bytes,
100+
# so this works just as well for a multi-gigabyte dump/archive/log.
101+
Command("psql", ["mydb"]).stdin_file("dump.sql").run()
102+
Command("tar", ["-xf", "-"]).stdin_file("archive.tar").cwd("/tmp/extract").run()
103+
```
104+
96105
## Set the working directory and environment
97106

98107
```python
@@ -188,6 +197,30 @@ object is a deferred feature) — if you need the lines *in Python*, loop over
188197
`stdout_lines()` instead. See [Streaming](streaming.md#tee-output-to-a-file) for
189198
backpressure, the no-op conditions, and write-error isolation.
190199

200+
## Get live progress from a synchronous run
201+
202+
`stdout_lines()` / `output_events()` need an event loop; `on_stdout_line(callback)`
203+
/ `on_stderr_line(callback)` give the plain, **blocking** `.output()` / `.run()`
204+
call the same live view — `callback` fires on every decoded line as it streams
205+
in, not just once the run finishes:
206+
207+
```python
208+
from processkit import Command
209+
210+
result = (
211+
Command("cargo", ["build", "--release"])
212+
.on_stdout_line(lambda line: print("build:", line))
213+
.output()
214+
)
215+
# capture is untouched — result.stdout still has the whole output.
216+
```
217+
218+
Works the same on the async verbs and on a streamed run — one callback, every
219+
path. A raising callback never derails the run (it goes to
220+
`sys.unraisablehook` instead). See
221+
[Streaming](streaming.md#live-per-line-callbacks) for the no-op conditions and
222+
the one-handler-per-stream rule.
223+
191224
## Tear a standalone process down deterministically
192225

193226
A `RunningProcess` is a context manager. Exiting the block kills the process —

‎docs/sandboxing.md‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Sandboxing untrusted tools
2+
3+
[‹ docs index](README.md)
4+
5+
Agent/LLM frameworks routinely hand a model the ability to run a "tool" it
6+
picked itself, with arguments it generated itself — a shell command, a code
7+
interpreter, a scraper. That tool is, by construction, less trusted than code
8+
you wrote: it should never be able to outlive your process, exhaust the host,
9+
or run forever. This guide is not a new capability — it is a **composition** of
10+
pieces documented individually elsewhere: [Running commands](commands.md)
11+
(environment, output caps), [Process groups](process-groups.md) (whole-tree
12+
resource limits), and [Timeouts & cancellation](timeouts-and-cancellation.md)
13+
(deadlines). It ties them into one recipe, a checklist, and — most
14+
importantly — an honest statement of what this buys you and what it does not.
15+
16+
- [The threat model](#the-threat-model)
17+
- [The recipe](#the-recipe)
18+
- [1. Locked-down environment](#1-locked-down-environment)
19+
- [2. Bounded output](#2-bounded-output)
20+
- [3. Whole-tree resource limits](#3-whole-tree-resource-limits)
21+
- [4. A timeout](#4-a-timeout)
22+
- [5. Teardown](#5-teardown)
23+
- [Checklist: run an untrusted tool safely](#checklist-run-an-untrusted-tool-safely)
24+
- [Full example](#full-example)
25+
26+
## The threat model
27+
28+
Be precise about what a `ProcessGroup` sandbox is — and is not — before
29+
leaning on it for anything that matters.
30+
31+
**processkit protects against:**
32+
33+
- **Process-tree leakage.** Every process the tool spawns, and everything
34+
*that* spawns, dies when the sandbox exits — enforced by the kernel
35+
container (Job Object / cgroup v2 / process group), not a best-effort
36+
signal to one pid. See [the no-orphan guarantee](process-groups.md#tearing-down).
37+
- **Resource exhaustion.** Whole-tree memory, process-count (fork bombs), and
38+
CPU caps are enforced by the kernel (see
39+
[Resource limits](process-groups.md#resource-limits-the-sandbox)); captured
40+
output is bounded so a chatty or malicious child cannot grow the parent's
41+
memory without limit (see
42+
[Bounding captured output](commands.md#bounding-captured-output)).
43+
- **Runaway execution time.** A timeout kills the whole tree at a deadline —
44+
see [Timeouts & cancellation](timeouts-and-cancellation.md).
45+
- **Ambient credential/environment leakage.** `env_clear()` /
46+
`inherit_env([...])` starts the child from nothing rather than handing it
47+
the parent's full environment, secrets included — see
48+
[Environment and sandboxing](commands.md#environment-and-sandboxing). On
49+
POSIX you can additionally drop privileges — see
50+
[Privileges and spawn flags](commands.md#privileges-and-spawn-flags).
51+
52+
**processkit does NOT protect against:**
53+
54+
- **Filesystem access.** The tool can read and write anything the OS
55+
permits its (possibly privilege-dropped) user to touch. processkit does not
56+
chroot, bind-mount, or otherwise virtualize the filesystem.
57+
- **Network access.** No firewalling or network namespace is applied; a
58+
sandboxed tool can still make outbound connections unless you restrict that
59+
another way (a container, a network policy, an egress proxy).
60+
- **Syscall/namespace isolation.** This is not seccomp, and not a
61+
PID/mount/user-namespace container. A Job Object, cgroup, or process group
62+
bounds a *tree*'s lifetime and resource consumption — it does not restrict
63+
*which* syscalls the tree may issue.
64+
- **Vetting the tool's behavior.** processkit does not sanitize, statically
65+
analyze, or judge what the program does — it bounds the blast radius (time,
66+
memory, CPU, process count, orphaned children), not the tool's actions
67+
within those bounds.
68+
69+
**In short:** this is *resource and lifetime* containment, not *security*
70+
isolation. If you need syscall, filesystem, or network isolation, pair
71+
processkit with an actual sandbox — a container, a VM, gVisor, a seccomp
72+
profile, a restricted service account — processkit composes cleanly with any
73+
of those; it just spawns and bounds whatever program you point it at. Do not
74+
let this guide's checklist read as "fully isolated" — it is not.
75+
76+
## The recipe
77+
78+
Compose these five ingredients, in this order, for a locked-down run of an
79+
untrusted tool:
80+
81+
```python
82+
from processkit import Command, ProcessGroup, ResourceLimit, Unsupported
83+
84+
tool = (
85+
Command("untrusted-tool")
86+
.env_clear().inherit_env(["PATH"]) # 1
87+
.output_limit(max_bytes=8 * 1024 * 1024, on_overflow="error") # 2
88+
.timeout(30.0) # 4
89+
.kill_on_parent_death()
90+
)
91+
92+
try:
93+
with ProcessGroup( # 3
94+
max_memory=512 * 1024 * 1024, max_processes=64, cpu_quota=1.0,
95+
) as group:
96+
group.start(tool)
97+
...
98+
# 5. the `with` block's exit reaps the whole tree here — no orphans, ever.
99+
except (ResourceLimit, Unsupported) as exc:
100+
... # no Job Object / cgroup-v2 root here (container, non-root cgroup, macOS)
101+
```
102+
103+
### 1. Locked-down environment
104+
105+
Start the child from nothing and allow-list only what it needs — never hand
106+
an untrusted tool the parent's full environment (which routinely carries
107+
credentials). Full treatment, including the ordering of `env`/`env_remove`
108+
on top: [Environment and sandboxing](commands.md#environment-and-sandboxing).
109+
110+
### 2. Bounded output
111+
112+
Cap `max_bytes` so a chatty or malicious tool cannot grow the parent's memory
113+
without bound (a `max_lines`-only cap does not — one newline-free flood is a
114+
single, unbounded line). `on_overflow="error"` turns hitting the cap into a
115+
failure rather than a silent drop, which is usually what you want for a tool
116+
you don't trust. Full treatment: [Bounding captured output](commands.md#bounding-captured-output).
117+
118+
### 3. Whole-tree resource limits
119+
120+
`max_memory` / `max_processes` / `cpu_quota` on the `ProcessGroup` cap the
121+
*whole tree* — not just the direct child — at the kernel level. This needs a
122+
real container (a Windows Job Object or a Linux cgroup-v2 root); where one
123+
isn't available, the constructor raises `ResourceLimit` rather than handing
124+
back a silently-unbounded group. Full treatment, including the platform
125+
matrix: [Resource limits: the sandbox](process-groups.md#resource-limits-the-sandbox).
126+
127+
### 4. A timeout
128+
129+
Untrusted code should never run unbounded. `.timeout(seconds)` kills the
130+
whole process tree at the deadline; pair it with `.timeout_grace(...)` for a
131+
graceful signal-then-kill if the tool might want to clean up first. Full
132+
treatment: [Timeouts & cancellation](timeouts-and-cancellation.md).
133+
134+
### 5. Teardown
135+
136+
Prefer the context manager (`with ProcessGroup() as group: ...` / `with
137+
Command(...).start() as proc: ...`) over any manual verb — its exit path is
138+
the no-orphan guarantee, on every platform, even if the block raises. Never
139+
lean on `__del__` / `atexit`: neither runs if the parent itself is hard-killed.
140+
Full treatment: [Tearing down](process-groups.md#tearing-down).
141+
142+
## Checklist: run an untrusted tool safely
143+
144+
- [ ] Environment locked down: `env_clear()` + `inherit_env([...])` (or an
145+
explicit allow-list built from `env(...)` calls) — never inherit the
146+
parent's full environment into an untrusted child.
147+
- [ ] Captured output bounded: `output_limit(max_bytes=...)` — a
148+
`max_lines`-only cap does not bound memory.
149+
- [ ] Whole-tree resource limits set on a `ProcessGroup`: `max_memory`,
150+
`max_processes`, `cpu_quota` — with `ResourceLimit` / `Unsupported`
151+
handled where the kernel container isn't available.
152+
- [ ] A timeout set (`Command.timeout(...)`, and `Pipeline.timeout(...)` for a
153+
piped chain) — untrusted code should never run unbounded.
154+
- [ ] Teardown via a context manager, never `__del__` / `atexit`.
155+
- [ ] `kill_on_parent_death()` set on the tool, so it dies even if your own
156+
process crashes before teardown runs.
157+
- [ ] (POSIX only, if running as a privileged user) privileges dropped with
158+
all three of `uid` / `gid` / `groups([...])` set together — `uid` alone
159+
leaves the child holding the parent's supplementary groups.
160+
- [ ] Read [the threat model](#the-threat-model) above — this checklist buys
161+
resource and lifetime containment, not syscall/filesystem/network
162+
isolation.
163+
164+
## Full example
165+
166+
`examples/04_sandbox_resource_limits.py` runs this recipe end to end for an
167+
agent making a couple of tool calls in one sandboxed session: a locked-down,
168+
output-capped, per-call-timeout command; whole-tree memory/process/CPU limits
169+
on the shared group; and teardown on context-manager exit — degrading
170+
gracefully to "contained, but uncapped" where the kernel container isn't
171+
available (a container, a non-root cgroup, macOS).
172+
173+
```bash
174+
python examples/04_sandbox_resource_limits.py
175+
```
176+
177+
---
178+
179+
Next: [Process groups](process-groups.md) · [Running commands](commands.md) ·
180+
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
181+
[Cookbook](cookbook.md) · [Platform support](platforms.md)

‎docs/streaming.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ the tree down deterministically.
1414
- [Lifecycle](#lifecycle)
1515
- [Streaming stdout](#streaming-stdout)
1616
- [Tee output to a file](#tee-output-to-a-file)
17+
- [Live per-line callbacks](#live-per-line-callbacks)
1718
- [Interleaved stdout and stderr](#interleaved-stdout-and-stderr)
1819
- [Interactive stdin](#interactive-stdin)
1920
- [Readiness probes](#readiness-probes)
@@ -174,6 +175,50 @@ Things to know:
174175
— `output()` / `aoutput()`, `run()`, or `start()` + `stdout_lines()` /
175176
`output_events()`.
176177

178+
## Live per-line callbacks
179+
180+
`stdout_lines()` / `output_events()` are async-only — they hand back an async
181+
iterator, so they need an event loop to drive. `on_stdout_line(callback)` /
182+
`on_stderr_line(callback)` give the **synchronous** surface the same live
183+
observation: `callback` runs on every decoded line *as it is produced*, even
184+
while `.output()` / `.run()` is still blocking:
185+
186+
```python
187+
from processkit import Command
188+
189+
def log_line(line: str) -> None:
190+
print("build:", line)
191+
192+
result = Command("cargo", ["build", "--release"]).on_stdout_line(log_line).output()
193+
# "build: ..." printed live, one call per line, while output() was still blocking.
194+
print(result.stdout) # capture is untouched — the callback observes, it doesn't consume.
195+
```
196+
197+
They work identically on the async verbs and on a streamed run (`start()`/
198+
`astart()` + `stdout_lines()` / `output_events()`) — one callback, every path;
199+
adding them does not turn the sync surface async-only, and does not replace the
200+
streaming iterators (which stay the only way to *consume* lines one at a time
201+
from Python — a callback only *observes*).
202+
203+
Things to know:
204+
205+
- **At most one handler per stream.** A repeat call **replaces** the previous
206+
one (builder semantics, like `timeout()`); compose inside a single Python
207+
callable to fan out to more than one observer.
208+
- **A raising callback never derails the run.** An exception raised inside
209+
`callback` is reported via `sys.unraisablehook` (visible on stderr, or
210+
catchable in a test via a custom `sys.unraisablehook`) instead of
211+
propagating — the run and its captured result are unaffected either way.
212+
- **No-op unless that stream's line pump runs**, same family as
213+
`stdout_tee`/`stderr_tee`: `on_stdout_line` is inert under
214+
`stdout("inherit")` / `stdout("null")` and under `output_bytes()` (stdout is
215+
captured raw there, bypassing the line pump). `on_stderr_line` is inert under
216+
`stderr("inherit")` / `stderr("null")` — but **not** under `output_bytes()`:
217+
that verb only bypasses the *stdout* line pump, stderr keeps decoding through
218+
it exactly as under `output()`.
219+
- **Runs independently of `stdout_tee`/`stderr_tee`.** Set both and both fire
220+
per line — a callback and a file tee are not mutually exclusive.
221+
177222
## Interleaved stdout and stderr
178223

179224
When the *interleaving* matters — a `--watch` build that prints progress to

0 commit comments

Comments
 (0)