Gateway daemon discovery + registry (build order step 1).
One long-lived gateway per DB owns execution; every TUI/web/whatever is a thin client of it. This namespace answers the boot-time question: "is a gateway already running for my DB — attach; else spawn one, DETACHED (nobody's child), then hand back where to connect."
Registry: one EDN file per DB at ~/.vis/gateway/registry/<sha256(db)>.edn
holding {:pid :port :host :secret :db :created-at}. Freshness = the recorded
:pid is still alive AND a caller-supplied probe confirms the port+secret
are really OUR daemon (guards OS pid reuse — see D4/Q3 in TODO-gateway-daemon).
Design decisions (locked, see TODO-gateway-daemon.md):
--db share one daemon).register-self! from serve-main!), so a
spawner never needs the child pid — it just polls for a fresh registry.:memory never registers/spawns (headless one-shot stays in-process).Effects (spawn, probe, pid-liveness) are injectable so the orchestration
in discover-or-start! is unit-testable without a real process.
Gateway daemon discovery + registry (build order step 1).
One long-lived gateway per DB owns execution; every TUI/web/whatever is a
thin client of it. This namespace answers the boot-time question: "is a
gateway already running for my DB — attach; else spawn one, DETACHED (nobody's
child), then hand back where to connect."
Registry: one EDN file per DB at `~/.vis/gateway/registry/<sha256(db)>.edn`
holding `{:pid :port :host :secret :db :created-at}`. Freshness = the recorded
`:pid` is still alive AND a caller-supplied `probe` confirms the port+secret
are really OUR daemon (guards OS pid reuse — see D4/Q3 in TODO-gateway-daemon).
Design decisions (locked, see TODO-gateway-daemon.md):
- Q2 registry key = the DB path (two dirs sharing `--db` share one daemon).
- Q3 race = port-bind winner is the daemon; the loser attaches. The daemon
SELF-REGISTERS on startup (via [[register-self!]] from `serve-main!`), so a
spawner never needs the child pid — it just polls for a fresh registry.
- Q5 `:memory` never registers/spawns (headless one-shot stays in-process).
Effects (`spawn`, `probe`, pid-liveness) are injectable so the orchestration
in [[discover-or-start!]] is unit-testable without a real process.(acquire-spawn-lock! db)Try to grab the EXCLUSIVE cross-process spawn lock for db WITHOUT blocking.
Returns a {:channel :lock} holder when THIS process won the right to spawn,
or nil when another process already holds it (that process is the designated
spawner — the caller should just await the registry instead of launching a
competing daemon). Never throws.
Try to grab the EXCLUSIVE cross-process spawn lock for `db` WITHOUT blocking.
Returns a `{:channel :lock}` holder when THIS process won the right to spawn,
or nil when another process already holds it (that process is the designated
spawner — the caller should just await the registry instead of launching a
competing daemon). Never throws.(await-registry! db probe)(await-registry! db
probe
{:keys [timeout-ms poll-ms on-tick]
:or {timeout-ms 8000 poll-ms 100}})Poll for a FRESH registry entry for db up to timeout-ms, checking every
poll-ms. :on-tick (optional) is called before each sleep with the elapsed
millis so a caller can render live 'still waiting…' feedback; it never breaks
the poll loop. Returns the entry or nil on timeout.
Poll for a FRESH registry entry for `db` up to `timeout-ms`, checking every `poll-ms`. `:on-tick` (optional) is called before each sleep with the elapsed millis so a caller can render live 'still waiting…' feedback; it never breaks the poll loop. Returns the entry or nil on timeout.
(base-argv)Prefix argv that re-launches vis in a fresh process: the native binary as-is,
or java -cp … clojure.main -m com.blockether.vis.core on the JVM (mirrors the
self-restart trick).
Prefix argv that re-launches vis in a fresh process: the native binary as-is, or `java -cp … clojure.main -m com.blockether.vis.core` on the JVM (mirrors the self-restart trick).
(db-target db)Normalize a DB target/spec to the value that identifies a daemon. SQLite specs
use their :path; :memory stays ephemeral; other values pass through.
Normalize a DB target/spec to the value that identifies a daemon. SQLite specs use their `:path`; `:memory` stays ephemeral; other values pass through.
(default-token-file)Stable bearer-token file shared by managed gateway daemons and local clients.
A client can use it to authenticate a loopback /healthz probe and repair a
missing registry without spawning a second daemon onto an occupied port.
Stable bearer-token file shared by managed gateway daemons and local clients. A client can use it to authenticate a loopback `/healthz` probe and repair a missing registry without spawning a second daemon onto an occupied port.
(delete-registry! db)Remove the registry file for db. Never throws. Returns true when a file was
removed.
Remove the registry file for `db`. Never throws. Returns true when a file was removed.
(delete-registry-if! db pred)Atomically remove db's registry only when (pred current-entry) is truthy.
The read + delete share the registry mutation lock, preventing an old daemon
or stale discoverer from deleting a successor's freshly-written ownership.
Atomically remove `db`'s registry only when `(pred current-entry)` is truthy. The read + delete share the registry mutation lock, preventing an old daemon or stale discoverer from deleting a successor's freshly-written ownership.
(deregister-self! db)Called from the daemon's shutdown hook: delete OUR registry entry for db,
but only if it still points at us (never clobber a successor that took over).
Called from the daemon's shutdown hook: delete OUR registry entry for `db`, but only if it still points at us (never clobber a successor that took over).
(discover-or-start! {:keys [db] :as opts}
&
{:keys [probe spawn timeout-ms poll-ms on-event]
:or {probe (constantly true) spawn spawn-detached!}})Resolve a gateway for db. Returns:
{:mode :none} for a :memory DB (Q5, never spawns);{:mode :attach :entry {…}} a fresh daemon already runs — connect;{:mode :spawned :entry {…}} WE spawned one and it self-registered;{:mode :awaited :entry {…}} ANOTHER process was spawning — we waited
on its daemon instead of piling on;{:mode :timeout} nobody came up in time.probe is the port+secret liveness check (defaults to pid-liveness alone).
spawn (default spawn-detached!) and now are injectable for tests.
:on-event (optional) is a side-effecting callback the caller uses to surface
live progress so waiters are NEVER left staring at a frozen screen. It fires
only on the SLOW path (never on a plain :attach) with maps:
{:phase :spawning} WE won the lock and are launching the daemon;
{:phase :awaiting} another vis is starting it — we wait, not spawn;
{:phase :recovering} a live registered owner missed a probe — preserve
it and wait for recovery instead of deleting it;
{:phase :tick :elapsed-ms n} a poll heartbeat while awaiting either of those;
{:phase :ready :mode m :entry e} the daemon came up;
{:phase :timeout} nobody came up in time. It never throws upward.
THUNDERING-HERD GUARD (see acquire-spawn-lock!): when the registry is not
fresh, only the ONE process that wins the cross-process spawn lock actually
launches a daemon; every other concurrent starter finds the lock held, learns
'someone is already spawning', and just awaits the winner's self-registration.
That replaces the old blind-port-bind race (N daemons launched, N-1 crashing)
with a single spawn + N-1 cheap waiters. A registry whose PID is DEAD is
conditionally deleted before spawning; a LIVE PID that merely missed a health
probe is preserved and re-probed. This distinction is critical: deleting a
live owner's only registry makes every new client spawn into its occupied port
and time out forever. The lock is re-checked against a double-read so a daemon
that came up between our read and the lock is attached, not re-spawned.
Resolve a gateway for `db`. Returns:
- `{:mode :none}` for a `:memory` DB (Q5, never spawns);
- `{:mode :attach :entry {…}}` a fresh daemon already runs — connect;
- `{:mode :spawned :entry {…}}` WE spawned one and it self-registered;
- `{:mode :awaited :entry {…}}` ANOTHER process was spawning — we waited
on its daemon instead of piling on;
- `{:mode :timeout}` nobody came up in time.
`probe` is the port+secret liveness check (defaults to pid-liveness alone).
`spawn` (default [[spawn-detached!]]) and `now` are injectable for tests.
`:on-event` (optional) is a side-effecting callback the caller uses to surface
live progress so waiters are NEVER left staring at a frozen screen. It fires
only on the SLOW path (never on a plain `:attach`) with maps:
`{:phase :spawning}` WE won the lock and are launching the daemon;
`{:phase :awaiting}` another vis is starting it — we wait, not spawn;
`{:phase :recovering}` a live registered owner missed a probe — preserve
it and wait for recovery instead of deleting it;
`{:phase :tick :elapsed-ms n}` a poll heartbeat while awaiting either of those;
`{:phase :ready :mode m :entry e}` the daemon came up;
`{:phase :timeout}` nobody came up in time. It never throws upward.
THUNDERING-HERD GUARD (see [[acquire-spawn-lock!]]): when the registry is not
fresh, only the ONE process that wins the cross-process spawn lock actually
launches a daemon; every other concurrent starter finds the lock held, learns
'someone is already spawning', and just awaits the winner's self-registration.
That replaces the old blind-port-bind race (N daemons launched, N-1 crashing)
with a single spawn + N-1 cheap waiters. A registry whose PID is DEAD is
conditionally deleted before spawning; a LIVE PID that merely missed a health
probe is preserved and re-probed. This distinction is critical: deleting a
live owner's only registry makes every new client spawn into its occupied port
and time out forever. The lock is re-checked against a double-read so a daemon
that came up between our read and the lock is attached, not re-spawned.(lock-file db)OS advisory-lock file guarding daemon SPAWN for db. Sits beside the registry
file so it shares the per-DB key; a stale lockfile is harmless (the OS lock,
not the file's existence, is what's held).
OS advisory-lock file guarding daemon SPAWN for `db`. Sits beside the registry file so it shares the per-DB key; a stale lockfile is harmless (the OS lock, not the file's existence, is what's held).
(memory-db? db)True for the ephemeral in-memory DB target — it never spawns/attaches a
daemon (Q5). Accepts raw --db values and resolved DB specs.
True for the ephemeral in-memory DB target — it never spawns/attaches a daemon (Q5). Accepts raw `--db` values and resolved DB specs.
(native-image?)True when running as the compiled GraalVM binary rather than on the JVM.
True when running as the compiled GraalVM binary rather than on the JVM.
(pid-alive-cached? pid)Like pid-alive? but memoized per pid for up to ~5s (PID_LIVENESS_TTL_MS).
Hot refcount/status polling probes the same handful of pids many times a
second, and each pid-alive? is a macOS proc-info syscall — that raw call
rate, not the work, is what pins the gateway CPU. The TTL trades up to ~5s of
liveness staleness (harmless for refcount shutdown, which also has a 30s
startup grace) for removing the ProcessHandle/of syscall storm. A nil pid
is NOT alive.
Like `pid-alive?` but memoized per pid for up to ~5s (`PID_LIVENESS_TTL_MS`). Hot refcount/status polling probes the same handful of pids many times a second, and each `pid-alive?` is a macOS proc-info syscall — that raw call rate, not the work, is what pins the gateway CPU. The TTL trades up to ~5s of liveness staleness (harmless for refcount shutdown, which also has a 30s startup grace) for removing the `ProcessHandle/of` syscall storm. A nil pid is NOT alive.
(pid-alive? pid)Best-effort: is OS process pid still running? A nil pid is NOT alive (an
entry with no pid can't own a live daemon).
Best-effort: is OS process `pid` still running? A nil pid is NOT alive (an entry with no pid can't own a live daemon).
(read-registry db)Read the registry entry for db, or nil when absent/unreadable/garbage.
Read the registry entry for `db`, or nil when absent/unreadable/garbage.
(register-self! db {:keys [port host secret]})Called by the daemon (serve-main!) once it is listening: write this process
as the owner of db. secret should be the gateway bearer token (or any
nonce a client can echo back on registry-fresh?'s probe). Returns the
entry, or nil for a :memory DB (which never registers).
Called by the daemon (`serve-main!`) once it is listening: write this process as the owner of `db`. `secret` should be the gateway bearer token (or any nonce a client can echo back on [[registry-fresh?]]'s probe). Returns the entry, or nil for a `:memory` DB (which never registers).
(registry-dir)Directory holding the per-DB registry files. Not created here.
Directory holding the per-DB registry files. Not created here.
(registry-file db)The registry EDN file for DB target db.
The registry EDN file for DB target `db`.
(registry-fresh? entry)(registry-fresh? {:keys [pid] :as entry} probe)True when entry describes a LIVE daemon: it has a pid, that pid is alive,
AND probe confirms it (defaults to trusting pid-liveness alone). The client
adapter injects a real probe that hits http://host:port with :secret to
defeat pid reuse (Q3).
True when `entry` describes a LIVE daemon: it has a pid, that pid is alive, AND `probe` confirms it (defaults to trusting pid-liveness alone). The client adapter injects a real `probe` that hits `http://host:port` with `:secret` to defeat pid reuse (Q3).
(registry-key db)Stable key for a DB target: sha256 of its canonical path (Q2).
Stable key for a DB target: `sha256` of its canonical path (Q2).
(release-spawn-lock! {:keys [lock channel]})Release a holder from acquire-spawn-lock!. Never throws.
Release a holder from [[acquire-spawn-lock!]]. Never throws.
(spawn-argv {:keys [db port host token-file require-token? base]})Full argv to launch a fresh gateway daemon for db on port. Serve flags
follow the gateway start subcommand. :base lets a caller override the
re-exec prefix (tests do).
Full argv to launch a fresh gateway daemon for `db` on `port`. Serve flags follow the `gateway start` subcommand. `:base` lets a caller override the re-exec prefix (tests do).
(spawn-detached! {:keys [db] :as opts})Fire-and-forget launch of a gateway daemon for db, fully DETACHED so closing
the spawner's terminal (SIGHUP) does NOT kill it (D4). On unix this runs the
argv inside a throwaway shell that backgrounds and disowns it (the daemon is
reparented to init) and renames it to vis gateway start …; elsewhere it
falls back to a plain detached ProcessBuilder. The daemon
SELF-REGISTERS its pid/port on startup, so we never need its pid here. Its
stdout+stderr are captured to a per-DB boot log under the registry dir so a
daemon that dies on startup is diagnosable instead of vanishing. Returns nil.
Fire-and-forget launch of a gateway daemon for `db`, fully DETACHED so closing the spawner's terminal (SIGHUP) does NOT kill it (D4). On unix this runs the argv inside a throwaway shell that backgrounds and disowns it (the daemon is reparented to init) and renames it to `vis gateway start …`; elsewhere it falls back to a plain detached ProcessBuilder. The daemon SELF-REGISTERS its pid/port on startup, so we never need its pid here. Its stdout+stderr are captured to a per-DB boot log under the registry dir so a daemon that dies on startup is diagnosable instead of vanishing. Returns nil.
(unix-launch-cmd argv log-path)Full [shell "-c" script] command that detaches argv with its stdio in
log-path. Preferred form runs under bash so the daemon can exec -a itself
into a readable vis <subcommand> … process line; trap '' HUP before the
exec survives execve, giving the same SIGHUP immunity nohup provides. Falls
back to plain nohup under sh when no bash exists (the process line then
keeps the absolute executable path, which is cosmetic, not functional).
Full `[shell "-c" script]` command that detaches `argv` with its stdio in `log-path`. Preferred form runs under bash so the daemon can `exec -a` itself into a readable `vis <subcommand> …` process line; `trap '' HUP` before the exec survives execve, giving the same SIGHUP immunity `nohup` provides. Falls back to plain `nohup` under `sh` when no bash exists (the process line then keeps the absolute executable path, which is cosmetic, not functional).
(write-registry! db entry)Atomically write entry (a map, :db/:created-at filled in if absent) as
the registry for db. Registry readers therefore never observe a partial EDN
file and mistake a live daemon for a missing one. Returns the written entry.
Atomically write `entry` (a map, `:db`/`:created-at` filled in if absent) as the registry for `db`. Registry readers therefore never observe a partial EDN file and mistake a live daemon for a missing one. Returns the written entry.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |