Skip to content

Sandbox.run() deadlocks when the child writes more than the pipe buffer to stdout/stderr #149

Description

@dzerik

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions