fix(serve): serve stops its daemon (F2); BIOROUTER_SERVE_UI validated like --web-dir (F8); serve doc fixes (F10) - #226
Open
Broccolito wants to merge 3 commits into
Open
fix(serve): serve stops its daemon (F2); BIOROUTER_SERVE_UI validated like --web-dir (F8); serve doc fixes (F10)#226Broccolito wants to merge 3 commits into
Broccolito wants to merge 3 commits into
Conversation
`biorouter serve` never killed the daemon it started, despite a comment
saying it did: the `Child` had been moved into the `spawn_blocking` wait, so
the Ctrl-C arm held no handle. The only thing that ever stopped the daemon
was a terminal's Ctrl-C, which reaches the whole foreground process group.
Measured by the 2026-09-10 QA run (F2) and reproduced here by the new test
against the old code:
SIGTERM serve exited in 103 ms; the daemon kept the port, still accepted
the launch token and still served the shell carrying its secret
SIGINT serve printed "Stopping." and then hung: the runtime waited
forever on the blocking task still waiting for the daemon
SIGKILL the daemon was still running 30 s later
The daemon now cannot outlive serve, in two layers:
1. serve keeps a tokio `Child`, installs SIGINT/SIGTERM listeners BEFORE the
spawn (so a signal during the 60 s readiness wait is held, not fatal), and
sends every exit path through `stop_daemon`: SIGTERM, a 10 s grace, then
SIGKILL and reap. A second Ctrl-C skips the grace. `kill_on_drop` backs up
an unwinding panic.
2. On Unix serve starts `biorouterd agent --exit-with-parent <its pid>`, an
opt-in flag the desktop never passes. The daemon polls getppid() against
that pid, not against 1: an orphan is re-parented to the nearest
subreaper (systemd --user, a container init), so `== 1` never fires on
most Linux desktops. When it fires, the daemon shuts down gracefully and
exits regardless 10 s later, since nobody is left to escalate.
With a browser tab open the renderer always has a 25 s catalog long poll
parked, so the daemon's graceful drain does not finish and the stop takes
the full grace (measured 10.11 s with the real interface); the message now
says so. A llama-server sidecar is then left to the existing pidfile reaper.
Tests: crates/biorouter-cli/tests/serve_lifecycle.rs runs the real binaries
and stops serve by pid with SIGTERM, SIGINT and SIGKILL, asserting the daemon
is gone and the port closed; all three failed against the old serve.rs. Two
unit tests pin the parent watch in both directions. CI runs the lifecycle
binary in the `serve` job (the workspace job is --lib --bins only), and both
that job and smoke_serve now assert the port is closed once serve has exited.
…ir is (F8) `--web-dir` naming a directory with no index.html was fatal, but `BIOROUTER_SERVE_UI` naming the same directory was only the first candidate of the search: it was skipped without a word and `serve` served whichever bundle it found next, one the operator had not chosen. The docs presented the two as one step (F8 of the 2026-09-10 QA run). A directory the operator names is now used as named or refused, with the same message for both spellings and the source named: no web interface at /nonexistent/qa-d-env (expected an index.html there; the path came from BIOROUTER_SERVE_UI) Precedence is `--web-dir`, then a non-blank `BIOROUTER_SERVE_UI`, then the search; a blank value reads as unset, as it does for BIOROUTER_PATH_ROOT. `choose_web_dir` takes its inputs as arguments so the rule is tested without touching the environment, and the candidate list no longer reads the variable, so the six tests that held the environment lock only to neutralise it no longer take it. One test still goes through the real environment, so a build that stopped reading the variable fails. The precedence is documented in browser-access.md, the CLI reference, the environment-variable reference, the architecture page and `--help`.
…F10) The documentation items of F10 from the 2026-09-10 QA run. SD-1 named `POST /config/provider`, which does not exist, so an audit of the decision from the doc measured a 404 and could read it as "no gate". The gate is `POST /config/set_provider` (`set_config_provider`), which answers a browser session with 409; the record now says so and notes the old name. The launch token was described as "spent on the first request" (and as "one-off" and "exchanged once" elsewhere). It is not consumed: QA redeemed one token four more times, and the manual drive here three, 303 each time. Decided deliberately to keep it reusable and correct the prose, recorded as SD-9. Single use cannot be had cheaply: the cookie's value is the token, so a real single use needs a daemon-minted session table (emptied by every restart), and it would break a second browser or colleague, a browser that dropped its cookie, and the bookmarked `--token` address the docs offer; prefetchers and link unfurlers would also spend a single-use link. `the_token_is_not_consumed_by_the_exchange` in routes::web_ui pins it. Fixed in serve.rs, browser-access.md, the CLI and environment-variable references, CLAUDE.md, and the CI and smoke-test comments. The `/headless/*` count disagreed (shell.rs "seventeen routes", CLAUDE.md and the architecture page "sixteen endpoints"). Both were right about different things: sixteen paths, seventeen handlers, since /headless/settings is GET and POST. Every place now says that, and the registration test that enumerates sixteen paths is named for them. Also: CLAUDE.md's documented `cargo test -p biorouter-server --lib routes::web_ui routes::shell` is rejected by cargo with a usage error (a second filter must follow `--`); it now reads `--lib -- routes::web_ui routes::shell`. The deployment README counted "seven" records when there were eight; it now says nine.
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.
Fixes three findings from the 2026-09-10 QA run on merged
mainat 7c96d79 (report:~/biorouter-runs/test-drive/qa-d/report.md): F2 (HIGH), F8 (LOW), and the documentation items of F10. There is one commit per fix, and each can be reviewed on its own.fix(serve): stopping serve stops its daemon (F2)serveorphaned its daemon, which kept the port, the token and the secretfix(serve): refuse a BIOROUTER_SERVE_UI with no interface, as --web-dir is (F8)BIOROUTER_SERVE_UIwas silently skippeddocs(serve): name the real SD-1 route; the launch token is reusable (F10)F2: stopping
servenow stops its daemonBefore.
servenever killed the daemon it started, even though a comment in the same statement said it did. TheChildhad been moved into thespawn_blockingwait, so the Ctrl-C arm had no handle to it. The only thing that ever stopped the daemon was a terminal's Ctrl-C, which reaches the whole foreground process group. The new integration test, run against the oldserve.rs, fails all three ways (verbatim below):serveSIGTERMserveexited in 103 ms. The daemon outlived it, still holding the port, still accepting the launch token and still serving the shell carrying its secret.SIGINTserveprinted "Stopping." and then hung: the runtime waited forever on the blocking task that was still waiting for the daemon. This explains the report's "both still alive at +6 s".SIGKILLThe fix has two layers.
servestops the daemon on every exit path. It keeps atokio::process::Childand installs SIGINT and SIGTERM listeners before the spawn, so a signal during the 60 s readiness wait is held rather than fatal. The readiness wait is now async, so a signal can interrupt it. Every path after the spawn goes throughstop_daemon: SIGTERM, a 10 s grace, then SIGKILL and reap. The paths are a signal, the daemon exiting, and a startup that never became ready. A second Ctrl-C skips the grace, andkill_on_drop(true)covers an unwinding panic. SIGHUP is deliberately left alone sonohupkeeps working.servestartsbiorouterd agent --exit-with-parent <its own pid>. The flag is opt-in: the desktop and a hand-run daemon never pass it. The daemon comparesgetppid()with that pid, not with 1, because an orphan is re-parented to the nearest subreaper (systemd --user, a container's init shim), sogetppid() == 1never fires on most Linux desktops. The pid is passed in rather than read at startup, so aservethat dies first can't be mistaken for its replacement. When the watch fires, the daemon shuts down gracefully and exits unconditionally 10 s later, from a plain OS thread, since nothing is left to escalate.With a browser tab open, the renderer always has a 25 s
/catalog/changeslong poll parked on the daemon. axum's graceful shutdown waits for it, so the stop takes the full grace and ends in a SIGKILL. I measured this with the real interface open in the in-app browser (the pendingGET /catalog/changes?since=6was visible in its network log):servewas gone after 10.11 s, the daemon was gone andlsofwas empty. The kill message now names the cause (biorouterd did not finish within 10s (an open browser tab keeps a request waiting); killing it.), and the docs say to expect it. A daemon killed that way skips its own cleanup, so a llama-server sidecar is left to the existing pidfile reaper, which is the degradation modecommands/agent.rsalready accepts for any SIGKILL. Bounding the drain inside the daemon is filed as a separate task. It would change shutdown for every launcher, which this PR avoids.Tests and CI
crates/biorouter-cli/tests/serve_lifecycle.rs(new, Unix) runs the realbiorouterandbiorouterdand stopsserveby pid with SIGTERM, SIGINT and SIGKILL. It asserts the daemon is gone and the port closed. It refuses to run against a missing or stalebiorouterd, and its cleanup identifies the daemon by pid plus start time, so a recycled pid is never signalled.--lib --bins, so it never runs integration binaries. Theservejob, which already builds both binaries, now runs the lifecycle test. That job andsmoke_serveboth assert the port is closed onceservehas exited. Theservejob's timeout goes from 30 to 45 min, becausecargo test -p biorouter-cliunifies dev-dependency features and recompiles part of the graph (4m56s here).F8:
BIOROUTER_SERVE_UIis now validated the same way as--web-dir--web-dirnaming a directory with noindex.htmlwas fatal.BIOROUTER_SERVE_UInaming the same directory was only the first search candidate: it was skipped silently, andserveserved the next bundle it found. A named directory is now used as named or refused, with the same message for both spellings and the source named:The precedence is
--web-dir, then a non-blankBIOROUTER_SERVE_UI, then the search. It is documented inbrowser-access.md, the CLI reference, the environment-variable reference, the architecture page and--help. A blank value reads as unset, asBIOROUTER_PATH_ROOTalready does.choose_web_dirtakes its inputs as arguments. One test still goes through the real environment, so a build that stopped reading the variable would fail.F10: documentation
SD-1 named
POST /config/provider, which 404s. The route isPOST /config/set_provider(set_config_provider), and the record now notes the old name so anyone who audited from it knows why they saw a 404.The launch token is not single-use, and that is now a recorded decision (SD-9) rather than prose saying "spent". I considered making it single-use and rejected it:
--tokenaddress the docs offer.routes::web_ui::the_token_is_not_consumed_by_the_exchangepins the behaviour. The prose is fixed inserve.rs,browser-access.md, the CLI and environment-variable references,CLAUDE.md, and the CI and smoke-test comments.16 vs 17
/headless/*routes. Both counts were right about different things: there are sixteen paths and seventeen handlers, because settings answers both GET and POST. Every place now says that, and the registration test is renamedall_sixteen_paths_are_registered.Two more doc fixes in the same section. CLAUDE.md's
cargo test -p biorouter-server --lib routes::web_ui routes::shellis rejected by cargo with a usage error, so it now reads--lib -- routes::web_ui routes::shell.docs/deployment/README.mdcounted "seven" records when there were eight; it now says nine.Verification
Commands run on this branch, macOS arm64, with
BIOROUTER_DISABLE_KEYRING=trueon every cargo test. The CLI tests used an isolatedHOMEwith literalCARGO_HOME/RUSTUP_HOME.Red:
cargo test -p biorouter-cli --test serve_lifecycleagainst the originalserve.rs(The test's own cleanup removed all three daemons;
ps -p 31606 31607 31608came back empty afterwards.)Green: the same test on HEAD (568edb4)
Unit tests:
commands::serve,routes::web_ui,routes::shell,commands::agentfmt and clippy
The
too_many_linesbaseline pass (-W clippy::too_many_lineson both crates) shows no hit in any file this PR touches. Every hit in these crates is an existingclippy-baselines/too_many_lines.txtentry.handle_servestays under 100 lines because the banner moved intoprint_banner. I ran clippy on these two crates only, not the whole workspace, because nothing else changed.By hand:
smoke_serve's browser contract against the local build, then SIGTERM andlsofsmoke_serveitself installs the packaged.debin Docker, so it cannot run without packaging. I ran its exact check sequence againsttarget/debug/biorouter serve, with a root-base bundle built from 7c96d79, then stoppedserveby pid:The assertion added to
smoke_serve(kill, wait,/statusmust refuse) is exactly the step above. Its container script was extracted and passesbash -n, with no stray quote.By hand: a real browser tab open, then SIGTERM
By hand: F8, mirroring the report's repro, with a bundle the search can find
Case 1 is the report's repro. Before this PR, it started normally and served
ui/desktop/src/web.Not verified here. Windows. The Windows paths are cfg-gated: no SIGTERM, no parent watch, Ctrl-C through
tokio::signal::ctrl_c. The lifecycle test is#![cfg(unix)]. CI's Windows job compiles--lib --bins, so it checks the build, not the behaviour. On Windows, endingbiorouter.exefrom Task Manager still leavesbiorouterd.exerunning, and the docs say so.Out of scope
skill install --forceitem, and its other CLI message and help items (mcp <bad name>,sessionhelp text,--version,session cancel), are left to the CLI chip.biorouter serveignoresBIOROUTER_BROWSER_TOKENin its own environment. Measured:BIOROUTER_BROWSER_TOKEN=token-from-the-environment-file biorouter serveprinted a random token, and?t=token-from-the-environment-file→ 401. That breaks the systemd recipe indocs/deployment/headless-linux.md. This PR does not change that text; the separate task fixes code and docs together.biorouter apps servehas the same SIGTERM orphaning inapps.rs(onlyctrl_cis handled), and bounding the daemon's graceful drain.🤖 Generated with Claude Code