# `Raxol.Journal.Test.FaultJournal`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/journal/test/fault_journal.ex#L1)

Fault-injection harness for ExUnit suites that exercise the journal.

Instruments the REAL `Raxol.Agent.Journal.FileStore` (no mock journal — the
code under test is the production storage path) with five named fault sites:

  * `:append_fail`                  — the Writer's `:file.write` fails
    (`:enospc`-class): a dead fd is swapped into the live Writer so the next
    append returns `{:error, _}` from the production error arm, offset not
    advanced, Writer alive.
  * `:open_fail`                    — `FileStore.open/2` fails: the session
    dir path is occupied by a regular file, so `File.mkdir_p!` in the
    Writer's `init/1` raises and `start_link` returns `{:error, _}`.
  * `:kill_after_write_before_head` — bytes reached the OS but the crash
    beat the HEAD rewrite: complete framed records are appended raw to the
    newest segment (exactly what a flushed `:file.write` leaves behind), then
    the Writer is killed, leaving HEAD stale behind the journal.
  * `:kill_after_head_before_publish` — journal + HEAD carry a record the
    live tail never saw: the record is appended through a shared (joiner)
    FileStore handle, bypassing the publish step.
  * `:writer_down`                  — the Writer process dies under a live
    handle; the next `append/2` through that handle returns
    `{:error, {:writer_down, _}}`.

## Meta-invariant support

  * **m1 dead-injector detection** — every site keeps a per-harness fire
    counter; `assert_all_fired!/2` fails the test if an armed site never
    fired. A dead injector = green lies.
  * **m2 seed-reproducible schedules** — fault schedules are StreamData
    generated, so a failing run prints the shrunk schedule and the ExUnit
    seed reproduces it exactly. `assert_all_fired!/2` takes the schedule so
    dead-injector failures also dump it.
  * **m6 oracle independence** — journal truth comes from `raw_scan/1` /
    `raw_records!/1`: a raw `File.read!` plus this module's OWN framed-line
    decoder. It never consults the Writer's in-memory offset or
    `FileStore.read/2`. Live truth is a subscriber process (the test
    process), never a publisher return value.

# `scan_entry`

```elixir
@type scan_entry() ::
  {:ok, map(), String.t()}
  | {:corrupt, Path.t(), String.t()}
  | {:torn, Path.t(), String.t()}
```

One raw-scan entry:
  * `{:ok, record, raw_line}` — a complete, decodable framed line
  * `{:corrupt, path, raw_line}` — a complete line that does not decode
  * `{:torn, path, raw_bytes}` — unterminated trailing bytes of the LAST segment

# `arm`

Arm a fault site: it MUST fire before `assert_all_fired!/2` or the test fails.

# `assert_all_fired!`

Meta-invariant m1: fail if any armed site never fired (dead injector).

`schedule` (any term) is included in the failure message so a failing run
dumps its fault schedule alongside the ExUnit seed (m2).

# `fired`

Per-site fire counts.

# `heal_append_fail`

Restore the Writer's real fd after `inject_append_fail/3`.

# `heal_open_fail`

Remove the blocking file so the next open succeeds.

# `inject_append_fail`

Site `:append_fail`: swap a dead (closed) fd into the live Writer so its next
`:file.write` returns `{:error, _}` — the `:enospc` branch. Returns the
original io term; pass it to `heal_append_fail/2` to restore the Writer.

The dead fd is opened+closed INSIDE the Writer process (the replace_state fun
runs there) so the Writer owns it.

# `inject_head_before_publish`

Site `:kill_after_head_before_publish`: append `record` through a joiner
FileStore handle sharing the session's single Writer — journal + HEAD get the
record, the live tail never does (the "crash after append, before publish"
window made permanent). Returns the assigned offset.

# `inject_open_fail`

Site `:open_fail`: occupy the session dir path with a regular file so
`FileStore.open/2` (Writer `init/1` -> `File.mkdir_p!`) fails. Only valid for
a session with no journal on disk yet.

# `inject_write_before_head`

Site `:kill_after_write_before_head` (data half): append fully-framed,
newline-terminated records raw to the newest segment — byte-for-byte what a
flushed `:file.write` leaves when the crash beats the HEAD rewrite. Records
must already carry `"id"` (continuing the sequence) and `"schema_version"`.

Pair with `stop_writer/1` / `kill_writer_brutal/2` for the "kill" half; the
Writer must NOT append again through its stale in-memory offset afterwards.

# `kill_writer_brutal`

A REAL brutal kill (meta-invariant m7: no timer cheats for durability).
`Process.exit(writer, :kill)` — terminate/2 never runs. Waits for the death
and then for `:global` to release the Writer's name (the async cleanup a
graceful stop does synchronously), so a reopen is deterministic.

# `new`

Start a fresh harness (an Agent holding armed-site set + fire counters).

# `raw_head`

Raw HEAD read: `{:ok, map}`, `{:error, reason}` (unparseable), or `:missing`.

# `raw_ids!`

Ids of a healthy journal, in file order.

# `raw_lines!`

`{record, raw_line}` pairs of a healthy journal (for byte-identity checks).

# `raw_records!`

Decoded records from a journal expected to be fully healthy — raises on corrupt/torn entries.

# `raw_scan`

```elixir
@spec raw_scan(Path.t()) :: [scan_entry()]
```

Independent decode of every segment. An unterminated trailing chunk of a
non-last segment is classified `:corrupt` (bytes can only legally be torn at
the very end of the journal).

# `record_fired`

Record that a site fired (called by the site injectors below).

# `segment_paths`

Segment file paths under `dir/journal`, ascending — raw `File.ls`, no Reader.

# `sites`

All named fault sites.

# `stop_writer`

Site `:writer_down`: stop the Writer (graceful stop — terminate/2 frees the
`:global` name synchronously, so a later reopen never races the cleanup).
Any handle still holding this pid gets `{:error, {:writer_down, _}}`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
