feat(daemon): serve eidetica through a local socket - #59
Draft
arcuru-bot wants to merge 10 commits into
Draft
Conversation
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
marked this pull request as draft
September 1, 2026 03:52
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
chaz daemonoptionally serve its embedded Eidetica instance on a per-state-directory Unix socketScope
This delivers the daemon-side foundation and client connection machinery. Service mode remains disabled by default, and
usage,cmd, TUI, and--printcontinue to open the embedded backend until follow-up migrations connect them through the daemon.The Eidetica dependency remains at the current
5d9a657cpin and explicitly enables itsservicefeature.Testing
CI=1 nix develop .# -c just nix fullnix develop .# -c just e2e0600, parent mode0700