Avatar for the oxabl-project user
oxabl-project
oxabl
BlogDocsChangelog

Performance History

Latest Results

chore: release master (#175)
master
27 days ago
chore: release master
release-please--branches--master
27 days ago
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
28 days ago
chore: release master
release-please--branches--master
29 days ago
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.
master
29 days ago

Latest Branches

CodSpeed Performance Gauge
0%
chore: release master#175
27 days ago
729a049
release-please--branches--master
CodSpeed Performance Gauge
0%
fix(daemon): close the registry's symlink hole and stop the probe taking the lock it tests#173
28 days ago
032c957
fix/daemon-registry-safety
CodSpeed Performance Gauge
0%
29 days ago
08c21d0
feat/daemon-session-core
© 2026 CodSpeed Technology
Home Terms Privacy Docs