Skip to content

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
mainfrom
claude/modest-shamir-aba610
Open

fix(serve): serve stops its daemon (F2); BIOROUTER_SERVE_UI validated like --web-dir (F8); serve doc fixes (F10)#226
Broccolito wants to merge 3 commits into
mainfrom
claude/modest-shamir-aba610

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Fixes three findings from the 2026-09-10 QA run on merged main at 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.

Commit Finding
fa2c5f4 fix(serve): stopping serve stops its daemon (F2) Killing serve orphaned its daemon, which kept the port, the token and the secret
619bd91 fix(serve): refuse a BIOROUTER_SERVE_UI with no interface, as --web-dir is (F8) A bad BIOROUTER_SERVE_UI was silently skipped
568edb4 docs(serve): name the real SD-1 route; the launch token is reusable (F10) Doc defects: wrong route in SD-1, "spent" token, 16 vs 17 routes

Security-adjacent. This changes how the daemon that holds the browser token and the daemon secret is stopped, and it records a decision about the token's lifetime (new SD-9). It needs human review, per .github/copilot-instructions.md.

F2: stopping serve now stops its daemon

Before. serve never killed the daemon it started, even though a comment in the same statement said it did. The Child had been moved into the spawn_blocking wait, 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 old serve.rs, fails all three ways (verbatim below):

Signal to serve Old behaviour
SIGTERM serve exited in 103 ms. The daemon outlived it, still holding the port, still accepting the launch token and still serving the shell carrying its secret.
SIGINT serve printed "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".
SIGKILL The daemon was still running 30 s later.

The fix has two layers.

  1. serve stops the daemon on every exit path. It keeps a tokio::process::Child and 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 through stop_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, and kill_on_drop(true) covers an unwinding panic. SIGHUP is deliberately left alone so nohup keeps working.
  2. On Unix the daemon also watches its parent. serve starts biorouterd agent --exit-with-parent <its own pid>. The flag is opt-in: the desktop and a hand-run daemon never pass it. The daemon compares getppid() with that pid, not with 1, because an orphan is re-parented to the nearest subreaper (systemd --user, a container's init shim), so getppid() == 1 never fires on most Linux desktops. The pid is passed in rather than read at startup, so a serve that 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/changes long 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 pending GET /catalog/changes?since=6 was visible in its network log): serve was gone after 10.11 s, the daemon was gone and lsof was 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 mode commands/agent.rs already 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 real biorouter and biorouterd and stops serve by pid with SIGTERM, SIGINT and SIGKILL. It asserts the daemon is gone and the port closed. It refuses to run against a missing or stale biorouterd, and its cleanup identifies the daemon by pid plus start time, so a recycled pid is never signalled.
  • Two unit tests pin the parent watch in both directions: it fires for a pid that is not the parent and stays quiet for the real parent.
  • CI. The workspace test job is --lib --bins, so it never runs integration binaries. The serve job, which already builds both binaries, now runs the lifecycle test. That job and smoke_serve both assert the port is closed once serve has exited. The serve job's timeout goes from 30 to 45 min, because cargo test -p biorouter-cli unifies dev-dependency features and recompiles part of the graph (4m56s here).

F8: BIOROUTER_SERVE_UI is now validated the same way as --web-dir

--web-dir naming a directory with no index.html was fatal. BIOROUTER_SERVE_UI naming the same directory was only the first search candidate: it was skipped silently, and serve served 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:

Error: no web interface at /nonexistent/qa-d-env (expected an index.html there; the path came from BIOROUTER_SERVE_UI)
Error: no web interface at /nonexistent/qa-d-bogus (expected an index.html there; the path came from --web-dir)

The precedence is --web-dir, then a non-blank BIOROUTER_SERVE_UI, then the search. It is documented in browser-access.md, the CLI reference, the environment-variable reference, the architecture page and --help. A blank value reads as unset, as BIOROUTER_PATH_ROOT already does. choose_web_dir takes 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 is POST /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:

    • The cookie's value is the token, so real single use needs a daemon-minted session table, which every restart empties.
    • It would break a second browser or a colleague, a browser that has dropped its cookie, and the bookmarked --token address the docs offer.
    • Prefetchers and link unfurlers would spend a single-use link before anyone clicked it.

    routes::web_ui::the_token_is_not_consumed_by_the_exchange pins the behaviour. The prose is fixed in serve.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 renamed all_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::shell is rejected by cargo with a usage error, so it now reads --lib -- routes::web_ui routes::shell. docs/deployment/README.md counted "seven" records when there were eight; it now says nine.

Verification

Commands run on this branch, macOS arm64, with BIOROUTER_DISABLE_KEYRING=true on every cargo test. The CLI tests used an isolated HOME with literal CARGO_HOME/RUSTUP_HOME.

Red: cargo test -p biorouter-cli --test serve_lifecycle against the original serve.rs
test sigterm_to_serve_stops_its_daemon ... FAILED
test a_daemon_whose_serve_was_killed_outright_stops_itself ... FAILED
test sigint_to_serve_stops_its_daemon ... FAILED
serve exited 103.225333ms after SIGTERM
the daemon (pid 31607) outlived serve after SIGTERM:
the daemon (pid 31606) was still running 30s after serve was killed:
serve was still running 30s after SIGINT (its daemon is pid 31608):
test result: FAILED. 0 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out; finished in 51.71s

(The test's own cleanup removed all three daemons; ps -p 31606 31607 31608 came back empty afterwards.)

Green: the same test on HEAD (568edb4)
     Running tests/serve_lifecycle.rs (target/debug/deps/serve_lifecycle-785372fd9b975afe)
running 3 tests
serve exited 105.026917ms after SIGINT
serve exited 101.93925ms after SIGTERM
test sigterm_to_serve_stops_its_daemon ... ok
test sigint_to_serve_stops_its_daemon ... ok
the orphaned daemon stopped 426.678333ms after serve was killed
test a_daemon_whose_serve_was_killed_outright_stops_itself ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 13.38s
Unit tests: commands::serve, routes::web_ui, routes::shell, commands::agent
$ HOME=$(mktemp -d) CARGO_HOME=/Users/wgu/.cargo RUSTUP_HOME=/Users/wgu/.rustup cargo test -p biorouter-cli --lib commands::serve
     Running unittests src/lib.rs (target/debug/deps/biorouter_cli-c00cdd7b09f24b84)
running 20 tests
test commands::serve::tests::a_relative_parent_is_tidied_for_display ... ok
test commands::serve::tests::a_wildcard_bind_is_not_treated_as_loopback ... ok
test commands::serve::tests::the_default_port_does_not_collide_with_the_daemon_it_starts ... ok
test commands::serve::tests::a_routable_address_is_not_loopback ... ok
test commands::serve::tests::the_url_carries_the_token_and_brackets_an_ipv6_literal ... ok
test commands::serve::tests::a_missing_interface_names_every_path_it_tried ... ok
test commands::serve::tests::a_token_is_long_and_different_every_time ... ok
test commands::serve::tests::serve_reads_the_variable_it_documents ... ok
test commands::serve::tests::a_named_directory_without_an_interface_is_refused_however_it_was_named ... ok
test commands::serve::tests::a_named_directory_is_used_in_preference_to_anything_the_search_finds ... ok
test commands::serve::tests::a_blank_variable_is_not_a_choice ... ok
test commands::serve::tests::the_flag_takes_precedence_over_the_variable ... ok
test commands::serve::tests::the_daemon_is_found_beside_the_real_executable ... ok
test commands::serve::tests::a_symlinked_executable_resolves_to_the_real_installation ... ok
test commands::serve::tests::a_missing_or_unusable_breadcrumb_falls_back_without_panicking ... ok
test commands::serve::tests::loopback_spellings_are_recognised ... ok
test commands::serve::tests::the_breadcrumb_is_consulted_after_the_locations_beside_the_binary ... ok
test commands::serve::tests::a_windows_style_install_finds_the_bundle_through_its_breadcrumb ... ok
test commands::serve::tests::the_resolved_candidates_are_never_windows_verbatim_paths ... ok
test commands::serve::tests::a_stale_breadcrumb_is_named_among_the_paths_that_were_tried ... ok
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 381 filtered out; finished in 0.01s

$ cargo test -p biorouter-server --lib routes::web_ui
running 11 tests
test routes::web_ui::tests::no_configured_token_admits_everyone ... ok
test routes::web_ui::tests::a_cookie_name_that_merely_ends_with_the_real_one_is_not_it ... ok
test routes::web_ui::tests::a_cookie_is_read_out_of_a_header_carrying_several ... ok
test routes::web_ui::tests::a_configured_token_is_required_and_compared_whole ... ok
test routes::web_ui::tests::a_shell_without_a_head_still_gets_its_configuration ... ok
test routes::web_ui::tests::the_runtime_config_names_the_origin_rather_than_leaving_the_renderer_to_guess ... ok
test routes::web_ui::tests::a_valid_cookie_is_enough_on_a_later_request ... ok
test routes::web_ui::tests::the_shell_is_never_cached ... ok
test routes::web_ui::tests::the_token_is_exchanged_for_a_cookie_and_redirected_out_of_the_address_bar ... ok
test routes::web_ui::tests::the_token_is_not_consumed_by_the_exchange ... ok
test routes::web_ui::tests::a_wrong_token_gets_the_shell_from_nobody ... ok
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 576 filtered out; finished in 0.00s

$ cargo test -p biorouter-server --lib -- routes::web_ui routes::shell
     Running unittests src/lib.rs (target/debug/deps/biorouter_server-a0b457219cca8ef3)
running 48 tests
test routes::shell::tests::all_sixteen_paths_are_registered ... ok
test routes::shell::tests::an_extension_name_is_one_segment ... ok
test routes::shell::tests::a_symlinked_directory_does_not_launder_a_path ... ok
test routes::shell::tests::a_revision_never_carries_an_unkeyed_digest_of_the_content ... ok
test routes::shell::tests::a_symlink_that_escapes_a_root_is_refused ... ok
test routes::shell::tests::artifact_read_refuses_a_link_at_the_final_component ... ok
test routes::shell::tests::a_path_that_does_not_exist_yet_is_still_placed ... ok
test routes::shell::tests::a_read_outside_every_root_is_refused ... ok
test routes::shell::tests::a_sibling_with_a_shared_prefix_is_outside ... ok
test routes::shell::tests::an_empty_path_is_refused ... ok
test routes::shell::tests::credential_stores_inside_a_root_are_refused ... ok
test routes::shell::tests::a_descriptor_is_only_accepted_when_it_is_the_file_that_was_validated ... ok
test routes::shell::tests::a_root_is_reachable_but_known_to_be_a_root ... ok
test routes::shell::tests::a_program_is_looked_up_with_the_platform_suffix ... ok
test routes::shell::tests::a_link_swapped_in_after_validation_is_refused_rather_than_followed ... ok
test routes::shell::tests::office_preview_range_guard_accepts_non_ascii_worksheet_xml ... ok
test routes::shell::tests::registry_downloads_are_host_and_scheme_bound ... ok
test routes::shell::tests::office_preview_refuses_a_used_range_that_only_exceeds_the_limit_in_aggregate ... ok
test routes::shell::tests::office_preview_rejects_implausible_workbook_ranges ... ok
test routes::shell::tests::office_preview_accepts_a_bounded_presentation_shape ... ok
test routes::shell::tests::artifact_read_refuses_a_credential_store_inside_a_root ... ok
test routes::shell::tests::every_artifact_kind_carries_a_revision ... ok
test routes::shell::tests::the_command_path_uses_the_platform_separator ... ok
test routes::shell::tests::the_settings_document_follows_the_configured_root ... ok
test routes::shell::tests::workbook_text_resolves_shared_strings ... ok
test routes::shell::tests::office_preview_caps_the_number_of_worksheets ... ok
test routes::shell::tests::artifact_route_returns_preview_bytes_and_honors_biorouterignore ... ok
test routes::web_ui::tests::a_configured_token_is_required_and_compared_whole ... ok
test routes::shell::tests::office_preview_extracts_document_text ... ok
test routes::shell::tests::traversal_out_of_a_root_is_refused ... ok
test routes::web_ui::tests::a_cookie_is_read_out_of_a_header_carrying_several ... ok
test routes::shell::tests::traversal_that_stays_inside_a_root_resolves ... ok
test routes::shell::tests::a_read_inside_a_root_succeeds ... ok
test routes::shell::tests::artifact_route_honors_the_requested_projects_ignore_file ... ok
test routes::web_ui::tests::a_valid_cookie_is_enough_on_a_later_request ... ok
test routes::web_ui::tests::no_configured_token_admits_everyone ... ok
test routes::web_ui::tests::a_cookie_name_that_merely_ends_with_the_real_one_is_not_it ... ok
test routes::web_ui::tests::a_shell_without_a_head_still_gets_its_configuration ... ok
test routes::web_ui::tests::the_runtime_config_names_the_origin_rather_than_leaving_the_renderer_to_guess ... ok
test routes::shell::tests::zip_entries_cannot_escape_the_install_directory ... ok
test routes::web_ui::tests::the_shell_is_never_cached ... ok
test routes::shell::tests::an_image_under_every_other_ceiling_is_refused_on_total_pixels ... ok
test routes::shell::tests::office_preview_clips_extracted_text_at_the_cap ... ok
test routes::web_ui::tests::a_wrong_token_gets_the_shell_from_nobody ... ok
test routes::web_ui::tests::the_token_is_exchanged_for_a_cookie_and_redirected_out_of_the_address_bar ... ok
test routes::web_ui::tests::the_token_is_not_consumed_by_the_exchange ... ok
test routes::shell::tests::two_files_sharing_a_size_and_an_mtime_still_get_different_revisions ... ok
test routes::shell::tests::office_preview_refuses_populated_cells_that_only_exceed_the_limit_in_aggregate ... ok
test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 539 filtered out; finished in 0.06s

$ cargo test -p biorouter-server --bin biorouterd commands::agent
     Running unittests src/main.rs (target/debug/deps/biorouterd-cf23f9b8f9cc9c40)
running 2 tests
test commands::agent::tests::a_daemon_notices_that_its_named_parent_is_not_its_parent ... ok
test commands::agent::tests::a_daemon_whose_parent_is_still_there_keeps_running ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 575 filtered out; finished in 2.01s
fmt and clippy
$ cargo fmt --all -- --check
workspace fmt: clean
$ cargo clippy -p biorouter-cli -p biorouter-server --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 42.06s
clippy exit=0

The too_many_lines baseline pass (-W clippy::too_many_lines on both crates) shows no hit in any file this PR touches. Every hit in these crates is an existing clippy-baselines/too_many_lines.txt entry. handle_serve stays under 100 lines because the banner moved into print_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 and lsof

smoke_serve itself installs the packaged .deb in Docker, so it cannot run without packaging. I ran its exact check sequence against target/debug/biorouter serve, with a root-base bundle built from 7c96d79, then stopped serve by pid:

serve pid: 3362
daemon pid: 3437  (/Users/wgu/Desktop/BioRouter/.claude/worktrees/modest-shamir-aba610/target/debug/biorouterd agent --exit-with-parent 3362)
--- smoke_serve contract against the local build ---
  bare GET / is 401                                          ok
  exchange: 303 + HttpOnly SameSite=Strict cookie            ok
  wrong token is 401                                         ok
  cookie -> shell with runtime config, 64-hex secret         ok
  apiBaseUrl absent                                          ok
  shell is no-store                                          ok
  root-base bundle is served                                 ok
  /headless/health 401 without the secret                    ok
  /headless/health ok with the secret                        ok
  fs/read outside the roots is 403                           ok
--- the launch token is reusable (F10: what the prose must say) ---
  redemption 1: 303
  redemption 2: 303
  redemption 3: 303
--- before stopping ---
COMMAND    PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
biorouter 3437  wgu   14u  IPv4 0x9cbf4e7f7462c415      0t0  TCP 127.0.0.1:18791 (LISTEN)
--- kill -TERM 3362 (serve) ---
serve exited rc=0 after 0.02s
daemon 3437: gone
lsof -nP -iTCP:18791 (expect nothing):
  (empty)
GET /status now: 000
connection refused (curl rc=7)
GET /?t=<token> now: 000
connection refused (curl rc=7)

The assertion added to smoke_serve (kill, wait, /status must refuse) is exactly the step above. Its container script was extracted and passes bash -n, with no stray quote.

By hand: a real browser tab open, then SIGTERM
serve 99074, daemon 99245
COMMAND     PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
biorouter 99245  wgu   10u  IPv4 0xf34937737d7287ae      0t0  TCP 127.0.0.1:18794 (LISTEN)
serve gone after 10.11s
daemon 99245: gone
lsof -nP -iTCP:18794:
  (empty)
Stopping.
biorouterd did not finish within 10s (an open browser tab keeps a request waiting); killing it.
By hand: F8, mirroring the report's repro, with a bundle the search can find
=== 1. the report's repro: a mistyped BIOROUTER_SERVE_UI, a bundle findable elsewhere ===
$ BIOROUTER_SERVE_UI=/nonexistent/qa-d-env
  -> Error: no web interface at /nonexistent/qa-d-env (expected an index.html there; the path came from BIOROUTER_SERVE_UI)
  (serve exited rc=1)
=== 2. the same mistake as --web-dir (the report's 'fatal' baseline) ===
$ biorouter serve --web-dir /nonexistent/qa-d-bogus
  -> Error: no web interface at /nonexistent/qa-d-bogus (expected an index.html there; the path came from --web-dir)
  (serve exited rc=1)
=== 3. a good BIOROUTER_SERVE_UI is used as named ===
$ BIOROUTER_SERVE_UI=/private/tmp/claude-501/-Users-wgu-Desktop-BioRouter--claude-worktrees-modest-shamir-aba610/08f21adc-eebe-42f9-89e0-0c7fff3417d7/scratchpad/web
  -> serving the web interface from /private/tmp/claude-501/-Users-wgu-Desktop-BioRouter--claude-worktrees-modest-shamir-aba610/08f21adc-eebe-42f9-89e0-0c7fff3417d7/scratchpad/web
  (stopped serve, rc=0)
=== 4. --web-dir takes precedence over a bad BIOROUTER_SERVE_UI ===
$ BIOROUTER_SERVE_UI=/nonexistent/qa-d-env biorouter serve --web-dir /private/tmp/claude-501/-Users-wgu-Desktop-BioRouter--claude-worktrees-modest-shamir-aba610/08f21adc-eebe-42f9-89e0-0c7fff3417d7/scratchpad/web
  -> serving the web interface from /private/tmp/claude-501/-Users-wgu-Desktop-BioRouter--claude-worktrees-modest-shamir-aba610/08f21adc-eebe-42f9-89e0-0c7fff3417d7/scratchpad/web
  (stopped serve, rc=0)
=== 5. a blank BIOROUTER_SERVE_UI is unset: the search runs ===
$ BIOROUTER_SERVE_UI=
  -> serving the web interface from /Users/wgu/Desktop/BioRouter/.claude/worktrees/modest-shamir-aba610/ui/desktop/src/web
  (stopped serve, rc=0)
(removed the staged ui/desktop/src/web)
port 18795: nothing left listening

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, ending biorouter.exe from Task Manager still leaves biorouterd.exe running, and the docs say so.

Out of scope

  • F10's skill install --force item, and its other CLI message and help items (mcp <bad name>, session help text, --version, session cancel), are left to the CLI chip.
  • New finding, filed separately: biorouter serve ignores BIOROUTER_BROWSER_TOKEN in its own environment. Measured: BIOROUTER_BROWSER_TOKEN=token-from-the-environment-file biorouter serve printed a random token, and ?t=token-from-the-environment-file → 401. That breaks the systemd recipe in docs/deployment/headless-linux.md. This PR does not change that text; the separate task fixes code and docs together.
  • Filed separately: biorouter apps serve has the same SIGTERM orphaning in apps.rs (only ctrl_c is handled), and bounding the daemon's graceful drain.

🤖 Generated with Claude Code

`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.
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