Latest Results
fix(daemon): let discovery say it does not know, and stop trusting a stale body
Review of the three preceding commits found one regression they introduced and
one abstraction they were missing.
The regression: an answering socket was paired with whatever registration had
just been read, with nothing checking that the answering process wrote it. A
SIGKILLed daemon leaves a file naming a dead pid and an older contract version;
its replacement binds the socket before it registers; a poll landing in that
window reported Running with the old body, which a client turns into a contract
mismatch and exits on -- a live daemon of the right version killing the editor's
language server. The pre-existing discover required lock-held AND pid-alive, so
this case used to be absent. A registration whose writer is gone is now never
Running, whatever the socket says: it is a start in progress, and the client
polls until the fresh registration lands.
The missing abstraction: Discovery had no way to say "I do not know," so two
opposite collapses shared one cause. A read failure that was not NotFound became
absent, and absent is what starts a rival daemon -- contradicting the connect
path, which already carved descriptor exhaustion out as undecided for exactly
that reason. In the other direction an unprobeable socket became live, sending
the client into a connect that fails while no daemon is ever started. Both now
report Undecided with the reason, and the client keeps polling instead of
committing.
Undecided also stopped sharing a boolean with the delete decision. Generosity is
right there -- do not unlink when unsure -- and wrong in a refusal that told the
user something was already listening on a path that may not exist, because the
probe had failed before any connect. Bind now matches the liveness three ways
and names the errno.
The accept loop used thread::spawn, which panics when a thread cannot be created.
Probes are accepted connections now and a client polls every 20ms, so under a
container pids.max that panic unwound the loop, Drop cleared socket, registration
and lock, and every client started a new daemon into the same traffic. One
unserveable client is dropped and the loop continues.
Refusals on the read path printed once per poll -- about a hundred near-duplicate
lines in two seconds -- while the error the user finally saw named a two-second
timeout for what was a permissions problem. Each distinct refusal now prints once
per process and the give-up names the cause.
Leaf verification was conditional on the directory already existing, so a freshly
created one got only the parent-grade check. Under a umask that strips owner
execute, mkdirat(0o700) yields 0600 -- untraversable -- which was accepted on the
creating run and refused forever after, surfacing as a bare EACCES from the lock
file rather than the named message the doc promises. The strict check is now
unconditional.
Four doc claims the code did not support are corrected: group-write is an
accepted residual, not a refusal, because st_gid cannot tell a private group from
a shared one; the prefix is created by path before any descriptor exists; a
wedged daemon reads as answering until its backlog fills, not as saturated; and
the staging sweep's own-pid arm depends on register being reached under the root
lock, which pub callers must hold.
Eight tests added, including deterministic coverage for both bind refusal arms,
which were previously reachable only by racing.fix/daemon-registry-safety feat: the daemon session core, and the wire a client can afford (#165)
* feat(daemon-protocol): define the wire in a crate the client can afford
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.
* refactor(daemon): make the session core its own crate
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.
* feat(daemon): discovery and a socket listener the daemon owns
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.
* feat(daemon): launch and share sessions over Unix sockets
* feat(daemon): serve impact and workspace queries
* feat(lsp): route editor sessions through daemon
* test(daemon): add cross-client parity leg
* feat(daemon): report live workspace progress
* feat(daemon): report reverse graph size
* chore(daemon): align the new crate's dependency versions with the workspace
The daemon crates were written before the release that took most of the
workspace to 1.0, and their manifests were added whole, so no merge ever
reconciled them. Cargo rejected the workspace outright once the two met.
Ten path dependencies now name the versions the crates actually carry. The
whole workspace was swept rather than the one line the error named, since
a manifest added wholesale is exactly the shape a merge cannot check.
* fix(daemon): keep a registration name inside the socket path limit
A workspace root is flattened into one file name so a human debugging a
stale registration can read which workspace it belongs to. Nothing bounded
the result. `sun_path` is 108 bytes on Linux and a filename is capped at
255, so a nested project root overflowed both — surfacing from `bind` as a
bare `ENAMETOOLONG` that named neither the limit nor the path.
The readable name is kept whenever it fits, so every existing path is
unchanged and the common case still reads. A name that does not fit keeps
its head and gains a hash of the whole root, so two deep roots sharing a
long prefix cannot collide.
The hash is a vendored FNV-1a rather than anything the toolchain supplies.
That answers the objection the original comment raised instead of dropping
it: a registration name that moved between builds would orphan every
running daemon, and neither `std` nor `rustc-hash` promises otherwise.
FNV-1a is a fixed offset basis and prime, pinned here against its
published vectors.
A directory too long for any name is now reported at bind time with the
limit and the path. `registration_path` stays infallible: threading a
`Result` through four callers to serve a case that needs a 200-byte cache
directory is the wrong trade.
The socket name is also built by explicit concatenation. `with_extension`
was correct only because the registration always ends in `.json`, and a
flattened root carries dots of its own — an invariant spread across three
functions and stated by none.
* fix(daemon): apply the access control the registration claims
Three doc comments said filesystem permissions do the access control.
Nothing implemented it. The directory, the registration, and the socket
were all created under the ambient umask — typically a 0755 directory, a
0644 registration naming the workspace root and socket path, and a 0755
socket whose write bit is the connect permission on Linux. On a shared
machine any local user could read the registration and drive
`oxabl/impact`, `oxabl/symbolSearch`, and `oxabl/reindex`: workspace and
symbol enumeration, plus a repeated whole-workspace pass as a local
denial of service.
The leaf directory is now created 0700 and repaired if an earlier build
left it looser, which `create_dir_all` alone will not do. The socket binds
inside it, so it is unreachable from the moment it exists rather than from
the moment a chmod lands. The registration opens at 0600 and the socket is
chmodded too, as the backstop that survives the directory being loosened
from outside. One helper does the creating, because two call sites
spelling the intent twice is how one drifts.
The staging file also gains the writer's pid. `rename` is atomic but the
write into the staging file is not, so a shared staging path let one
writer publish a body another was still writing — the torn read the
rename exists to prevent. That pairs with opening it `create_new`, which
is only safe once the name is per-writer.
With neither XDG_CACHE_HOME nor HOME set the directory lands under the
system temp directory, where another user can create it first and have it
adopted, symlink included. That branch now verifies ownership and mode and
refuses rather than repairs: a directory in a shared parent that we do not
own is not ours to fix.
The unit of access is a uid. Two humans on one account share one daemon,
which is the trust boundary every other file in that directory already has.
* fix(daemon): admit exactly one daemon per root, and prove it is alive
Two findings, one mechanism.
Startup had a check with no claim. `bind` ran discovery, unlinked a stale
socket path, then bound — and unlinking is precisely what makes
`EADDRINUSE` impossible, so two daemons that both saw `Absent` both bound.
The `register` doc asserted the bind settled that race. It never could.
The worst outcome was not the duplication. The loser's cleanup runs
unconditionally, so when the orphan exited it deleted the *winner's*
registration and socket. The winner stayed alive and unreachable, the next
client saw nothing registered, and started a third. A startup race became
a persistent fault.
Liveness had the same shape. A registration was trusted when some process
carried its pid, which stopped meaning "this daemon" the moment the number
could be recycled — and a client that believed it connected to a socket
nobody held and waited, the one failure the module opens by naming.
An advisory lock on the root answers both. It is taken before anything
observable is touched, so no rival can be between the unlink and the bind,
and it is held for exactly the listener's lifetime. The kernel releases it
when the holder dies by any means, including SIGKILL where no cleanup
runs, so liveness becomes something the kernel maintains and a recycled
pid cannot resurrect a dead daemon.
The pid check stays, as the secondary signal. `flock` is unreliable on
older NFS servers and a home directory on NFS is plausible; where the lock
cannot be trusted this degrades to the previous behaviour rather than to
something worse. A registration is live only when both agree.
The lock file is never unlinked. Deleting it would let two daemons lock
two inodes for one root, which is the race it exists to prevent.
Every registry test wrote a registration and expected it live, which under
the new rule it is not — a registration without the lock is exactly the
recycled-pid case. They now take the lock through `register_locked`, which
is the pairing a real daemon has.
* test(daemon): restore the cancellation tripwires and the cycle gate
Moving the substrate into this crate left its tests behind. Three
threading tests and the editor-cycle benchmark were deleted, and no CI
step noticed: CodSpeed discovers bench targets, so the interactivity gate
did not fail, it stopped existing — at the commit that adds a routing hop.
The tests are the larger loss, and one of them matters more than the rest.
`a_cancellation_while_indexing_stays_a_cancellation` asserts that a
cancellation landing during a cross-file index read arrives as an absent
answer rather than a contained panic. That is precisely the tripwire for a
guard that swallows cancellation — deleted in the same stack that widened
a guard over the edge builder. `snapshot_read_cancelled_mid_flight_yields_none`
was uncovered too: nothing else provokes a deterministic cancellation, and
the panic-containment suite covers the opposite direction and cannot tell
a swallowed cancellation from a correct answer.
All three port with one import line changed and are deterministic over
fifteen consecutive runs.
The benchmark returns as `daemon_cycle_bench`, measuring the same two
gates over the same fixtures — which is what makes it a defensible
re-baseline rather than a new number. It gains a group for this crate's
own new cost: a request now crosses a dispatch table and serde before
reaching a handler, and nothing measured that. Timed in process, because
the socket round trip is Unix-only and its variance would swamp the
signal. The routing hop measures around half a microsecond against a
three-millisecond cycle.
* perf(daemon): park a waiting client instead of polling for the pass
A client whose request arrived while a whole-workspace pass was running
waited by sleeping ten milliseconds and re-checking. At the cold pass this
crate reports — some six minutes over ten thousand files — that is a
thread waking about thirty-six thousand times, and every wake took the
session lock. The lock the running pass needs to install its result. The
waiting contended with the work it was waiting for.
Waiters now block on a completion signal carried by the progress slot
itself, so they hold no lock at all while parked. The surrounding loop
stays: a spurious wake and a pass that installed nothing because the
buffers moved under it both still need a re-check. The signal fires from
one place covering all three outcomes — installed, discarded as stale, and
failed — because a waiter not woken on the failure path hangs, which is
worse than the polling this replaces. A bounded wait backs that up, so a
missed wake could only ever cost a slow re-check.
`freshness` also raced to start a pass. It read the state and decided to
spawn in two steps, so a second call arriving in between read "nothing
running" too and spawned again — and the loser did no useful work, existing
only to wait for a result it discarded. Claiming is now part of the same
critical section as the read, and the claim is what the spawned thread
carries. Starting a pass on first query stays; racing to start it does not.
Both claim sites run the pass through one function, so the signal has one
call site rather than one per caller.
The duplicate-pass guard was already correct and is unchanged: the check
and the claim were always inside one critical section.
The three `expect`s on this path are gone with it. Two disappear by
construction — the checked snapshot now travels out of the critical
section that checked it, instead of being re-read under a second lock and
asserted — and the third reaches its session the ordinary way. All three
held today, but by the absence of eviction code rather than by any type or
lock, and a `MethodError` costs nothing.
Note on coverage: these are tested at the mechanism rather than through a
slow pass. `build_workspace` constructs its filesystem inline, so parking
a real pass would need an injection seam larger than the fix.
* feat(daemon): report the edges the pass could not name
The pipeline now collects edges it resolved but could not map to a path,
because the file id came from an index it does not own. The daemon was
still reporting only the unresolved-reference ratio, so that gap reached
no client.
`Freshness` carries the count beside the ratio rather than folded into it.
The two are different gaps — an unresolved reference is a name the
workspace failed to supply, while these resolved and only lost their path
— and one number could not say which had happened. The contract version
bumps with the shape change, as the rule on it requires.
Also reconciles the note on `build_edge_set`. It says the builder must not
be guarded, and a guard now spans it from the pipeline; that is consistent
rather than an exception, because the guard re-raises what it cannot
describe and a cancellation still reaches the layer that understands it.
The rule is about what a guard may swallow, not about what it may enclose. Latest Branches
0%
release-please--branches--master 0%
fix/daemon-registry-safety 0%
© 2026 CodSpeed Technology