Inline driver profile (unit T2d).
A sibling of Raxol.Terminal.Driver, not a replacement: today's driver
enters the alternate screen at init (\e[?1049h) and termbox owns the
whole tty. This module never does either. It puts the tty in raw mode
(no echo, no line buffering, no signal generation), runs T1's capability
probe over the same input fd, and streams parsed key events to a
subscriber -- all while leaving the terminal's native scrollback
completely alone. T2b (printed-history append) and T2a (scroll-region
manager) are separate units layered on top; this module sets no scroll
region itself.
Constructor options
:dispatcher_pid-- the default subscriber for parsed input events (see below). May benil(headless use).:subscriber-- overrides:dispatcher_pidas the input-event target, for tests that want a separate collector.:raw_sink-- optional pid that receives every PRE-parse input chunk as{:inline_raw_input, binary()}, exactly as it arrived from the tty reader, beforeInputParsersees it. Debug/observability seam (byte-level input tracing); defaultnil-- when unset, nothing changes on the input path.:device-- output sink. Default:stdio. Accepts:stdioor anyIO.device/0(aStringIOpid works great in tests -- everything here is written via plainIO.write/2, never raw port writes). This is the suite design's hard requirement: the output device is a parameter, not a hardcoded:stdiowrite, so Tier A can capture bytes with no pty and no termbox.:stty-- module implementingsave/0,raw!/0,restore/1(defaultRaxol.Terminal.Driver.Stty). Inject a recording stub in tests so OS-level tty state is never touched by a pure test run. Note:raw!/0's termios flags include-isig, so once raw mode is entered the kernel stops generating SIGINT/SIGTSTP from Ctrl-C / Ctrl-Z -- interrupt delivery and job control become this driver's responsibility (or the app's) until teardown restores the saved settings.:tty?-- override real-tty detection (defaultRaxol.Terminal.TerminalUtils.has_terminal_device?/0). Drives the default of:install_reader?and:stty_enabled?below.:stty_enabled?-- whether to actually invoke the injected:sttymodule (default: same as:tty?). Kept independent of:tty?so a test can force the byte-emission path (tty?: true) while keepingstty_enabled?: false-- the real OS tty of whatever process is running the test suite is never touched by Tier A.:install_reader?-- whether to hook the prim_tty trace-based stdin reader (default: same as:tty?). Independent for the same reason.:rows-- terminal height used for the final teardown cursor move (default: detected via:io.rows/0, falling back to the injected:sttymodule'ssize/0, falling back to 24).:probe?-- whether to run the T1 capability probe at startup (defaulttrue). Tests that only care about teardown ordering can passfalseto skip the (bounded, ~100ms local / ~1s SSH) wait.:capabilities-- a pre-computed%Raxol.Terminal.Capabilities{}. When given, the probe is skipped entirely and this record is cached directly -- the fastest test seam, and also useful for embedding contexts that already know the answer.:probe_opts-- forwarded verbatim toRaxol.Terminal.Capabilities.Probe.new/2(:budget_ms,:extend_ms,:now_ms,:tmux_passthrough?,:platform).:probe_env-- the env map the probe seeds from (defaultSystem.get_env/0).
The output-device + teardown seam
emit_teardown/2 is the pure(-ish) seam the suite design calls for: it
takes the output device and a driver state and writes the canonical
teardown byte sequence (Raxol.Terminal.InlineDriver.Sequences),
restores the OS tty via the injected stty module, and returns an updated
state with torn_down?: true. It is idempotent -- a state that already
has torn_down?: true is returned unchanged and nothing is written, so
calling it twice (a signal handler AND terminate/2, say) can never
double-emit \e[r or reposition the cursor twice (LC-N-DOUBLE).
terminate/2 is the only caller in this module. It runs whenever THIS
process terminates for a reachable reason: a direct GenServer.stop/1
on the driver, or a crash inside any handle_manager_* callback (a
raised error inside a GenServer callback still runs that same process's
own terminate/2 -- orthogonal to trap_exit, which only gates
external exit signals). System.halt and SIGKILL run no cleanup at
all -- documented residual, not a gap in this module (kill -9 cannot
be caught by anything running in the killed process).
Teardown-on-quit through Lifecycle: graceful stop FIXED (T28a), SIGTERM open (T28b)
The driver's teardown itself is correct and deterministic; whether Lifecycle invokes it on shutdown was unit T28's subject, since split into two facets with different fates:
Raxol.stop/1(in-process graceful stop): deterministic since T28a (merged).Lifecyclenow traps exits and drives teardown from its ownterminate/2, driver-first, so a dependent's:shutdownexit can no longer race back through the link and killLifecyclebefore this driver is stopped -- thisterminate/2runs by construction. (Pre-T28a this was a measured ~50% miss under ExUnit load.) Enforced by the now-unskipped graceful-stop test intest/harness/t2d_teardown_positive_test.exs.OTP default SIGTERM ->
init:stop/0(the real VM tree-unwind a productionkill -TERM/ container stop triggers): teardown is still never reached. The tree unwind does not route throughLifecycleat all, so thisterminate/2is skipped every time (measured under a real pty: no\e[r, no modes-off in the capture). This is the stdio-shutdown race the termboxdriver.exalready flags (~line 494) surfacing as a total miss on the inline path. Tracked as unit T28b (the first SIGTERM handler attempt self-deadlocked in:erl_signal_serverand was reworked). Until T28b lands, production SIGTERM teardown relies on the app arranging its own handler that callsRaxol.stop/1(as the Tier BLC-P-SIGTERMtest does); the gap stays pinned by the remaining skipped@tag :pending_t28btest. The driver's own teardown is meanwhile proven deterministically by stopping the driver process directly (LC-P-CLEAN).
Input contract
Parsed input events are sent to the subscriber as
{:inline_input, %Raxol.Core.Events.Event{}} messages -- a simple
pid/message contract, intentionally thin. Unit T13a wires the real
Dispatcher; until then this seam is the whole contract.
Summary
Functions
Returns a specification to start this module under a supervisor.
Writes the canonical teardown byte sequence to device and restores the
OS tty (via state.stty_module, unless state.stty_enabled? is
false). Idempotent: a state with torn_down?: true is returned
unchanged, writing nothing (LC-N-DOUBLE).
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
The isig diagnosis for embedder-level reporting (the live demo's boot
POST line): which mechanism last held -isig and how often the
event-clocked guard has had to re-assert it.
Asks the real terminal where the shell left the cursor: writes DSR-6
(Sequences.cursor_position_request/0, CSI 6n) to the driver's
device and reads the CPR reply (CSI row ; col R) off the driver's
own input stream. This is the substrate for GUEST-BOOT placement
(Raxol.UI.Rendering.PaintAuthority.InlineAuthority's :boot_cursor
option): boot the surface exactly where the user's shell stopped,
instead of pushing a blank screen first.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec emit_teardown(IO.device(), Raxol.Terminal.InlineDriver.State.t()) :: Raxol.Terminal.InlineDriver.State.t()
Writes the canonical teardown byte sequence to device and restores the
OS tty (via state.stty_module, unless state.stty_enabled? is
false). Idempotent: a state with torn_down?: true is returned
unchanged, writing nothing (LC-N-DOUBLE).
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.
@spec isig_report(GenServer.server()) :: %{ boot_confirmed?: boolean(), reasserts: non_neg_integer(), isig_off?: boolean() }
The isig diagnosis for embedder-level reporting (the live demo's boot
POST line): which mechanism last held -isig and how often the
event-clocked guard has had to re-assert it.
boot_confirmed?-- the post-reader-arm verify loop saw-isighold for 3 consecutive reads at claim time;reasserts-- how many times the per-input-event guard found ISIG flipped back ON mid-session and re-asserted;isig_off?-- the LIVE flags right now, read through the same injectable reader the guard uses.
@spec probe_cursor( GenServer.server(), keyword() ) :: {:ok, Raxol.Terminal.InlineDriver.CursorReport.position()} | {:error, :timeout | :no_tty}
Asks the real terminal where the shell left the cursor: writes DSR-6
(Sequences.cursor_position_request/0, CSI 6n) to the driver's
device and reads the CPR reply (CSI row ; col R) off the driver's
own input stream. This is the substrate for GUEST-BOOT placement
(Raxol.UI.Rendering.PaintAuthority.InlineAuthority's :boot_cursor
option): boot the surface exactly where the user's shell stopped,
instead of pushing a blank screen first.
Returns:
{:ok, {row, col}}— 1-based, straight from the terminal's reply.{:error, :no_tty}— the driver was constructed withtty?: false(piped/CI). ZERO bytes are written: a device that cannot answer must not be probed at all.{:error, :timeout}— no CPR arrived within:budget_ms(default 300ms). The budget is a LIVENESS bound on the device round-trip, not a rendering clock — see the module attribute note. On timeout every byte read while waiting has already been forwarded to the subscriber as ordinary input; nothing is dropped.
The consumption contract (why this lives on the driver)
The CPR reply arrives interleaved with real keystrokes on the same
fd this driver already owns. Scanning happens INSIDE the driver
process (Raxol.Terminal.InlineDriver.CursorReport, the pure
scanner), before InputParser ever sees the bytes, because a CPR is
not representable as "just another event": InputParser decodes a
row-1 reply (\e[1;<n>R) as a modified F3 keypress — the classic
DSR/F3 wire collision — so letting it through would deliver a
phantom key event to the app. Keystrokes interleaved with (or split
around) the reply are forwarded to the subscriber in arrival order,
never dropped.
Caller discipline
- Probe BEFORE the first paint, at most once per claim: this writes
bytes to the device, so it is strictly opt-in (nothing in
init_manager/1ever emits it). - The result is only placement-honest while nothing else has written to the device between the reply and the boot that consumes it.
- Residual: a reply that arrives AFTER the deadline lapses flows
down the ordinary input path, where
InputParserconsumes it silently — except the row-1 form, which surfaces as a phantom modified-F3 keypress. Bound the exposure by probing when the terminal is idle (boot), which is the only supported call site.