Summary
Sandbox.run() (the one-shot convenience API) deadlocks and hangs until the timeout when the sandboxed child writes more than one pipe buffer worth of data to a piped stdout/stderr. Observed hang threshold in practice: as little as 32 KiB of stdout. Below the buffer it returns instantly; at/above it the run blocks for the full timeout, then the child is SIGKILLed and a timeout result is returned — the actual output is lost.
Root cause
run() → wait() waits for the child to fully exit before reading the capture pipes:
// crates/sandlock-core/src/sandbox.rs (wait)
let exit_status = match self.rt_mut().pidfd.take() {
Some(pidfd) => wait_child_exit_via_pidfd(pidfd, pid).await, // waits for EXIT
None => wait_child_exit_blocking(pid).await,
};
...
let stdout = self.rt_mut()._stdout_read.take().map(sandbox_read_fd_to_end); // read AFTER exit
let stderr = self.rt_mut()._stderr_read.take().map(sandbox_read_fd_to_end);
The read end of the stdout/stderr pipe is drained only after the child exits. But when the child fills the pipe buffer, its write(2) blocks (the buffer is never drained during execution), so the child never exits. wait_child_exit then blocks until the timeout. Classic pipe-full deadlock — the same failure mode subprocess avoids with communicate() (concurrent drain).
Why run() specifically is a bug (not just the documented contract)
The low-level popen()/Process.wait() API documents the drain-before-wait contract:
a piped stdout/stderr you have not drained can block the child on a full > pipe and hang the wait forever; pass a timeout or drain first.
For popen() the caller owns the read fd and can drain concurrently, so that is a reasonable contract. But run() performs wait + read internally and never hands the read fd to the caller, so there is no seam to drain from. The contract is therefore unsatisfiable for run(), which makes any child producing more than a pipe buffer of output a hang. run() is documented as
"the common one-shot case" with no size caveat, so callers reasonably expect it to handle arbitrary output sizes like subprocess.run(..., capture_output=True).
Reproduction
from sandlock import Sandbox
sb = Sandbox(...) # any policy; stdout defaults to Piped
res = sb.run(["python3", "-c", "print('x' * 65536)"], timeout=25)
# Expected: res.stdout == b"x"*65536 + b"\n", quick return
# Actual: blocks ~25s, child SIGKILLed, timeout result, output lost
print('x' * 32767) returns instantly; print('x' * 32768) hangs.
Suggested fix
Drain the capture pipes concurrently with the wait inside run()/wait(), e.g. spawn Tokio tasks that read_to_end the stdout/stderr fds and join them alongside wait_child_exit (the communicate() pattern). This removes the
deadlock for any output size while keeping the current run() signature.
An alternative/companion would be to expose an fd-passing variant in the public API (the Rust core already has create_with_io) so callers can redirect the child's stdout to a regular file / their own fd and avoid the pipe entirely.
Impact
Any run() caller whose sandboxed workload legitimately produces >~32 KiB of stdout/stderr (large prints, verbose libraries, serialized data) hangs for the full timeout and loses the output. There is currently no way to work around it through the public Python API on the released version other than moving the capture off the pipe from inside the child.
Summary
Sandbox.run()(the one-shot convenience API) deadlocks and hangs until the timeout when the sandboxed child writes more than one pipe buffer worth of data to a pipedstdout/stderr. Observed hang threshold in practice: as little as 32 KiB of stdout. Below the buffer it returns instantly; at/above it the run blocks for the fulltimeout, then the child is SIGKILLed and atimeoutresult is returned — the actual output is lost.Root cause
run()→wait()waits for the child to fully exit before reading the capture pipes:The read end of the stdout/stderr pipe is drained only after the child exits. But when the child fills the pipe buffer, its
write(2)blocks (the buffer is never drained during execution), so the child never exits.wait_child_exitthen blocks until the timeout. Classic pipe-full deadlock — the same failure modesubprocessavoids withcommunicate()(concurrent drain).Why
run()specifically is a bug (not just the documented contract)The low-level
popen()/Process.wait()API documents the drain-before-wait contract:For
popen()the caller owns the read fd and can drain concurrently, so that is a reasonable contract. Butrun()performswait+ read internally and never hands the read fd to the caller, so there is no seam to drain from. The contract is therefore unsatisfiable forrun(), which makes any child producing more than a pipe buffer of output a hang.run()is documented as"the common one-shot case" with no size caveat, so callers reasonably expect it to handle arbitrary output sizes like
subprocess.run(..., capture_output=True).Reproduction
print('x' * 32767)returns instantly;print('x' * 32768)hangs.Suggested fix
Drain the capture pipes concurrently with the wait inside
run()/wait(), e.g. spawn Tokio tasks thatread_to_endthe stdout/stderr fds andjointhem alongsidewait_child_exit(thecommunicate()pattern). This removes thedeadlock for any output size while keeping the current
run()signature.An alternative/companion would be to expose an fd-passing variant in the public API (the Rust core already has
create_with_io) so callers can redirect the child's stdout to a regular file / their own fd and avoid the pipe entirely.Impact
Any
run()caller whose sandboxed workload legitimately produces >~32 KiB of stdout/stderr (large prints, verbose libraries, serialized data) hangs for the full timeout and loses the output. There is currently no way to work around it through the public Python API on the released version other than moving the capture off the pipe from inside the child.