DrinkMeAlice.TestServer (drink_me_alice v0.1.0)

Copy Markdown

Owned, private, loopback-only SurrealDB server child — for tests of this library and of anything built on it.

  • Data lives under a caller-provided private directory (<dir>/data, SurrealKV); the directory is created mode 0700.
  • Root credentials are freshly generated per server, passed only via the SURREAL_USER/SURREAL_PASS environment (never on the command line), and never written to logs or receipts.
  • Bound to an ephemeral loopback port picked by the kernel.
  • The server's full version identity is recorded as the first line of <dir>/server.log — a bare version number does not identify an artifact.
  • Port (stderr) output is appended to <dir>/server.log with a real cumulative byte cap (262144); server.log_bytes tracks it. Long-running probes should call drain/2 inside their wait loops so diagnostic bytes never accumulate in the port mailbox.
  • Startup reports definite failure on early child exit (including bind failure); a /health 200 counts only while the owned child is alive.
  • Concurrent starts on the SAME data dir are refused, never raced. Within this VM a per-dir claim — a dedicated holder process per start, monitoring the port owner, so one caller may hold several stores while a claim still dies with its owner — makes the second caller fail fast with {:error, {:data_dir_in_use, dir, holder_pid}}. Against an already-existing store, the SurrealKV endpoint is scanned for a SUSPECTED foreign surreal start (typically a leftover from a killed VM) and refused with {:error, {:data_dir_lock_suspected, dir, suspects}} BEFORE any child is spawned or the old log is touched. The scan is best-effort, fail-open, and suspicion is a hint to verify — not verified lock ownership; an early exit that still slips through its race window is reported with the log tail and the suspected foreign holders.
  • stop/1 escalates TERM -> KILL under finite deadlines and confirms the OS child is reaped; the last observed exit status is preserved, and the data-dir claim is released — only for THIS instance's claim, only on proven cleanup, and SYNCHRONOUSLY: normally stop/1 does not report completion before the registration is removed, so an immediate same-dir restart is not refused by our own stale claim. If the bounded release handshake times out (a holder that cannot run), the result preserves both facts as {:error, {:claim_release_unconfirmed, holder, cleanup}}. A stale handle or an unproven cleanup cannot strip a live claim; a FAILED start with unproven child cleanup ({:acquisition_unclean, reason, holder} or {:cleanup_unproven, other, holder}) retains its claim — with the holder pid in the error as a receipt — until the starting process dies.

Version qualification

The suite is written and qualified against SurrealDB 3.0.5 ONLY. start/1 resolves the executable (SURREAL_BIN, else surreal on PATH), records its identity and refuses a different version with {:error, {:unqualified_server, reason}} — a later 3.x build is not automatically the qualified behavior. Set SURREAL_ALLOW_UNQUALIFIED=1 to start anyway: that is a compatibility EXPERIMENT, not a qualification, and every such run says so on stdout.

Summary

Functions

Drain port output into server.log and return the updated server.

Whether :live tests may run: :ok, {:skip, reason}, or — with SURREAL_REQUIRE_QUALIFIED=1 set (CI) — {:error, reason} for anything that would otherwise be skipped, so a qualification job fails instead of silently excluding its live suite. A different installed server version skips with the reason; set SURREAL_ALLOW_UNQUALIFIED=1 to run it anyway as a compatibility experiment whose results are NOT a qualification.

The first major.minor.patch in a version line, or :error.

The SurrealDB version this suite is qualified against.

{bin, identity} for the SurrealDB executable: SURREAL_BIN if set, else surreal on PATH. identity is the FULL surreal version output — build metadata included, because a version number alone does not identify the artifact under test.

Start a private server. Options

Stop the server: drain queued port messages, TERM, bounded grace, KILL, drain again, confirm reaped — then release the data-dir claim so the documented restart-on-the-same-dir flow keeps working.

The SurrealDB binary path, raising when absent (deprecated raise behavior kept for direct callers; prefer resolve_bin/0).

Types

creds()

@type creds() :: %{user: String.t(), pass: String.t()}

t()

@type t() :: %DrinkMeAlice.TestServer{
  claim_pid: pid() | nil,
  creds: creds(),
  dir: String.t(),
  exit_status: integer() | nil,
  http_port: :inet.port_number(),
  log_bytes: non_neg_integer(),
  log_path: String.t(),
  os_pid: non_neg_integer(),
  port: port()
}

Functions

drain(server, timeout \\ 100)

@spec drain(t(), non_neg_integer()) :: t()

Drain port output into server.log and return the updated server.

Always performs a nonblocking burst pass first (bounded at 512 queued items) so an already-queued exit_status is observed even with timeout: 0; then blocks up to timeout for trailing output. Call inside long wait loops so diagnostic bytes never accumulate unboundedly in the port mailbox.

live_policy()

@spec live_policy() :: :ok | {:skip, String.t()} | {:error, String.t()}

Whether :live tests may run: :ok, {:skip, reason}, or — with SURREAL_REQUIRE_QUALIFIED=1 set (CI) — {:error, reason} for anything that would otherwise be skipped, so a qualification job fails instead of silently excluding its live suite. A different installed server version skips with the reason; set SURREAL_ALLOW_UNQUALIFIED=1 to run it anyway as a compatibility experiment whose results are NOT a qualification.

parse_version(line)

@spec parse_version(binary()) :: {:ok, String.t()} | :error

The first major.minor.patch in a version line, or :error.

qualified_version()

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

The SurrealDB version this suite is qualified against.

resolve_bin()

@spec resolve_bin() :: {:ok, binary(), binary()} | {:error, term()}

{bin, identity} for the SurrealDB executable: SURREAL_BIN if set, else surreal on PATH. identity is the FULL surreal version output — build metadata included, because a version number alone does not identify the artifact under test.

The version probe is bounded (2000 ms) and typed: a missing binary, an unusable path, a nonzero exit or a hung command is {:error, reason} — never a raise, never a parsed substring of failed output.

start(opts)

@spec start(keyword()) :: {:ok, t()} | {:error, term()}

Start a private server. Options:

  • :dir (required) — private root; <dir>/data is the SurrealKV path, <dir>/server.log records the server identity and bounded stderr.
  • :port — bind this port instead of a free one (restart on the same endpoint, so a conn configured once reconnects to it).
  • :user / :pass — reuse these root credentials instead of generating fresh ones. REQUIRED when restarting onto an existing SurrealKV store: the stored root user persists, so fresh creds would not take effect and every signin would fail -32002.
  • :args — extra surreal start CLI args (e.g. --allow-net, --async-event-interval); must never contain credentials.
  • :env — extra env pairs (charlist keys/values).
  • :health — false to skip the /health wait.

A caller may hold several servers at once (each start claims its own directory through a dedicated holder process). Refuses with {:error, {:unqualified_server, reason}} before creating anything when the resolved executable is not the qualified version and SURREAL_ALLOW_UNQUALIFIED=1 is not set. Refuses with {:error, {:data_dir_in_use, dir, holder_pid}} when another live claim in this VM already owns dir. When the store already exists, a best-effort host process scan runs: a SUSPECTED foreign holder (a process whose executable is named surreal and whose arguments carry the exact endpoint token) refuses with {:error, {:data_dir_lock_suspected, dir, suspects}} before any child is spawned. A command-line match is a hint, not verified lock ownership: identify the process, confirm it really owns this store, and stop that owner — do not kill on a command-line match alone, and never delete a store that may still be active. The scan is fail-open. Directories are claimed by their expanded path. On error, pre-spawn failures release the claim synchronously (if that release cannot be confirmed, the result says so via {:claim_release_unconfirmed, holder, reason}); a start whose child was acquired but not proven cleaned up retains the claim — with the holder pid in the error as a receipt — until the starting process dies. Diagnostics are best-effort (a broken IO device cannot prevent finalization), and EVERY exception after acquisition — including one raised by failing diagnostics inside a rescue handler — is converted to proven cleanup or a retained claim with a typed receipt ({:startup_failed, {:finalization_crashed, cause}, ...}).

stop(server)

@spec stop(t()) ::
  {:exit_status, integer()} | {:reaped, :status_unobserved} | {:error, term()}

Stop the server: drain queued port messages, TERM, bounded grace, KILL, drain again, confirm reaped — then release the data-dir claim so the documented restart-on-the-same-dir flow keeps working.

The claim is released ONLY when cleanup is proven AND this instance still owns the claim: the live registration must be this start's holder process, so a stale handle stopped after a replacement start cannot release the replacement's claim, and an unproven cleanup ({:error, :unreaped} and friends) leaves the exclusion in place while the child may still hold the store — it clears when the owning process dies.

Release itself is a bounded synchronous handshake: normally stop/1 does not return until the registration is removed. If confirmation times out (a holder that cannot run), the public result preserves BOTH facts — the child cleanup proof and the unresolved release — as {:error, {:claim_release_unconfirmed, holder, cleanup}}; the claim clears when the holder next runs or its owner dies, and an immediate same-directory restart may still be refused until then.

Returns {:exit_status, n} only for an observed OS exit code; {:reaped, :status_unobserved} when the child is confirmed gone but no exit_status message was observed; {:error, ...} when cleanup cannot be proven or the claim release is unconfirmed.

surreal_bin()

The SurrealDB binary path, raising when absent (deprecated raise behavior kept for direct callers; prefer resolve_bin/0).