Raxol.REPL.CaptureIO (Raxol v2.8.0)

View Source

A bounded IO server used as the group leader for a REPL evaluation.

StringIO would be the obvious choice, and was, but it accumulates without limit in a process of its own. That placement is what makes it unsuitable here: Raxol.REPL.Evaluator runs user code under a max_heap_size that kills on breach, and output written to a separate process does not count against it. Enum.each(1..1_000_000, fn _ -> IO.puts(big) end) therefore allocates almost nothing in the evaluation process while growing the capture without bound, inside a limit that never trips.

Measuring the capture process from outside does not work either: output arrives as refc binaries, which live in the shared binary heap and are not counted by :erlang.process_info/2's :memory. So the count has to be kept where the writes happen.

This server counts bytes as it accepts them and stops accumulating at :limit, recording that it truncated. Writes keep succeeding afterwards -- an evaluation is not made to crash because it printed too much; it simply stops being recorded, and the caller can say so.

Reads are refused. A REPL evaluation has no stdin, and returning :eof rather than an error would let IO.gets look like it succeeded at reading end-of-input.

Summary

Functions

Returns a specification to start this module under a supervisor.

Stop the capture server.

Return the captured output and whether it was truncated at the limit.

Start a capture server holding at most limit bytes, owned by the caller.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(pid)

@spec close(pid()) :: :ok

Stop the capture server.

contents(pid)

@spec contents(pid()) :: {binary(), boolean()}

Return the captured output and whether it was truncated at the limit.

Unbounded on purpose. The server legitimately blocks for up to :mfa_timeout while an MFA expansion runs (see put_mfa/4), so the implicit 5 000 ms GenServer.call/2 default could expire while the server was doing exactly what it was told to -- turning "the output was cut" into {:timeout, {GenServer, :call, ...}} inside the evaluation, which the evaluator then reports as a crash with the captured output discarded. The caller is the evaluation process, which is itself bounded and brutally killed by Raxol.REPL.Evaluator on its own deadline, so this call cannot outlive that.

start(limit, opts \\ [])

@spec start(pos_integer(), keyword()) :: {:ok, pid()}

Start a capture server holding at most limit bytes, owned by the caller.

:mfa_timeout bounds the MFA form of put_chars (see put_mfa/4): the expansion runs in a throwaway process, and the monitor that watches it only covers an expander that DIES. One that neither answers nor dies wedges this server for good, so the wait is bounded. There is no timeout of its own to invent: the evaluation this capture belongs to already has one, and Raxol.REPL.Evaluator passes it -- an expansion cannot usefully outlive the evaluation that asked for it. It defaults to the same Raxol.Core.Defaults.timeout_ms/0 the evaluator's own default comes from.

Unlinked on purpose: the caller sets it as its own group leader and closes it in an after block, and a link would turn a killed evaluation into a crash report for a process that is simply no longer needed.

Unlinked is not unattached. The caller is MONITORED, and the server stops when it goes: an after block does not run when the evaluation is killed by Process.exit(pid, :kill) on timeout, or by the VM on a max_heap_size breach -- which are the two paths hostile input is meant to take. Without the monitor each of them orphaned one capture server holding up to limit bytes, forever, on the anonymously served playground demo. Stopping on :DOWN is a normal exit, so it still produces no crash report.

That this leak mattered on an anonymous surface is not an endorsement of running the evaluator on one. This cap, like the evaluator's others, is a resource bound on one process and not a security boundary: what stands in front of that surface is Raxol.REPL.Sandbox's :strict AST allowlist, applied by the caller. See Raxol.REPL.Evaluator's "Trust boundary" section.