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
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec close(pid()) :: :ok
Stop the capture server.
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.
@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.