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 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=...— rejected withTypeError: "print() 'file' argument is not supported". Code that doesprint(..., file=sys.stderr)will not work;sys.stderris an opaque marker (see sys.md).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.
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. - Ordering is exact, 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.
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). - Python
CollectStreamsalso charges a fixed per-entry overhead toward the cap, since many tiny fragments would otherwise OOM the host before payload bytes hit the limit. RustPrintWriter::CollectStreamsmerges consecutive same-stream fragments, so entry count stays small for normalprint(). - 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 as the Python host path and does not merge consecutive same-stream fragments (unlike Rust in-processPrintWriter::CollectStreams). 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.