print()
Output always goes to the host via a print callback (vm.print_writer). The
host decides where it ends up; there is no real sys.stdout or sys.stderr
underneath (see sys.md).
sep=...— separator between arguments.Nonefalls back to a single space. Must be astrorNone; otherwiseTypeError.end=...— appended after the last argument.Nonefalls back to"\n". Must be astrorNone; otherwiseTypeError.
file=...— onlysys.stdoutandsys.stderrare accepted; both are opaque markers matched by identity (see sys.md). Anything else raisesTypeError: "print() 'file' argument must be sys.stdout or sys.stderr, not {type}", including an object definingwrite(), which CPython would call. CPython instead raisesAttributeError: '{type}' object has no attribute 'write'for an object without one.flush=...— accepted and ignored. Output is delivered to the host through the subprocess protocol on its own schedule (see “Chunk boundaries” below); aprint()cannot make it arrive sooner.- Any other keyword raises
TypeError: ... unexpected keyword argument.
- Each positional argument is converted via
py_str(equivalent tostr(x)) before being written. - The host callback receives formatted chunks. There is no atomicity guarantee
across multiple
print()calls if the host interleaves with other output. - Stderr output is delivered through the same callback with
stream='stderr'.CollectStringkeeps no labels and interleaves both streams in one buffer;CollectStreamslabels each entry.
Chunk boundaries carry no meaning. The worker holds output in a buffer and sends it when the buffer reaches roughly 8 KiB or its oldest byte has waited out the flush interval (5 ms by default), so:
- One
print()can arrive in several callbacks, and severalprint()calls can arrive in one. A chunk does not correspond to a call, a line, or an argument. - Output that is printed and then followed by silence is released by the
interpreter’s periodic checkpoint, so it does not wait for the next
print(). - That checkpoint sits in the bytecode dispatch loop, so it only fires between
instructions. One long native operation — a large
sort, a regex scan,json.dumpsover a big structure — holds whatever was buffered for as long as it runs, however short the interval. This is the one case where the interval does not bound the wait. The output is late, never dropped: the buffer is still drained before the next host call and at the end of the turn, so a host that needs liveness here can set the interval to 0 and get a callback as each line is written. - Both streams share that one buffer, so output alternating between them is batched like any other; the callback is still called once per run, so a chunk never mixes the two.
- Ordering is exact, between the streams as well, and the buffer is always drained before a host call or the end of a run, so output cannot arrive after the event it preceded.
- Buffered output is lost if the worker dies hard: the pool killing it on
request_timeout, the allocator ending the process at its hard memory ceiling, or a crash. A graceful turn drains first, so this only affects a worker that never finished — but the window is up to 8 KiB or one flush interval of already-complete lines, where line buffering would have sent them. A host that would rather have that output than the batching (to see what a snippet printed before it hung, say) can set the interval to 0.
Hosts can set the interval per session: print_flush_interval (seconds) in
pydantic_monty, printFlushInterval (seconds) in @pydantic/monty, and
ReplConfig::print_flush_interval in Rust. 0 turns the timer off and
restores line buffering, one callback per completed line. The wire carries
whole milliseconds, so a positive interval under 1 ms is sent as 1 ms rather
than rounding down into that sentinel, and a negative or non-finite value is
rejected at checkout.
The wasm worker takes the same setting, but it does not stream: a turn’s frames all reach the host together when the turn ends, whatever the interval. What the setting still decides there is how that output is split — one print callback per frame, and a collector charges its cap per frame — so a snippet that hangs or is killed yields nothing either way.
In the Python and JavaScript pool APIs, a print callback that raises aborts the feed after the current protocol turn,
not at the offending print().
Sandboxed code cannot catch the callback’s exception.
If the turn suspended, both bindings discard the session; later feeds on it fail.
Check out a fresh session before running more code.
CollectString and CollectStreams (Rust PrintWriter variants and the
matching pydantic_monty collectors) accumulate print output in host-side
buffers. That growth is not covered by ResourceLimits.max_memory
(heap-only, and in the pool only on the worker).
- Default cap: 10 MiB (
DEFAULT_MAX_PRINT_COLLECT_BYTES). - Exceeding the cap fails with
memory limit exceeded: {used} bytes > {limit} bytes(same wording as heapResourceError::Memory), but what raises and who can catch it differ by host:- In-process Rust (
PrintWriter::CollectString/CollectStreams): the error is raised inside the VM as an ordinaryMemoryError, so sandboxed code can catch it withexcept MemoryError. Unlike a real resource limit it is a catchableRunError::Exc, notUncatchableExc. - Pool hosts (
pydantic_monty,@pydantic/monty): the cap is enforced in the parent as print events arrive, so it fails the protocol turn rather than raising into the VM. Sandboxed code cannot catch it, and the host seesMontyRuntimeErrorwhose inner exception isMemoryError(exc.exception()in Python,err.exception.typeNamein JS). The JS check is its own TypeScript implementation (crates/monty-js/ts/print.ts), not the RustPrintWriter.
- In-process Rust (
- Pass
max_bytes=Noneto disable the cap (trusted hosts only). - Every
CollectStreamsalso charges a fixed 64 bytes per retained entry toward the cap, since a retained entry costs vector andStringbookkeeping that is not text: without it, output alternating between the streams would hold roughly 64x the host memory the cap accounts for. So the cap bounds entries as well as payload, and a run collects less text thanmax_bytessuggests — a fragment per entry caps out atmax_bytes / 65of them. RustPrintWriter::CollectStreamsmerges consecutive same-stream fragments, so an ordinaryprint()pays the overhead once. - Entries follow the chunk boundaries above, not
print()calls: several prints usually collect into one entry. Setprint_flush_interval=0to get one entry per completed line. - JS (
@pydantic/monty):CollectString/CollectStreamsacceptmaxBytes(camelCase), same 10 MiB default and message;CollectStreamscharges the same 64-byte per-entry overhead and does not merge consecutive same-stream fragments (unlike Rust in-processPrintWriter::CollectStreams), so it charges one per fragment. Output entries are{ stream, text }objects rather than Python tuples. The cap is a logical UTF-8 charge, not a hard V8/host-RSS bound: JS stores strings as UTF-16, so host RSS can exceed the stated cap. Stdout/Disabled/Callbackare unchanged;Callbackhosts can already self-limit by returning an error.