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

Three 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.

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.

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

Per-user vs per-project instances

Two scoping choices:

The instance key determines the socket path:

~/Library/Application Support/lazydap/instance-<hash>/lazydap.sock
~/.cache/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