The Local Daemon Pattern

The Local Daemon Pattern

A specific architectural shape: a per-user (or per-project) daemon that auto-spawns on first CLI invocation, lives between commands to retain expensive state, and dies when no longer needed. The CLI feels stateless; behind the scenes, a daemon makes it stateful.

Mxr uses it. Lazydap uses it. Many modern dev tools use variants: git's gitstatusd, cargo's build cache, IDEs' language servers, Docker Desktop. The pattern has no universally-agreed name; "local daemon," "auto-spawning daemon," "user-level daemon" are all close.

The shape

First invocation:
  $ mxr search "from:alice"
       │
       ├─ probe socket: not found
       ├─ fork daemon: detached, drops PID file, binds socket
       ├─ wait for socket to appear (poll up to 2s)
       ├─ connect to socket
       └─ send Search request, get response, exit

Second invocation (microseconds later):
  $ mxr cat <message-id>
       │
       ├─ probe socket: found, daemon is up
       ├─ connect to socket
       └─ send Cat request, get response, exit

Daemon lifecycle:
  ├─ stays alive across CLI invocations
  ├─ holds expensive state (caches, open adapters, sync loops)
  ├─ optionally self-shutdowns after N minutes idle
  └─ explicitly killable via `mxr shutdown` or signal

The CLI is short-lived. The daemon is long-lived. The user thinks they're running CLI commands; they're actually opening sockets to a background process.

Why this shape

Four problems it solves simultaneously:

1. Cold-start latency

A naive CLI re-loads everything per invocation: open SQLite, parse config, spin up async runtime, connect to providers, do work, exit. Easily 200ms per command before any actual work.

A daemon already has all that loaded. CLI invocations are 5–20ms total — connect, request, response. Subjectively instant.

2. Persistent connections / handles

Some operations require a long-lived connection (IMAP IDLE for push email, DAP adapter sessions, file watchers). A short-lived CLI can't hold these. A daemon can.

3. Multiple concurrent clients

Once the daemon exists, N clients can connect to the same instance. The TUI runs alongside the CLI, both consume the same state, both see the same updates in real time. Without a daemon, you'd need the TUI to be the canonical state-holder, which couples it to the protocol forever.

4. Warm state without a foreground UI

The daemon can keep useful state warm after the visible client exits. That is the part a GUI-held architecture cannot give you: close the TUI and music state, indexes, caches, and provider handles can still live in the background process.

The catch is priority. Warm work must not make the next foreground command slower. A good daemon warms reusable truth, not screens, and keeps that warm path bounded. See Warm Reusable Truth, Not Screens and Hot Path vs Warm Path.

The auto-spawn dance

Done well, it's invisible. Done poorly, it leaks.

Done well:

async fn ensure_daemon_running() -> Result<()> {
    let socket = socket_path()?;
    if probe_daemon(&socket).await.is_ok() {
        return Ok(());          // daemon already up
    }
    // Spawn daemon: re-exec ourselves with `daemon --foreground` flag,
    // detached, with file descriptors closed.
    fork_daemon().await?;
    // Poll for the socket to appear, with backoff.
    for attempt in 0..20 {
        tokio::time::sleepfrom_millis(100 * (attempt + 1)).await;
        if probe_daemon(&socket).await.is_ok() {
            return Ok(());
        }
    }
    Err("daemon failed to start within 2s")
}

The fork: spawn the same binary with a daemon --foreground (or implicit) subcommand, in a detached child process. On macOS / Linux the standard incantation involves setsid to break free of the parent's session.

If a CLI is often driven by agents or test harnesses, process detachment matters even more. The harness may reap its process group after the command exits. A daemon that is meant to survive the CLI must detach from the parent process group/session, not merely spawn a child and hope the parent leaves it alone.

The wait loop has one more trap: readiness is not liveness. A child process can
exist while its socket is still missing because it is doing legitimate
initialization. Spotuify hit this when embedded player registration happened
before IPC bind; the launcher timed out even though the daemon was still
starting. Either bind a small status endpoint early, or make the launcher wait
long enough for the slowest pre-bind path. See [[Daemon Readiness Is Not Process
Liveness]].

PID Files track the daemon. Signal Handling manages its lifecycle.

Per-user vs per-project instances

Two scoping choices:

There is a third axis once the maintainer is also a real user: per-runtime-mode. Mxr now treats installed, development, and demo runs as separate runtime identities:

release mxr -> mxr
cargo run   -> mxr-dev
mxr demo    -> mxr-demo

The identity determines more than the socket. It scopes config, data, search indexes, PID files, bridge files, token roots, and non-production credential refs. See Runtime Identity Is a Data Boundary.

The instance key determines the socket path:

~/Library/Application Support/lazydap/instance-<hash>/lazydap.sock
~/Library/Application Support/mxr/mxr.sock
$XDG_RUNTIME_DIR/mxr/mxr.sock

Where <hash> is derived from the project root (and optionally an env override like LAZYDAP_INSTANCE).

Idle shutdown (optional)

A daemon that runs forever wastes RAM if the user moved on hours ago. Pattern: shut down after N minutes of no client activity. Restart on the next invocation.

Trade-off: the next invocation pays cold-start cost again. Default in mxr/lazydap is "don't auto-shutdown" — RAM is cheap, latency isn't. Configurable for users who care.

Crash recovery

The daemon will crash. Network glitch hits an unhandled future, a panic propagates, the OOM killer arrives. Robust client behaviour:

  1. Probe socket. Fails? Maybe daemon died.
  2. Read PID file. PID is dead? File is stale, daemon definitely gone.
  3. Fork a new daemon.
  4. Resume work.

Persistent state must survive in durable storage (SQLite for mxr, TOML for lazydap). Live session state can vanish — the new daemon doesn't need to know about the old session, because the user's CLI invocation is fresh anyway.

Why not systemd / launchd?

Auto-spawning client-side bypasses init systems entirely. The user doesn't have to:

Trade-off: the daemon can't restart on system boot. For tools where the cost of cold-start on first-of-day is acceptable, this is fine. For tools that need to be running before the user logs in (sshd) it isn't.

mxr and lazydap fit "acceptable cold-start." Init-managed daemons are a different shape.

Comparison to other patterns

The local-daemon pattern is a sweet spot for local-first developer tools: per-invocation latency is good, cold-start is cheap, state is preserved across commands, no cloud, no admin setup.

Real-world examples

What sets the pattern apart

The key invariants of "local daemon pattern" specifically:

  1. CLI is the primary interface. The daemon exists to make the CLI fast/stateful, not to be a server.
  2. Auto-spawn is invisible. Users don't manage the daemon's lifecycle.
  3. Multiple clients OK. TUI, agent skill, scripts — all peers, none privileged.
  4. Local IPC. Unix socket (or named pipe on Windows). Not TCP, not HTTP — those imply network and they're not the use case.
  5. Persistence is durable. Daemon state can be lost; what matters survives in SQLite/TOML/etc.

When all five hold, you have the pattern.

See also