Raxol. Headless
(Raxol v2.8.0)
View Source
Manages headless Raxol application sessions for non-interactive use.
Starts TEA apps in :agent environment (no terminal driver, no IO output)
and provides functions to inspect screen state, send keystrokes, and read
the application model. Designed for use via Tidewave project_eval or
programmatic testing.
Usage
# Start a session from a module
{:ok, "demo"} = Raxol.Headless.start(RaxolDemo, id: "demo")
# Start from an example script (compiles module, skips boot code)
{:ok, "demo"} = Raxol.Headless.start("examples/demo.exs", id: "demo")
# Take a text screenshot
{:ok, text} = Raxol.Headless.screenshot("demo")
# Send a key and see the result
{:ok, text} = Raxol.Headless.send_key_and_screenshot("demo", :tab)
# Inspect the model
{:ok, model} = Raxol.Headless.get_model("demo")
# Stop
:ok = Raxol.Headless.stop("demo")Session ids are binaries of 1-32 characters of [A-Za-z0-9_-], the rule
a widget id follows (Raxol.MCP.TreeWalker.id?/1): an id reaches MCP
clients verbatim, in raxol_list, session-id completion, resource URIs and
tool names. A session's MCP tools are named under its id
("demo.table.sort"). The raxol_start MCP tool narrows it to
^[a-z][a-z0-9]{0,6}$.
Processes
The Raxol.Headless server holds the session registry and serializes
session-id reservation, list/0, and resolving an id to its session.
Registry synchronization during start/2 and all session teardown run in
monitored workers, so an unresponsive MCP Registry cannot block calls for
another session. A session stays reserved while either worker is active.
A synchronizer also monitors the concrete Registry process it registered
with, so replacing that process ends the affected session instead of
leaving it listed without tools.
Every other function makes one lookup (5 s) and then runs in the caller: it
asks the session's Lifecycle for its processes and calls its dispatcher (the
model, and the event for send_key/3, send_resize/3 and send_message/2)
or its rendering engine (screenshot/1, get_buffer/1), each call with a
5 s timeout, or send_key/3's :timeout for the key. A slow app therefore
holds up only the process calling it. Raxol.MCP.Server runs every tool call
in its one process, so over MCP that process is shared by every client.
Those functions answer rather than exit when the session or its processes fail:
{:error, :not_found}- no session has that id, or it ended during the call{:error, {:session_unavailable, class}}- the session is alive but one of its processes did not answer{:error, {:dispatch_failed, class}}-send_key/3orsend_resize/3: the dispatcher exited or did not answer in time{:error, :called_from_own_update}- called from the session's ownupdate/2, whose dispatcher could only wait on itself{:error, :dispatcher_not_available}and{:error, :rendering_engine_not_available}- the Lifecycle reports no live process for that role
class is the exit reason's atom (for example :timeout, :noproc,
:killed), else :unknown; the reason is never returned. Logs either carry
only that class and operation context or a bounded rendering of the reason.
The lookup itself exits if the server is not running; list/0 answers [].
Summary
Types
Why a name could not become a runnable module.
Functions
Returns the raw screen buffer for the session's current frame.
Returns the application model from the session's dispatcher.
Lists all active sessions. Returns [] if the Headless server is not running.
The session's MCP tools the registry refused at its last tool sync because
another tool, holder, already has the same client-side name, as
{:ok, [{name, holder}]} (see
Raxol.MCP.ToolSynchronizer.name_collisions/1; only name collisions).
{:ok, []} for a session with no MCP registry. Answers the same errors as
get_model/1.
Takes a text screenshot of the session's current screen.
Sends a key event to the session and returns once the application's
update/2 has handled it.
Sends a key and returns a screenshot of the frame it produced.
Delivers msg to the application's update/2 and waits for it to fold.
Sends a terminal resize event to the session and returns once the
application's update/2 has handled it.
Returns once the session has caught up with everything sent to it so far:
its dispatcher has taken every queued message through update/2, its
rendering engine has drawn the resulting model, and the session's MCP tool
set includes every tool that frame derives (the set is sticky: tools of
widgets no longer on screen stay registered until the session stops, see
Raxol.MCP.ToolSynchronizer).
Starts a headless session.
Starts the Headless session manager under its starter's lifecycle.
Stops a headless session.
Types
Why a name could not become a runnable module.
:not_a_raxol_application is deliberately distinct from :module_not_found.
One says nothing on the code path answers to that name at all; the other says
the name resolved to real code that does not implement TEA. An operator fixes
those two by doing completely different things, so collapsing them into one
refusal costs them the diagnosis.
Functions
Returns the raw screen buffer for the session's current frame.
Unlike screenshot/1, which returns a text capture, this returns the
Raxol.Core.Buffer itself, preserving per-cell style for downstream
rendering targets such as the LiveView-to-video pipeline.
Returns the application model from the session's dispatcher.
@spec list() :: [String.t()]
Lists all active sessions. Returns [] if the Headless server is not running.
The session's MCP tools the registry refused at its last tool sync because
another tool, holder, already has the same client-side name, as
{:ok, [{name, holder}]} (see
Raxol.MCP.ToolSynchronizer.name_collisions/1; only name collisions).
{:ok, []} for a session with no MCP registry. Answers the same errors as
get_model/1.
Takes a text screenshot of the session's current screen.
Sends a key event to the session and returns once the application's
update/2 has handled it.
The dispatcher answers after the key has been through focus navigation,
event bubbling and update/2, so on :ok the model already reflects the
key: a following get_model/1, screenshot/1 or get_buffer/1 sees it,
with nothing to wait for in between.
Commands that update/2 returns are started, not awaited. A Task, an
interval, or any other command that completes asynchronously can still
land after this returns.
The wait happens in the calling process, not in the Raxol.Headless
server, so a slow update/2 holds up only its own caller: other sessions
answer meanwhile. Over MCP that caller is the tool call's own callback
worker (Raxol.MCP.Server runs each call in one), so a slow session holds
only the calls made to it, while it holds a slot of the server's
:max_in_flight and of the calling connection's share.
From inside an app's update/2, list/0, start/2 and calls on other
sessions work. Calls on the app's own session answer
{:error, :called_from_own_update}: its dispatcher is the process running
update/2, so they could only wait on themselves.
wait: false skips the wait: the call returns once the key is queued at
the dispatcher and says nothing about when update/2 takes it. It is for
callers that only enqueue.
Past 1000 queued messages the dispatcher applies backpressure and the
call waits for it to take the key, as the live input path does.
Returns {:error, {:dispatch_failed, class}} when the dispatcher exits or
does not answer within :timeout. class is the exit reason's atom (for
example :timeout, :noproc, :killed, :shutdown), else :unknown,
never its payload: an exception that killed the dispatcher is logged, not
returned.
A timeout only ends the wait. The key is still in the dispatcher's queue,
so update/2 may yet handle it after this returns.
Options
:ctrl,:alt,:shift- hold the modifier (default:false):timeout- how long to wait forupdate/2, in milliseconds or:infinity(default: 5000):wait-falsereturns once the key is queued (default:true)
@spec send_key_and_screenshot(String.t(), String.t() | atom(), keyword()) :: {:ok, String.t()} | {:error, term()}
Sends a key and returns a screenshot of the frame it produced.
send_key/3 followed by screenshot/1: the screenshot is rendered after
update/2 has handled the key. Takes the same options as send_key/3,
except :wait: the screenshot is of the handled key, so it always waits.
Delivers msg to the application's update/2 and waits for it to fold.
The message takes the same path a subscription tick does, so
send_message(id, :tick) is indistinguishable from the app's own
subscribe_interval firing. Paired with subscriptions: false at start,
this is how a caller advances an app by an exact number of ticks: the
return means the model has folded the message, so a following
get_buffer/1 renders its effect.
@spec send_resize(String.t(), pos_integer(), pos_integer()) :: :ok | {:error, term()}
Sends a terminal resize event to the session and returns once the
application's update/2 has handled it.
The dispatcher forwards the new dimensions to the rendering engine
(resizing its buffer) and to the application's update/2 as a
%Event{type: :resize, data: %{width: w, height: h}}, both before it
answers, so a following get_buffer/1 renders at the new size. Commands
update/2 returns are not awaited. As with send_key/3, the wait runs in
the caller with the same errors; it gives up after
5000 ms, and the resize may still be applied
after that.
Returns once the session has caught up with everything sent to it so far:
its dispatcher has taken every queued message through update/2, its
rendering engine has drawn the resulting model, and the session's MCP tool
set includes every tool that frame derives (the set is sticky: tools of
widgets no longer on screen stay registered until the session stops, see
Raxol.MCP.ToolSynchronizer).
Three barriers in order: a model read at the dispatcher, a synchronous
render at the engine (which casts the frame's view tree to the session's
Raxol.MCP.ToolSynchronizer before the render answers), and a flush at the
synchronizer that first takes a reply from that engine, so the cast has
arrived before the flush applies it (Raxol.MCP.ToolSynchronizer.flush/2).
Commands update/2 started are not awaited. Answers the same errors as
get_model/1 and screenshot/1.
Starts a headless session.
First argument is either a module atom or a file path string. Either way the
module has to be a Raxol application: it must export init/1, update/2 and
view/1. Declaring Raxol.Core.Runtime.Application is not sufficient on its
own, since the attribute can be present with none of the callbacks behind it.
When given a path, the file is compiled and the first module meeting that
contract is used.
When Raxol.MCP.Registry is running, startup also derives the app's initial
MCP tools synchronously. A successful return therefore exposes tools from a
static first view without waiting for another render.
A session any of whose initial tools the registry refuses, because another
tool already holds its client-side name (Raxol.MCP.Registry.omp_name/1:
a client's session "s" holds the names of an Elixir caller's "S"), is
stopped and answers {:error, {:name_collisions, names}}, names being
its own refused tools. A tool a later render derives into a held name is
refused alone and answered by name_collisions/1.
With a registry to register in, a session whose tool synchronizer does not
start is stopped too and answers {:error, {:tool_synchronizer_failed, class}}, class being the exit's class: :timeout when a registry
registration or the app runtime's dispatcher lookup exceeds its 5 s call
timeout; :noproc when a named registry stops after its check or the app
runtime has no dispatcher pid. An app whose runtime fails to initialize
answers {:error, {:app_init_failed, class}}; only the class and app context
are logged. Other sessions and the session manager carry on.
Passing a path executes code
Compiling a defmodule runs its body, so whoever chooses the path string
chooses what runs in this VM, with the VM's privileges. That is arbitrary
code execution, not a file read, and it is a property of this function rather
than of any one caller.
The AST filter that keeps only defmodule nodes is a convenience -- it skips
an example script's boot code -- and never a sandbox. What bounds the compile
is a monitored child on a budget, which bounds the DAMAGE to this process
(a body that raises, throws, exits, or never returns is answered rather than
taking the session manager down with it). It does not bound what the body may
do while it runs.
So the path argument is only ever as safe as the caller that chooses it.
Callers that take one from outside the VM must confine it themselves:
Raxol.Headless.McpTools refuses the argument entirely unless a deployment
sets RAXOL_HEADLESS_PATH_ROOT, and then confines it under that root with
Raxol.Core.Boundary.Path.confine/3. The programmatic callers
(Raxol.Recording.Video, Raxol.MCP.Test) pass a path they already chose.
The module argument does not compile source, but loading a beam can execute
an @on_load callback. The TEA contract is checked after the module loads and
before a session starts; callers must therefore treat the VM's code path as
trusted deployment input.
Options
:id- Session identifier, 1-32 characters of[A-Za-z0-9_-](default: the module's last segment, underscored:"table_demo"forTableDemo; a character outside the rule becomes_, and a name over 32 characters becomes its first 23,_and 8 hex digits of the module's hash). Any other value is{:error, {:invalid_session_id, id}}and starts nothing.:width- Screen width (default: 120):height- Screen height (default: 40):registry- theRaxol.MCP.Registrythe session's tools are registered in, as a name or pid (an atom, a pid,{:global, name}or{:via, module, name}). A registry given here must be a runningRaxol.MCP.Registry; any other value, a name no process has, or a process of another kind is{:error, {:invalid_registry, registry}}, and the process is never messaged. The check is of identity, not responsiveness: it reads which module the process was started with, so aRaxol.MCP.Registrythat is suspended or too busy to reply passes it; the session's first registration then times out and the start answers{:error, {:tool_synchronizer_failed, :timeout}}. (default:Raxol.MCP.Registry, when it is running; without it the session has no tools):server- theRaxol.MCP.Servertold when the session's tool set changes (notifications/tools/list_changed), in the same forms as:registryand checked the same way: a server given here must be a runningRaxol.MCP.Server, else{:error, {:invalid_server, server}}and the process is never messaged. (default:Raxol.MCP.Server):subscriptions-falseboots the app with its declared subscriptions (timers, event sources) unarmed, so the model advances only on messages delivered explicitly viasend_message/2orsend_key/3. This is what makes a recording reproducible: with a timer running, which tick a sampled frame shows is a property of scheduler timing rather than of the caller. (default:true)
@spec start_link(keyword()) :: GenServer.on_start()
Starts the Headless session manager under its starter's lifecycle.
When started outside a supervisor it stops when that starter exits, including
normally. Stop it explicitly with GenServer.stop/2, or through its
supervisor.
Stops a headless session.