Skip to content

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).

Supported keyword arguments

  • sep=... — separator between arguments. None falls back to a single space. Must be a str or None; otherwise TypeError.
  • end=... — appended after the last argument. None falls back to "\n". Must be a str or None; otherwise TypeError.

Rejected / ignored

  • file=... — only sys.stdout and sys.stderr are accepted; both are opaque markers matched by identity (see sys.md). Anything else raises TypeError: "print() 'file' argument must be sys.stdout or sys.stderr, not {type}", including an object defining write(), which CPython would call. CPython instead raises AttributeError: '{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); a print() cannot make it arrive sooner.
  • Any other keyword raises TypeError: ... unexpected keyword argument.

Behaviour

  • Each positional argument is converted via py_str (equivalent to str(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'. CollectString keeps no labels and interleaves both streams in one buffer; CollectStreams labels each entry.

Chunk boundaries

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 several print() 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.dumps over 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.

Callback failures

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 / CollectStreams caps

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 heap ResourceError::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 ordinary MemoryError, so sandboxed code can catch it with except MemoryError. Unlike a real resource limit it is a catchable RunError::Exc, not UncatchableExc.
    • 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 sees MontyRuntimeError whose inner exception is MemoryError (exc.exception() in Python, err.exception.typeName in JS). The JS check is its own TypeScript implementation (crates/monty-js/ts/print.ts), not the Rust PrintWriter.
  • Pass max_bytes=None to disable the cap (trusted hosts only).
  • Every CollectStreams also charges a fixed 64 bytes per retained entry toward the cap, since a retained entry costs vector and String bookkeeping 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 than max_bytes suggests — a fragment per entry caps out at max_bytes / 65 of them. Rust PrintWriter::CollectStreams merges consecutive same-stream fragments, so an ordinary print() pays the overhead once.
  • Entries follow the chunk boundaries above, not print() calls: several prints usually collect into one entry. Set print_flush_interval=0 to get one entry per completed line.
  • JS (@pydantic/monty): CollectString / CollectStreams accept maxBytes (camelCase), same 10 MiB default and message; CollectStreams charges the same 64-byte per-entry overhead and does not merge consecutive same-stream fragments (unlike Rust in-process PrintWriter::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 / Callback are unchanged; Callback hosts can already self-limit by returning an error.