Skip to content

feat: the daemon session core, and the wire a client can afford - #165

Open
evanbrobertson wants to merge 9 commits into
feat/reverse-dependency-edgesfrom
feat/daemon-session-core
Open

feat: the daemon session core, and the wire a client can afford#165
evanbrobertson wants to merge 9 commits into
feat/reverse-dependency-edgesfrom
feat/daemon-session-core

Conversation

@evanbrobertson

@evanbrobertson evanbrobertson commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Works #161. Works #162. Works #163. Implements the Phase B daemon/session layer for the impact slice and is stacked on #164.

What lands

  • A lightweight typed protocol crate with contract-version checks, honest sourced values, provenance, freshness, grouped impact answers, symbol search, and explicit reindex.
  • A shared per-workspace daemon session core with cancellation re-arming, panic containment, buffer supersession, and one index shared by editor and desktop clients.
  • Unix-socket registration, discovery, launch through the hidden oxabl daemon command, clean multi-client teardown, and stale-registration replacement.
  • oxabl/impact, oxabl/symbolSearch, oxabl/freshness, and oxabl/reindex, including live workspace progress and graph-size reporting.
  • An oxabl_lsp stdio shim that joins or starts the daemon and no longer names the incremental engine.
  • Cross-client parity coverage through a real daemon session.

Verification

cargo check --workspace, cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, and cargo fmt --all -- --check pass. All nine multi-client acceptance tests are active and pass.

The fixed-workspace benchmark indexed 10,482 files with 8 unanalysed and a 0.0061% unresolved ratio. The graph pass took 367.6 s and owns an estimated 72,574,328 heap bytes. The serial symbol-search-plus-impact path measured 218.58 ms p95; daemon impact handling measured 14 ms p95. Cold start misses the 60 s target and is isolated in #170 rather than widening this PR.

The desktop app has to name a request type. It must not pull salsa, the pipeline,
and the whole analysis stack in to do so, so the wire lives in its own crate that
depends on serde and nothing else. It owns the `oxabl/*` method names, the request
and response shapes, the contract version, and the per-root registration a client
reads to find a running daemon.

Two shapes carry the honesty rules into the type system rather than into a
convention:

`Sourced<T>` wraps a value the daemon may not be able to source — the
compile-time estimate has no source until a build daemon exists. Deliberately not
`Option<T>`: an `Option` invites `unwrap_or_default`, and for every slot this
wraps a default is a zero that claims something false. It carries the reason
instead and offers no default at all.

An impact response states its own provenance and freshness, and reports the
rebuild set as a distinct field from the grouped dependents. Unresolved references
are their own collection, out of every group and out of the reference count. A
contract mismatch names both versions, because one message has to be enough to
diagnose it.

Unknown methods classify to an explicit case rather than failing the message, so a
daemon meeting a newer client's method answers for that request only.

Absent from the release configuration, like the other client-layer crates.
Two clients on one workspace meant two indexes. The language server held the
salsa instance, its memoized facts, and the disciplines that decide whether a
completed background computation is still worth publishing — so a second client
on the same tree would parse all of it again and hold all of it again.

The substrate moves to `oxabl_daemon` unchanged: the per-buffer inputs, the
per-file inputs a lookup has reached, the two-phase expansion and diagnostics
queries, and the `WorkspaceIndex` implementation that memoizes the shared seam.
It names no LSP type, so it moved without translation, and the language server's
existing lifecycle, cancellation, panic-containment, cross-file invalidation and
parity suites pass unchanged — which is the only evidence that matters for a
behaviour-preserving extraction.

The load-bearing part is the four disciplines, now `dispose`: a returned decision
rather than control flow inside one client's result handler. A completed
computation can come back irrelevant four ways and three of them look identical
from the outside — a superseded buffer version, a superseded configuration
generation, a cancellation, and a genuine panic. The first drops, the middle two
re-arm, and the last fails one request and is never retried. That rule threads
through every gate on purpose: a panic landing under a superseded generation must
not collect the one retry it exists to prevent. Two copies of this reasoning would
drift, and drifting means publishing a stale answer.

A session is one workspace root's state, and sessions live in a map — the server
reads only the first workspace folder today and its own comment says nothing in it
can hold two, so multi-root is a question of how many sessions exist rather than
how much one holds. Two spellings of one root normalise to one session, because
the alternative indexes the workspace twice.

Routing becomes one dispatch table, so an LSP method and an `oxabl/*` method are
the same kind of thing and share one wrapper that contains a panic and reports it
as that request's failure. `salsa::Cancelled` must never reach that wrapper, which
is why the queries keep catching their own. The message loop is transport-agnostic
so sockets are a second caller rather than a second loop.

`oxabl_lsp` stays working and standalone, reaching the moved substrate and calling
the moved disciplines. Stripping it to a shim is separate.
A client has to find a running daemon for a workspace root, or start one, and a
crashed daemon must not leave a client waiting on a socket nobody holds. Discovery
is a registration file per root under the XDG cache directory, recording pid,
socket path, and contract version. A registration whose pid is not alive is
treated as absent and replaced — liveness is checked rather than the socket
tried, because a stale socket can accept a connection that is then never answered
and no timeout is right for both a cold start and a busy pass. Writes go through a
rename, so a concurrent reader sees the old registration or the new one and never
a half-written file.

The daemon owns its accept loop because it has to: the transport crate's listen
helper binds and accepts exactly once, and its socket transport is crate-private
and TCP-only. What is public is the part that matters — message read and write,
and the connection's channel fields — so the framing and the request ids are
reused and only the socket type differs. A thread per client, over a Unix socket,
where filesystem permissions do the access control.

A second daemon on one root is refused rather than allowed to steal the socket: a
socket file whose owner is alive belongs to that daemon, and unlinking it would
leave the running one unreachable while this one served a second index over the
same workspace — the exact duplication the daemon exists to prevent.

Handlers now receive a session host rather than a mutable session map, which puts
the locking rule where it can be read: take the lock to write or to clone a
snapshot, then release it before querying. A handler that queries under the lock
serialises the whole daemon, and that is the difference between one client's slow
answer costing that client and costing everyone.

**Unfinished, and the tests say so.** The per-client framing threads and the accept
loop do not tear down cleanly, so `tests/multi_client.rs` blocks rather than
failing. Every test there is a U7 acceptance scenario and all nine are `#[ignore]`d
with that reason, because a hang stalls CI indefinitely instead of reporting.
Discovery is fully covered and green. The launchable `oxabl daemon` artifact is not
wired yet either.
@evanbrobertson
evanbrobertson marked this pull request as ready for review August 4, 2026 20:37
@evanbrobertson
evanbrobertson force-pushed the feat/daemon-session-core branch from f9d503b to b37dea2 Compare August 4, 2026 20:37
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