Raxol.Harness.EditorSuspend (Raxol v2.6.1)

View Source

The PURE side of the composer's external-editor handoff: editor resolution policy, the draft round-trip discipline, the temp-file naming policy, and -- the load-bearing part -- the suspend/resume state machine with per-step compensation.

No IO, no processes, no tty anywhere in this module. The thin impure runner (Raxol.Harness.EditorSession) drives this machine and interprets each emitted step against the real device/stty/reader; keeping the sequencing HERE means the ordering and the failure-recovery table are plain data, exhaustively testable with a ledger fold instead of a mocked terminal.

The step sequence (pinned, order is load-bearing)

Suspend bracket:

  1. :write_tmp -- persist the draft first: the cheapest step, and failing it unwinds nothing.
  2. :disable_reader -- quiesce the BEAM's tty reader (Raxol.Terminal.InlineDriver.ReaderGate) BEFORE any terminal bytes: from this point typed keystrokes queue in the kernel for the editor instead of being split between two readers. It is also the most failure-prone step, and failing here still unwinds nothing terminal-visible.
  3. :release_screen -- the canonical suspend bytes (Raxol.Terminal.InlineDriver.Sequences.suspend_bytes/1: modes off, CSI r, autowrap+cursor, bare park). MUST run while the tty is still raw, else the escapes are line-processed as garbage -- the same invariant the teardown order pins.
  4. :restore_tty -- cooked modes, LAST among the terminal operations: the editor needs sane modes to start from (a line-based $EDITOR would be unusable raw, and Ctrl-C must work if it wedges before installing its own termios).
  5. :spawn_editor / :await_editor -- the synchronous handoff.

Resume bracket:

  1. :requery_size -- while still cooked: the terminal may have been resized while the editor owned it.
  2. :raw_tty -- before the reader is re-enabled, so no cooked-mode echo race on queued typing.
  3. :enable_reader -- the gate reopens; the reader's armed state persisted across the bracket.
  4. :reinit_modes -- Sequences.init_bytes/0 (bracketed paste + focus reporting back on; the suspend's modes-off turned them off).
  5. :reassert_region -- the UNCONDITIONAL DECSTBM re-pin (InlineAuthority.reassert/1; a geometry-gated resize alone would skip the write when nothing changed).
  6. :reload_draft -- read the temp file back (exit-status policy is the runner's concern; this machine only sequences).
  7. :cleanup_tmp -- always last, every path.

Compensation (what is restorable at each failure point)

Each step's effect on the externally observable resources is a ledger transition over {tty, reader, screen, modes, tmp}. advance/2 with {:error, reason} (and recovery/1, the runner's rescue seam) derives the compensation from the ledger, emitted in the safe resume order: :raw_tty, :enable_reader, :reinit_modes, :reassert_region, :cleanup_tmp (each included only when the ledger actually deviates). By construction, completed-steps ++ compensation always folds back to the invariant {raw, enabled, asserted, on, absent} -- the exhaustive test in editor_suspend_test.exs pins exactly that, over every failure point.

Worst-case assumption for suspend-phase failures

A FAILED suspend-phase step (:write_tmp, :disable_reader, :release_screen, :restore_tty) is folded into the ledger AS IF its effects landed: a failed File.write may have created the file (ENOSPC after create), a raising IO.write may have emitted part of its bytes, a timed-out stty/gate call may have taken effect with the reply lost. Compensating effects that never happened is harmless (every compensation is idempotent toward the invariant -- an enable against a never-disabled reader is ignored and times out, a re-raw of an already-raw tty is a no-op); NOT compensating effects that DID happen strands the terminal. Resume-phase steps get the opposite assumption -- a failed :raw_tty/:reinit_modes is NOT done, so compensation retries it.

Degradable steps: :enable_reader

:enable_reader is the one step whose failure must NOT abort: the resume is already half-done, and finishing it (modes, region, draft reload) with degraded input beats stranding the terminal mid-resume. The runner reports it via advance(machine, {:degraded, reason}): the machine continues to the next step but records the degradation -- the ledger keeps reader: :disabled (a degraded enable is honestly NOT an enable, so a later failure's compensation retries the reader), and degradations/1 exposes the list so the runner/caller MUST surface it (notice + telemetry) instead of a silent {:ok, ...}. A {:degraded, _} report on any non-degradable step raises: it is a programmer error, not a policy choice.

Draft round-trip discipline

encode_draft/1 is the identity -- the draft is written verbatim. decode_draft/1 strips exactly ONE trailing line terminator (\r\n or \n), nothing else: POSIX editors append exactly one trailing newline to a saved file, so stripping one restores the draft byte-for-byte for any draft not itself ending in a newline (composer drafts never do -- Enter submits). Mirrors the OTP shell editor's string:chomp choice.

Summary

Functions

Report the outcome of the pending step and receive the next effect.

File content -> draft: strips exactly one trailing \r\n or \n (see the moduledoc's round-trip discipline). Everything else is preserved byte-for-byte.

Every degradation recorded so far, as {step, reason} in step order. A non-empty list at :done means the run COMPLETED but a resource could not be restored (today: the stdin reader after the editor) -- the runner must surface this to the operator, never swallow it.

Draft -> file content: the identity (written verbatim).

A fresh machine: nothing completed, nothing pending.

The compensation the abort path would take from this machine's position -- the runner's rescue seam: on an EXCEPTION mid-step (rather than an error return), the runner interprets exactly these steps before surfacing the failure. Identical to what advance(machine, {:error, _}) would return.

$VISUAL || $EDITOR || "vi" -- first NON-EMPTY wins (an empty-string export is treated as unset, not as an editor named ""). Always succeeds: the fallback exists by policy; a missing binary surfaces later as the shell's exit 127, which the runner maps to a keep-the-draft notice rather than a crash.

The canonical ordered step list (see the moduledoc).

Pure temp-file naming policy; the runner joins it to the tmpdir.

Types

completion()

@type completion() :: {step(), :ok | {:degraded, term()}}

machine()

@type machine() :: %{completed: [completion()], pending: step() | nil}

step()

@type step() ::
  :write_tmp
  | :disable_reader
  | :release_screen
  | :restore_tty
  | :spawn_editor
  | :await_editor
  | :requery_size
  | :raw_tty
  | :enable_reader
  | :reinit_modes
  | :reassert_region
  | :reload_draft
  | :cleanup_tmp

Functions

advance(machine, arg2)

@spec advance(machine(), :ok | {:degraded, term()} | {:error, term()}) ::
  {:effect, step(), machine()}
  | {:done, machine()}
  | {:abort, [step()], machine()}

Report the outcome of the pending step and receive the next effect.

  • advance(machine, :ok) -- the pending step (if any) completed; returns {:effect, next_step, machine} with the next step now pending, or {:done, machine} when the sequence is exhausted.
  • advance(machine, {:degraded, reason}) -- the pending step is a DEGRADABLE step (:enable_reader) whose work failed but whose failure must not abort the resume: the machine continues exactly like :ok but RECORDS the degradation (degradations/1), and the ledger keeps the resource un-repaired so a later failure's compensation retries it. Raises ArgumentError on a non-degradable step (programmer error, not policy).
  • advance(machine, {:error, reason}) -- the pending step FAILED; returns {:abort, compensation, machine} where compensation covers the completed steps PLUS, for a suspend-phase pending step, the worst-case assumption that its effects landed (see the moduledoc), in the safe resume order.

decode_draft(content)

@spec decode_draft(binary()) :: String.t()

File content -> draft: strips exactly one trailing \r\n or \n (see the moduledoc's round-trip discipline). Everything else is preserved byte-for-byte.

degradations(map)

@spec degradations(machine()) :: [{step(), term()}]

Every degradation recorded so far, as {step, reason} in step order. A non-empty list at :done means the run COMPLETED but a resource could not be restored (today: the stdin reader after the editor) -- the runner must surface this to the operator, never swallow it.

encode_draft(draft)

@spec encode_draft(String.t()) :: String.t()

Draft -> file content: the identity (written verbatim).

new()

@spec new() :: machine()

A fresh machine: nothing completed, nothing pending.

recovery(map)

@spec recovery(machine()) :: [step()]

The compensation the abort path would take from this machine's position -- the runner's rescue seam: on an EXCEPTION mid-step (rather than an error return), the runner interprets exactly these steps before surfacing the failure. Identical to what advance(machine, {:error, _}) would return.

resolve_editor(env)

@spec resolve_editor(%{optional(String.t()) => String.t()}) :: {:ok, String.t()}

$VISUAL || $EDITOR || "vi" -- first NON-EMPTY wins (an empty-string export is treated as unset, not as an editor named ""). Always succeeds: the fallback exists by policy; a missing binary surfaces later as the shell's exit 127, which the runner maps to a keep-the-draft notice rather than a crash.

steps()

@spec steps() :: [step()]

The canonical ordered step list (see the moduledoc).

tmp_filename(unique)

@spec tmp_filename(String.t()) :: String.t()

Pure temp-file naming policy; the runner joins it to the tmpdir.