Skip to content

feat(daemon): serve eidetica through a local socket - #59

Draft
arcuru-bot wants to merge 10 commits into
arcuru:mainfrom
arcuru-bot:feat/eidetica-service-socket
Draft

feat(daemon): serve eidetica through a local socket#59
arcuru-bot wants to merge 10 commits into
arcuru:mainfrom
arcuru-bot:feat/eidetica-service-socket

Conversation

@arcuru-bot

Copy link
Copy Markdown
Contributor

Summary

  • let chaz daemon optionally serve its embedded Eidetica instance on a per-state-directory Unix socket
  • enforce lifetime claims for both the state directory and socket path before opening the backend, and fail startup unless the socket is live with owner-only permissions
  • add race-safe client auto-start/connect machinery with bounded readiness, stale-socket handling, relative-path support, and no direct-open fallback
  • document configuration, authorization boundaries, startup contention, and crash recovery

Scope

This delivers the daemon-side foundation and client connection machinery. Service mode remains disabled by default, and usage, cmd, TUI, and --print continue to open the embedded backend until follow-up migrations connect them through the daemon.

The Eidetica dependency remains at the current 5d9a657c pin and explicitly enables its service feature.

Testing

  • CI=1 nix develop .# -c just nix full
    • lint, treefmt, test, doc, and build checks completed successfully
    • test artifact: 1026 + 11 + 19 tests passed; 0 failed; 2 ignored doc tests
  • nix develop .# -c just e2e
    • cold boot, copied-state recovery, bridge/daemon restarts, group routing and allow-list rejection, and ReAct tool-call cycle passed
  • live daemon smoke test
    • socket mode 0600, parent mode 0700
    • a second daemon refused before opening the backend
    • SIGTERM exited cleanly and removed the socket

An embedded SQLite backend belongs to one process. Two chaz processes on
one state directory do not observe each other's writes and contend for
the write lock, which is why `chaz cmd` is documented as needing the
daemon stopped and why a long interactive run collects "database is
locked" holes in its transcript.

Eidetica already answers this: one process owns the backend and serves it
over a unix socket, and everything else connects as a client. Take the
first step of that — with `service.enabled`, the daemon additionally runs
a ServiceServer on `<state_dir>/eidetica.sock`, serving the very Instance
it runs on. The frontends still open the database directly, so this is
off by default and inert when on; migrating them is separate.

The socket sits beside the database rather than at eidetica's per-user
default path, because one user runs several peers — a daemon and its
transport bridges — and each owns a different backend.

A daemon that finds a live socket at that path refuses to start, before
it opens the backend at all. Eidetica's server unlinks any socket it
finds, so without the check a second daemon would take the socket from
the first and both would end up holding the database — the failure the
socket exists to prevent. The check belongs upstream; it lives here until
it gets there.
The sole-opener rule leaves one question — what a frontend does when it
finds no daemon running — and "open the database itself" is the wrong
answer, because it is the two-openers topology under another name. The
right one is to start a daemon and connect to that, so the rule holds
even on a cold state directory.

Build that machinery, with the interlocks it needs and nothing consuming
it yet; the frontends migrate one at a time after this.

Two claims with two lifetimes, both flock so the kernel releases them
when the holder dies. The daemon claim says "I am the daemon for this
state directory" and is held for the daemon's life: a second daemon fails
to take it and refuses to start before it opens anything, which is the
sole-opener rule's actual enforcement rather than a look-then-act check
two daemons can both pass. The start claim says "I am the one starting a
daemon" and is held only until that daemon serves: it is what makes eight
frontends racing on a cold state directory produce one daemon instead of
eight, with the losers waiting on the winner's socket.

Every failure is an error to the caller — a start that failed, a daemon
that never accepted connections inside the readiness bound, a claim that
could not be taken. None of them falls back to opening the database,
which is structural here rather than careful: this module has no way to
open a backend at all.
The daemon logged "eidetica service socket serving" and entered the
shutdown wait immediately after spawning the service server, so a server
that failed to bind (e.g. an unbindable socket path) left the daemon
advertising a socket nobody could reach.

Wait for the socket to actually accept a connection before logging ready:
the spawned task races a bounded connect-based probe. A task that exits
first fails startup with its error; a readiness timeout stops the server
and fails startup too. Once ready, the task and its shutdown sender are
returned for the existing clean-shutdown path. Regression test uses a
Unix socket path past the platform limit, which cannot bind.
ServiceServer binds before chmodding the socket 0600, so a connect-based
probe could report the daemon ready in the gap before a failing chmod
kills the server and the task finishes. Readiness now requires both a
successful connect and 0600 permission bits — the server's last fallible
startup step — which proves it reached the accept loop.

Race the server task against a bounded readiness future with a single
tokio::select!, biased so a finished task wins a tie. On timeout the sole
stop sender is dropped and the task awaited so the socket file is cleaned
up before startup fails.
Two daemons with different state directories but the same custom service.path each passed the liveness probe before either bound, then eidetica's ServiceServer unlinked and stole the other's socket. Add an advisory flock on a file beside the socket (append .lock) taken before the backend opens and held for the daemon's life, so the socket path is itself the arbitration — exactly one daemon may serve a given path.
The claim file lives beside the socket, so taking the claim now creates the socket parent at owner-only 0700 first, mirroring what eidetica's ServiceServer does before binding. A custom service.path inside a not-yet-existing dedicated directory otherwise fails on the lock file before the server ever gets to create the parent.
AutoStart::connect built the unix:// URL from the socket path as
given, but eidetica's URL parser requires an absolute path. A relative
state_dir or service.path - or the bare default when no state dir
resolves - produced a URL the parser rejected. Resolve the path against
the working directory for the connection URL only; the stored path,
lock paths, and readiness probes are untouched.
The connect_reaches_a_relative_service_socket_without_spawning test
changed the process working directory while cargo test runs the suite on
parallel threads, and its mutex only serialized this one test — unrelated
tests could resolve relative paths against the temporary directory. The
absolute and relative resolve_socket_url_path unit tests pin the same
URL-boundary behavior without touching global state, so the integration
test, its cwd guard, and the lock go.
@arcuru-bot

Copy link
Copy Markdown
Contributor Author

Patrick's review concluded that generic socket ownership, readiness, security, and lifecycle belong in Eidetica. That upstream work should land first; then this PR should be reduced and rebased to cover only Chaz-specific path, configuration, and runtime policy. The currently unused client autostart machinery should move out of this PR into the frontend-cutover layer. This PR should not merge in the meantime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant