diff --git a/lib/firmboot/water_leak/durable.ex b/lib/firmboot/water_leak/durable.ex index 1a1457d..e903a8e 100644 --- a/lib/firmboot/water_leak/durable.ex +++ b/lib/firmboot/water_leak/durable.ex @@ -63,6 +63,12 @@ defmodule Firmboot.WaterLeak.Durable do when point in [:before_write, :partial_write, :after_write, :after_sync, :sync_error], do: GenServer.call(pid, {:fault, point}) + @doc """ + Opens the journal at `path`, replays it and builds the initial GenServer state. + + Stops with the underlying reason on an invalid `repair_tail` option, a missing or + malformed journal, or a replay failure; never starts from partial state. + """ @impl true def init({path, repair_tail}) do with true <- is_boolean(repair_tail), @@ -90,6 +96,10 @@ defmodule Firmboot.WaterLeak.Durable do end end + @doc """ + Serves `:snapshot`, `:status` and fault-arming calls directly, and `:submit` by + journaling and applying the command before replying. + """ @impl true def handle_call(:snapshot, _from, s), do: {:reply, s.domain, s} def handle_call({:fault, fault}, _from, s), do: {:reply, :ok, %{s | fault: fault}} @@ -137,6 +147,8 @@ defmodule Firmboot.WaterLeak.Durable do end end + # Applies a new command, journals it, and only then replies; a failed write/sync + # stops the process rather than reply from state that may not match the file. defp commit(s, id, command) do if s.journal.count >= s.journal.config.max_records do {:reply, {:error, :storage_capacity}, s} @@ -176,6 +188,7 @@ defmodule Firmboot.WaterLeak.Durable do end end + @doc "Closes the journal file as the process terminates." @impl true def terminate(_reason, s), do: Journal.close(s.journal) end diff --git a/lib/firmboot/water_leak/durable/codec.ex b/lib/firmboot/water_leak/durable/codec.ex index bdf92f6..eeeb2b4 100644 --- a/lib/firmboot/water_leak/durable/codec.ex +++ b/lib/firmboot/water_leak/durable/codec.ex @@ -96,6 +96,7 @@ defmodule Firmboot.WaterLeak.Durable.Codec do end end + # Mirrors encode/1: one clause per tag byte, binary pattern matches enforcing shape. defp parse( <<0, cap::32, threshold::64, silence::32, fingerprint::binary-size(32), n, source::binary-size(n)>> @@ -128,6 +129,8 @@ defmodule Firmboot.WaterLeak.Durable.Codec do defp parse(<<4, bytes::16>>), do: {:tail_repair, bytes} defp parse(_), do: :invalid + + # Small enum <-> wire-byte mappings used by encode/1 and parse/1 above. defp version(:v1), do: 1 defp version(:v2), do: 2 defp fault(f), do: Enum.find_index([:none, :position, :reported, :history], &(&1 == f)) diff --git a/lib/firmboot/water_leak/durable/journal.ex b/lib/firmboot/water_leak/durable/journal.ex index 8f4278e..035f8fd 100644 --- a/lib/firmboot/water_leak/durable/journal.ex +++ b/lib/firmboot/water_leak/durable/journal.ex @@ -124,6 +124,7 @@ defmodule Firmboot.WaterLeak.Durable.Journal do end end + # Kills the process when the armed fault point matches the current point; a no-op otherwise. defp crash(point, point) when not is_nil(point), do: Process.exit(self(), :kill) defp crash(_, _), do: :ok @@ -163,6 +164,8 @@ defmodule Firmboot.WaterLeak.Durable.Journal do end end + # Reads chained slots until eof, an incomplete tail, or the record limit; verifies + # each slot's hash against the running chain as it goes. defp read_records(fd, hash, remaining, records) do case :file.read(fd, @slot) do :eof -> @@ -191,6 +194,7 @@ defmodule Firmboot.WaterLeak.Durable.Journal do end end + # Encodes `record` and wraps it with the magic prefix, chained hash and zero padding. defp frame(record, previous) do payload = Codec.encode(record) size = byte_size(payload) @@ -201,6 +205,7 @@ defmodule Firmboot.WaterLeak.Durable.Journal do 0::size((@payload_size - size) * 8)>>, hash} end + # Inverse of frame/2: verifies the magic, chained hash and zero padding, then decodes. defp unframe( <<@magic, size::16, hash::binary-size(32), rest::binary-size(@payload_size)>>, previous diff --git a/lib/firmboot/water_leak/durable/transition.ex b/lib/firmboot/water_leak/durable/transition.ex index 8eb3b50..dbe8e2e 100644 --- a/lib/firmboot/water_leak/durable/transition.ex +++ b/lib/firmboot/water_leak/durable/transition.ex @@ -61,6 +61,7 @@ defmodule Firmboot.WaterLeak.Durable.Transition do Enum.reverse(ranges) end + # One clause per command kind; returns the possibly-updated state and its reply. defp execute(s, _id, {:sample, reading}) do existing = Map.get(s.waiting, reading.seq) || @@ -113,6 +114,7 @@ defmodule Firmboot.WaterLeak.Durable.Transition do end end + # Applies any buffered readings that are now contiguous with the processed sequence. defp drain(s) do case Map.pop(s.waiting, s.water.model.seq + 1) do {nil, _} -> diff --git a/mix.exs b/mix.exs index 18bdc18..a02e961 100644 --- a/mix.exs +++ b/mix.exs @@ -1,6 +1,8 @@ defmodule FirmbootReference.MixProject do + @moduledoc "Build configuration for the Firmboot reference project." use Mix.Project + @doc "The Mix project configuration: app name, version, Elixir constraint and deps." def project do [ app: :firmboot_reference, @@ -11,5 +13,6 @@ defmodule FirmbootReference.MixProject do ] end + @doc "Declares `:crypto` as a runtime application, needed by the journal's SHA-256 chain." def application, do: [extra_applications: [:crypto]] end diff --git a/test/firmboot/durable_test.exs b/test/firmboot/durable_test.exs index 8edc825..de27f5f 100644 --- a/test/firmboot/durable_test.exs +++ b/test/firmboot/durable_test.exs @@ -363,12 +363,15 @@ defmodule Firmboot.DurableTest do assert WaterLeak.outstanding(Durable.snapshot(recovered).water) == [@key] end + # Starts a writer under test and registers it to be stopped on exit. defp start(path, options \\ []) do assert {:ok, pid} = Durable.start(path, options) on_exit(fn -> if Process.alive?(pid), do: Durable.stop(pid) end) pid end + # Submits the fixture's first six readings in order, including the mid-stream + # :update at seq 5, so a test can start from a known, journaled baseline. defp seed(pid) do for reading <- Enum.take(Experiment.readings(), 6) do if reading.seq == 5 do @@ -380,11 +383,13 @@ defmodule Firmboot.DurableTest do end end + # All orderings of a list, used to exercise every arrival order of a fixture. defp permutations([]), do: [[]] defp permutations(xs), do: for(x <- xs, tail <- permutations(List.delete(xs, x)), do: [x | tail]) + # Flips one bit at `offset`, used to simulate single-byte journal corruption. defp flip(binary, offset) do <> = binary before <> <> <> after_bytes