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/3 or send_resize/3: the dispatcher exited or did not answer in time
  • {:error, :called_from_own_update} - called from the session's own update/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

module_refusal()

@type module_refusal() ::
  {:module_not_found, module()} | {:not_a_raxol_application, module()}

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

get_buffer(id)

@spec get_buffer(String.t()) :: {:ok, map()} | {:error, term()}

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.

get_model(id)

@spec get_model(String.t()) :: {:ok, term()} | {:error, term()}

Returns the application model from the session's dispatcher.

list()

@spec list() :: [String.t()]

Lists all active sessions. Returns [] if the Headless server is not running.

name_collisions(id)

@spec name_collisions(String.t()) ::
  {:ok, [{String.t(), String.t()}]} | {:error, term()}

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.

screenshot(id)

@spec screenshot(String.t()) :: {:ok, String.t()} | {:error, term()}

Takes a text screenshot of the session's current screen.

send_key(id, key, opts \\ [])

@spec send_key(String.t(), String.t() | atom(), keyword()) :: :ok | {:error, term()}

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 for update/2, in milliseconds or :infinity (default: 5000)
  • :wait - false returns once the key is queued (default: true)

send_key_and_screenshot(id, key, opts \\ [])

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

send_message(id, msg)

@spec send_message(String.t(), term()) :: :ok | {:error, term()}

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.

send_resize(id, width, height)

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

settle(id)

@spec settle(String.t()) :: :ok | {:error, term()}

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.

start(module_or_path, opts \\ [])

@spec start(module() | String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}

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" for TableDemo; 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 - the Raxol.MCP.Registry the 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 running Raxol.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 a Raxol.MCP.Registry that 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 - the Raxol.MCP.Server told when the session's tool set changes (notifications/tools/list_changed), in the same forms as :registry and checked the same way: a server given here must be a running Raxol.MCP.Server, else {:error, {:invalid_server, server}} and the process is never messaged. (default: Raxol.MCP.Server)
  • :subscriptions - false boots the app with its declared subscriptions (timers, event sources) unarmed, so the model advances only on messages delivered explicitly via send_message/2 or send_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)

start_link(opts \\ [])

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

stop(id)

@spec stop(String.t()) :: :ok | {:error, term()}

Stops a headless session.