fix(session-meta): prune to the union of live watchers; widen the shared-setting guard to crates/*/tests - #213
Merged
Broccolito merged 3 commits intoSep 10, 2026
Conversation
`SessionMetaEvents::last` is process-global; a poll's `ids` list is one window's. `retain_watched(&ids)` pruned the shared map against whichever caller's list arrived last, so two Biorouter windows with different chats open evicted each other every 2 s — and `observe` adopts a first-seen id SILENTLY, so each eviction was followed by a silent re-adoption and neither window was ever told a row had moved. A poll now takes a `WatchGuard` for the life of the request. The guard's Drop retires the claim and prunes to the UNION of every live watcher, which is the only list that agrees with the map it prunes. `retain_watched` is gone; `retain_union` is private and reachable only from the drop. The union alone is not enough: a claim lives for exactly one parked GET, so the only window's ids are unclaimed for an instant at every request boundary, and pruning strictly to the union there would evict the ids that window is about to ask for again. `RETENTION` (5 min, and an id's stamp is refreshed by every read) closes that gap; a test constructs the feed with `Duration::ZERO` to observe an eviction without sleeping. Measured on the pre-fix tree with the regression ported onto the old API: "window A must learn that s1 moved; left: 0, right: 1".
…config
The table-driven guard that replaced three per-key instruments walked
`crates/*/src/**` only, and its own doc comment explained why `tests/` was
skipped — so a consolidation that read as a strict improvement narrowed the
coverage by 129 of the workspace's 762 `.rs` files. The reasoning was half
right: a crate's top-level `tests/` file compiles to its own binary, so a
write there cannot race the lib tests. It races every other test in that
binary, and nothing else in this repository looks there.
Two unrestored `set_var("BIOROUTER_ALLOW_PROJECT_HOOKS", "1")` writes sat in
`tests/hooks_agent_loop_tests.rs` and `tests/global_memory_consent_agent_loop.rs`
while the guard, the audit's ledger and the audit's writer recipe all
reported the key as handled. Both now state the decision on
`AgentConfig::with_project_hooks`, the seam #205 added for exactly this;
both files write a real `.biorouter/hooks.yaml`, so each binary's reader was
sensitive from its first agent onward.
The walk now covers `src/**` and each crate's top-level `tests/**`, with a
separate non-vacuity floor per scope — one total is satisfied by the `src`
half alone, so it would pass on the very narrowing this undoes. `examples/`
and `build.rs` (8 files) stay out: neither is compiled into a test binary.
Measured: with one `set_var` line restored, the widened guard fails naming
`biorouter/tests/hooks_agent_loop_tests.rs:137`, and the pre-widening walk
stays green.
Every behavioural test in the workspace passes with the claim deleted: the row map lives in `biorouter`, the poll takes its guard in `biorouter-server`, and no test drives the route. So the line that carries the fix had no instrument at all — the same shape as the widened guard in the commit before this one, one layer down. A source scan is the only thing that can see it. Two traps this repository has already measured apply here and both are handled: the scan reads the production slice only, because this module's own tests name the shape they forbid; and it strips comments, because the call site carries a warning that SPELLS `let _ = events.watch(&ids)` — an unstripped scan finds two claims and fails on a correct tree. Proved against both poisoned probes rather than reasoned about. With `let _ = events.watch(&ids)`: "the claim must be bound to a NAMED local". With the line deleted: "the poll claims its ids exactly once … Found: []".
Broccolito
deleted the
claude/fix-session-meta-watchers-and-guard-scope
branch
September 10, 2026 00:37
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.
Two review findings from the #201/#205/#209/#211 run: one functional (two Biorouter windows silently stop being told a chat's row moved), one instrument (the guard that was supposed to catch the second class of defect stopped looking at a third of the workspace, and two live offenders were sitting in the part it stopped reading).
Why
Finding 1 — the row map was pruned against one window's chats.
SessionMetaEvents::lastis process-global; theidson aGET /sessions/changesis one window's open-chat list. The route calledretain_watched(&ids)on every 2 s tick of every poll, so the map was pruned against whichever caller's list arrived last.observeadopts a first-seen id silently by design — the right behaviour for a chat you just opened, and the reason this defect is invisible: B's prune drops A's chats, A's nextobservere-adopts them and publishes nothing, then A's prune drops B's. Neither window is ever told a row moved. Tab tear-off is a shipped feature, so two windows is the ordinary case, and the endpoint's whole reason to exist — seeingbiorouter session --resume <id> --provider …from another process — is what stops working.Finding 2 — the guard could not see where the offenders were.
model::tests::no_test_parks_a_shared_setting_in_the_process_environment(#209) consolidated three per-key instruments into one table. The two it replaced walked all ofcrates/**; the table walkedcrates/*/src/**and documented the omission as deliberate. So a change that reads as a strict improvement narrowed the coverage by 129 of the workspace's 762.rsfiles. Two unrestoredset_var("BIOROUTER_ALLOW_PROJECT_HOOKS", "1")writes sat in that blind spot while three instruments reported the key handled: the guard walkedsrconly, the ledger row named one writer, and the audit's own writer recipe greppedcrates/biorouter/src.Root cause
Finding 1. A process-global map pruned against a per-caller list. The two halves disagree about scope, and nothing in the code said so —
retain_watchedtook a&[String]and had no way to know whose it was. Itsif last.len() <= watched.len() { return; }early-out made it look conservative while doing exactly the wrong thing the moment a second window existed.Finding 2. The stated reason for skipping
tests/was half right and drew the wrong conclusion. A crate's top-leveltests/file compiles to its own binary, so a write there cannot race the lib tests most of these readers live in — true. But it races every other test in that binary, which is a smaller hazard, not a different one, and nothing else in this repository looks there. Both offending files write a real.biorouter/hooks.yamlinto their own working directory, so each binary's reader was sensitive from its first agent onward, and neither file ever removed the variable.What changed
Finding 1 — a watcher registry and a claim with a lifetime
crates/biorouter/src/session_meta.rs:178—watchers: Mutex<HashMap<u64, Vec<String>>>, the open-chat ids of every live watcher keyed by a monotonic token (next_watcher), so a guard can only ever retire its own claim.crates/biorouter/src/session_meta.rs:339—watch(self: &Arc<Self>, ids) -> WatchGuard,#[must_use]. Registering only ever grows the union, so it prunes nothing; pruning belongs on the drop.crates/biorouter/src/session_meta.rs:398/:403—WatchGuardand itsDrop: retire the claim, then re-prune.crates/biorouter/src/session_meta.rs:362—retain_union, private and reachable only from that drop. Prunes to the union of every live watcher.retain_watchedis gone — zero references anywhere in the repo.crates/biorouter-server/src/routes/session_meta.rs:143—let _claim = events.watch(&ids);, taken before the park loop and released when the request answers. It must be a named binding:let _ = events.watch(&ids)drops on the spot and reinstates the defect in a shape that reads as a fix. The comment says so at the call site.crates/biorouter-server/src/routes/session_meta.rs:214— a source scan pinning that line, because no behavioural test in the workspace can see it: the row map lives inbiorouter, the claim is taken inbiorouter-server, and no test drives the route, so deleting the line leaves every suite green. Both traps this repo has already measured apply and are handled — the scan reads the production slice only (this module's own tests name the shape they forbid) and strips comments (the call site's warning spellslet _ = events.watch(&ids), so an unstripped scan finds two claims and fails on a correct tree).crates/biorouter/src/session_meta.rs:90/:142/:205— the union alone is not sufficient, and this is the part that is easy to get wrong. A claim lives for exactly one parked GET, so the only window's ids are unclaimed for an instant at every request boundary, and pruning strictly to the live union there would evict the ids that window is about to ask about again — swallowed for good, because re-adoption is silent.RETENTION(5 min) plus aTracked { row, seen }stamp refreshed by every read closes that gap.with_retention(Duration::ZERO)lets a test observe an eviction without sleeping through it.Finding 2 — widen the walk, and state the decision instead of parking it
crates/biorouter/src/model.rs:1462–:1463— the walk now takescrates/*/src/**andcrates/*/tests/**.examples/andbuild.rsstay out (8 files, measured); neither is compiled into a test binary, so a write there cannot reach one.crates/biorouter/src/model.rs:1529–:1537— two non-vacuity floors, one per scope (scanned_src > 400,scanned_tests > 90). A single total is satisfied by thesrchalf alone, so it would pass on the very narrowing this widening undoes. Measured today: 633src, 121tests, 7examples, 1build.rs= 762.crates/biorouter/src/model.rs:1371— a# What it walkssection recording why the second half was missing, so the next consolidation does not re-derive the omission.crates/biorouter/tests/hooks_agent_loop_tests.rs:156andcrates/biorouter/tests/global_memory_consent_agent_loop.rs:162—.with_project_hooks(true)onAgentConfig, the seam Three live readers of the process environment take explicit inputs #205 added for exactly this, replacing theset_var.HooksManager::new_with_managedreads the variable with a barestd::env::varthat nowith_config_overridestask-local can reach, which is why the remedy is an argument and not an override.docs/testing/process-global-state.md:30— the guard's stated scope, and the 129/762 measurement.docs/testing/process-global-state.md:122— the existing row now names all three writers and corrects its own "no.biorouter/hooks.yamlin-tree, so no reader is sensitive today", which was true only of the lib.docs/testing/process-global-state.md:124— a new row for thecrates/*/testswriters, attributing thesrcreaders to Three live readers of the process environment take explicit inputs #205 and these two to this PR, and recording the measurement that the pre-widening guard stays green with one of them restored.docs/testing/process-global-state.md:183— the writer recipe under Re-measuring now grepscrates/, notcrates/biorouter/src.Did the widened walk flag anything else? No. It reads 121 more files and finds no new offender.
crates/*/testsdoes hold 12BIOROUTER_PATH_ROOTwriter call sites across 11 files (11 on one line, one split across two), and they are not silenced:BIOROUTER_PATH_ROOThas no row inWATCHED, deliberately — the ledger grades it open — deferred against ~46 livePaths::config_dir()readers and 33lock_envwriters, which is a separate piece of work, not something this PR narrowed the walk to avoid. The walk was not scoped down at any point.No contract changed. No route signature, no request/response type, no event type. Verified rather than assumed:
cargo run -p biorouter-server --bin generate_schemarewroteui/desktop/openapi.jsonandgit statuscame back clean, so the TS client needed no regeneration.Tests
cargo fmt --all -- --check— exit 0../scripts/clippy-lint.sh— exit 0 (✅ All baseline clippy checks passed!); notoo_many_linesbaseline entry was added.Finding 1, fail-before.
git show origin/main:…for both files, with the new regression ported onto the pre-fix API (each window's ownretain_watched(&ids)standing in for the guard it does not have):Pass-after —
cargo test -p biorouter --lib -- session_meta(14 tests; three are new: the two-window case,a_row_rewritten_between_two_polls_of_one_window_is_still_reportedfor the retention gap, anda_chat_nobody_watches_stops_being_trackedrewritten so dropping a guard is what releases the ids):cargo test -p biorouter-server --lib -- routes::session_meta:The call-site guard, proved against two poisoned probes rather than reasoned about. With
let _ = events.watch(&ids)— the spelling that compiles, silences#[must_use], and restores the defect:With the line deleted outright:
Finding 2, fail-before. With one of the two
set_varlines restored,cargo test -p biorouter --lib -- model::tests::no_test_parks:And the claim the ledger makes, measured rather than reasoned about — the pre-widening walk (
tests/counted but not read) against that same restored line:Pass-after, both files restored:
The two integration binaries whose writers were removed — they exercise real project hooks from a real
.biorouter/hooks.yaml, so they would fail outright if the seam did not carry the opt-in:Full suites:
Follow-ups
SessionManagerfixture this module does not have today.BIOROUTER_PATH_ROOTstays open. The widened walk now reads its 12crates/*/testswriters but cannot judge them, because the key has noWATCHEDrow. Adding one means first deciding what a legitimate write looks like against ~46 livePaths::config_dir()readers; the ledger already tracks this as deferred.retain_unioncan hold an id for up to 5 minutes after its last watcher. Bounded and cheap, but a poll-count- or generation-based release would be exact. Only worth it if the map is ever measured to matter.🤖 Generated with Claude Code