Conversation
The audit's highest-value open item, and the one that turns the honesty rule
from call-site discipline into an invariant.
THE BUG. Both filter handlers had this shape:
writeQueryState(); refreshHeatmap();
await <globe reload>; // long
refreshFacetCounts(); // only now are the counts invalidated
The await is not short. Measured on production: the globe reload takes 168s on
4G and 423s on 3G (see PERF_BANDWIDTH_FINDINGS_2026-08-06.md — it is currently
downloading ~74 MB). For that entire window the facet counts kept displaying the
PREVIOUS filter's numbers, unmarked and looking settled. isamplesorg#340 was this same
shape with a different trigger; isamplesorg#341/isamplesorg#342 fixed instances, not the class.
THE FIX. invalidateFacetCountsNow() — synchronous, cheap, called at the top of
the source and facet handlers BEFORE any await:
1. ++facetCountsReqId so an in-flight recompute cannot repaint over us
2. clearTimeout on the debounce armed for the old inputs
3. markFacetCountsRecomputing() — dims now, and via its 400ms timer (isamplesorg#342)
swaps text to "(Loading…)" when the wait is long enough to matter
It deliberately does NOT schedule a recompute: the caller still owns that, after
its await, once the new inputs have settled. Scheduling here would query
half-applied state and add contention to the very load being waited on.
Under an active search it returns early — those counts are already the honest
"(—)" dash (isamplesorg#340), and re-dimming would downgrade "we cannot know this" to
"we're about to know it", a worse claim rather than a better one.
PROVEN by A/B, not asserted. Same script, same 400 kbps throttle, toggle one
source filter, sample the DOM every 0.4s:
production (no fix) invalidated within 0.4s: False
stale UNMARKED windows : 27
user sees "(4,389,231)" for 10+s after deselecting SESAR
this branch invalidated within 0.4s: True
stale UNMARKED windows : 0
user sees "(Loading…)" throughout
Adds tests/playwright/verify_preawait_invariant.py, which is that experiment.
Verified: quarto render clean; test_smoke.py passes; test_frontend_derived 40
passed; isamplesorg#341/isamplesorg#342 behaviour unaffected (search -> 60/60 dashes 0 stuck;
facet-only -> real counts); no pageerrors.
SCOPE. Facet counts only. The samples table has the same shape
(applySearchFilterChange awaits reconcileGlobeForFilters before
refreshSamplesTable) — audit F8, deliberately left for a separate change.
Refs isamplesorg#340, isamplesorg#304, isamplesorg#305
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa
…unable to false-pass Codex blocked round 1 on two findings, both valid: 1. A TERMINAL EXCEPTION COULD STRAND (Loading...). invalidateFacetCountsNow() bumps facetCountsReqId and cancels the pending debounce, but refreshFacetCounts() sat only on the success path. If anything between the invalidate and the end of the globe work threw — updateSourceLegendState, writeQueryState, refreshHeatmap, reconcileGlobeForFilters — nothing would ever schedule a recompute and the counts would sit at (Loading...) until some unrelated later event. A HUNG promise staying Loading is honest (isamplesorg#342); a REJECTED one is terminal, so Loading becomes a different kind of lie. Fixed with an inner try/finally in both handlers, so refreshFacetCounts() runs on success AND on throw. No watchdog, no query issued while work is pending — it only guarantees a successor once the work settles or rejects. The outer finally still owns busyRelease(), and selection revalidation still runs after. Deliberately NOT applied to applySearchFilterChange(): Codex analysed it and it is not a stale-number hole (activation paints the (—) dash synchronously, and clearing leaves that honest dash visible during reconciliation). 2. THE VERIFIER COULD FALSE-PASS. It printed ever_loading without asserting it, checked stale-unmarked only before t=12s while sampling past 18s, would accept a permanently dimmed numeric value, and never proved a successor repaint. All four clauses are now required, the stale check covers every observation, and it un-throttles at the end and requires real numbers to come back — which is precisely the anti-stranding assertion for finding 1. Also took Codex's idempotence hardening: under an active search invalidateFacetCountsNow() now repaints the (—) dash rather than merely returning, so it does not depend on an earlier isamplesorg#340 caller having painted it. Verified on the rebuilt page: verifier passes all four clauses (invalidated within 0.4s / swapped to Loading / 0 stale-unmarked windows / successor repainted); test_smoke.py passes; test_frontend_derived 40 passed; isamplesorg#341/isamplesorg#342 behaviour unaffected. STILL OPEN from the review, deliberately not done here: converting the verifier to a discovered .spec.js that deterministically holds a globe request, and hardening facet-tree.spec.js's fixed 3s sleeps (they parse numeric text and could now read (Loading...) if a globe reload exceeds 3s). Refs isamplesorg#340, isamplesorg#304, isamplesorg#305 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa
|
🤖 rbotyee (Claude, operated by @rdhyee) — Raymond's intent: close out the project's stale open PRs. What I did: closing this one, on Raymond's approval, for the reasons below. Closing rather than merging or rebasing. In your own words above: "Codex has blocked this twice and I agree with it" — and the two correctness gaps you documented (a race between overlapping filter handlers that can still fire a premature query; a verifier that can't actually distinguish this fix from the pre-fix code) are still unresolved. The problem this targets — facet counts going stale under a filter change — has a shipped, documented stopgap live in production (#341/#342: an honest The diagnostics here (the invalidation-generation gap, the fault-injection testing gap) are good and worth keeping as a reference — this comment links back to them for whoever picks up #340's real fix next, rather than carrying a known-not-ready branch open indefinitely. — isamples-0914 (Claude, for rdhyee) |
What this is
The coherence audit's highest-value open item (F2), and the approach Codex argued for over a watchdog: "the invariant belongs at input invalidation, not at a timer attached to selected query paths."
The bug it targets
Both filter handlers had this shape:
That await is not short. Measured overnight on production: 168 s on 4G, 423 s on 3G — because the Explorer currently downloads ~74 MB on cold load (separate finding, #345). For that entire window the facet counts kept displaying the previous filter's numbers, unmarked.
#340 was this same shape with a different trigger. #341 and #342 fixed instances; this targets the class.
The change
invalidateFacetCountsNow()— synchronous, called before any await: bumps the request id, clears the pending debounce, and marks counts recomputing (which swaps text to(Loading…)after 400 ms via #342). It deliberately does not schedule a recompute. An innertry/finallyguarantees a successor on success and on throw.Demonstrated effect
400 kbps, toggle one source filter, sample the DOM every 0.4 s:
(4,389,231)for 10+ s after deselecting SESAR(Loading…)Why it is NOT ready
1. Overlapping handlers can still fire a premature query. Change A invalidates and awaits; change B invalidates and starts newer work; A is superseded, settles first, and its
finallyschedules a recompute that can run while B's globe reload is still pending.facetCountsReqIdprevents a stale repaint but not the premature query. Needs a dedicated filter-invalidation generation — notfacetCountsReqId, which camera refreshes also mutate.2. The verifier cannot prove what I claimed. It only ever lets the awaited work succeed, and the pre-fix code also repainted on success — so the run cannot distinguish the new
finallyfrom the old placement. It needs deterministic fault injection (force the query-state write to throw right after invalidation) and per-element rather than aggregate assertions.3. Also outstanding:
applySearchFilterChange()should get guaranteed-successor placement (not pre-await invalidation) — after clearing a search, a rejected reconcile can strand the old dash even though counts are computable again.4. Untested risk:
facet-tree.spec.jsuses fixed 3 s sleeps then parses numeric text; if a globe reload exceeds 3 s it could now read(Loading…). I could not test this — the spec needs adocs/datamirror absent from this checkout. I confirmed it fails identically on a control build without this change (10/10 both), so this branch doesn't regress it, but the specific concern is unverified.Verification that did pass
quarto renderclean ·tests/test_smoke.pyPASS ·tests/test_frontend_derived.py40 passed · #341/#342 unaffected (search → 60/60(—), 0 stuck; facet-only → real counts) · no page errors · deployed to fork staging.Refs #340, #304, #305
🤖 Generated with Claude Code
https://claude.ai/code/session_01QCCDurpcLzMe7L72y2HDAa