Raxol.Terminal.InlineDriver (Raxol Terminal v2.6.1)

Copy Markdown View Source

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 be nil (headless use).
  • :subscriber -- overrides :dispatcher_pid as 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, before InputParser sees it. Debug/observability seam (byte-level input tracing); default nil -- when unset, nothing changes on the input path.
  • :device -- output sink. Default :stdio. Accepts :stdio or any IO.device/0 (a StringIO pid works great in tests -- everything here is written via plain IO.write/2, never raw port writes). This is the suite design's hard requirement: the output device is a parameter, not a hardcoded :stdio write, so Tier A can capture bytes with no pty and no termbox.
  • :stty -- module implementing save/0, raw!/0, restore/1 (default Raxol.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 (default Raxol.Terminal.TerminalUtils.has_terminal_device?/0). Drives the default of :install_reader? and :stty_enabled? below.
  • :stty_enabled? -- whether to actually invoke the injected :stty module (default: same as :tty?). Kept independent of :tty? so a test can force the byte-emission path (tty?: true) while keeping stty_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 :stty module's size/0, falling back to 24).
  • :probe? -- whether to run the T1 capability probe at startup (default true). Tests that only care about teardown ordering can pass false to 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 to Raxol.Terminal.Capabilities.Probe.new/2 (:budget_ms, :extend_ms, :now_ms, :tmux_passthrough?, :platform).
  • :probe_env -- the env map the probe seeds from (default System.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). Lifecycle now traps exits and drives teardown from its own terminate/2, driver-first, so a dependent's :shutdown exit can no longer race back through the link and kill Lifecycle before this driver is stopped -- this terminate/2 runs by construction. (Pre-T28a this was a measured ~50% miss under ExUnit load.) Enforced by the now-unskipped graceful-stop test in test/harness/t2d_teardown_positive_test.exs.

  • OTP default SIGTERM -> init:stop/0 (the real VM tree-unwind a production kill -TERM / container stop triggers): teardown is still never reached. The tree unwind does not route through Lifecycle at all, so this terminate/2 is skipped every time (measured under a real pty: no \e[r, no modes-off in the capture). This is the stdio-shutdown race the termbox driver.ex already 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_server and was reworked). Until T28b lands, production SIGTERM teardown relies on the app arranging its own handler that calls Raxol.stop/1 (as the Tier B LC-P-SIGTERM test does); the gap stays pinned by the remaining skipped @tag :pending_t28b test. 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).

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

emit_teardown(device, state)

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

handle_manager_cast(msg, state)

Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.

isig_report(server)

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

probe_cursor(server, opts \\ [])

@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 with tty?: 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/1 ever 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 InputParser consumes 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.

start_link(init_opts \\ [])