ADR 0020: Server as Self-Managing Daemon with Embedded Watch
Status
Accepted
Context
The project previously had two independent background-process subsystems:
idx daemon enable/disable/statusβ spawned a separateidx watchprocess per project via aDaemonServicethat maintained a multi-project state file (~/.idx/daemon.state).idx serverβ ran a JSON-RPC listener in the foreground with no lifecycle management.
This design forced users to manage two distinct processes and remember two separate command groups. It also created a dependency injection cycle: DaemonService needed InitCommandService (to call init before watching), and InitCommandService needed ProjectMonitorChecker (implemented by DaemonService), requiring a SetInitCommand setter to break the cycle at runtime.
Decision
Collapse both subsystems into a single self-managing daemon per project: idx server run β a hidden process that runs the JSON-RPC listener and the file-watch loop concurrently under a shared cancellation context.
New CLI surface
| Command | Behaviour |
|---|---|
idx server start |
Spawns idx server run in background, saves PID state |
idx server stop |
Sends SIGTERM to the process, removes state file |
idx server status |
Prints PID, uptime, and socket path |
idx server run |
Hidden β the real daemon process (server + watch) |
idx watch |
Retained β manual foreground mode for dev/debug |
idx daemon enable/disable/status is removed entirely.
Process routing in main.go
isServerCommand returns true only for idx server run, routing it to runServer(). All other subcommands (start, stop, status) are client-side and route through runClient().
Concurrency model
newServerRunCommand uses errgroup to run two goroutines under a signal.NotifyContext (SIGINT/SIGTERM):
errg.Go(func() { return runner.indexServer.Serve(ctx) })
errg.Go(func() { return runner.indexCommand.WatchWithContext(ctx, debounce) })
When either exits (signal received or fatal error), the context is cancelled and both goroutines shut down cleanly.
DI cycle elimination
ServerDaemonService.Start no longer calls Init. The embedded watch loop calls ensureRootIndex automatically on the first event, making the explicit init call redundant. This removes the bidirectional dependency: only InitCommandService β ServerDaemonService (as ProjectMonitorChecker) remains.
State persistence
Per-project state is stored at ~/.idx/<sanitized-project-name>.server.state (JSON), containing:
{
"pid": 12345,
"started_at": "2026-05-27T10:00:00Z",
"socket": "/home/user/.idx/myproject.sock",
"project_path": "/home/user/myproject"
}
Orphan detection: on Start, if a state file exists but the PID is no longer alive, the stale state is removed and a fresh daemon is spawned.
Watch context propagation
consumeWatchEvents gained a context.Context parameter with a case <-ctx.Done(): return nil branch, ensuring the watch goroutine exits when the server shuts down rather than blocking on the watcher.Events channel indefinitely.
Consequences
Positive:
- Single command group for server lifecycle; simpler mental model for users.
- One process per project instead of two, reducing resource overhead.
- DI cycle eliminated; no runtime setter (
SetInitCommand) needed. idx searchand other client commands work unchanged β they still dial the Unix socket.idx destroystops the server daemon before removing indices viastopServerForDestroy.
Negative:
idx watchno longer starts a persistent background daemon; users who relied onidx daemon enable .must migrate toidx server start.- The hidden
idx server runsubcommand must not be called directly by users; it is an implementation detail of the daemon lifecycle.
Related ADRs
- ADR 0006: Original daemon management decision (superseded by this ADR).
- ADR 0019: IPC via JSON-RPC 2.0 over Unix socket (unchanged; server implementation is the same).
- ADR 0005: Real-time watch mode (unchanged;
WatchWithContextreuses the existing watch loop).