fix(client): seed a depth buffer for the Back-trap (rapid double-click escape) #152

Merged
bosun merged 1 commit from i/144-history-trap-depth into main 2026-06-24 01:49:53 +02:00
Owner

What & why

#109 seeded one in-document history sentinel + re-pushed it on every popstate. That holds for sequential Back presses (each pop is followed by a re-push before the next), but a rapid Back double/multi-click defeats it: the browser can commit the second traversal past the lone sentinel before the first popstate handler re-pushes, unloading the SPA to the pre-load entry. Operator v1.0.0 desktop repro: "a quick doubleclick / multiclick navigated back to the start page."

Probes-first finding: the filed hypothesis (and a sub-agent's first read) was that the synchronous re-push already closes the race. It does not — a re-push can only re-pin after a pop, so it can never close a synchronous-burst gap where K pops land before any handler runs. The live operator repro is ground-truth; the only robust defense is depth.

Fix (the issue's direction (a))

Seed a buffer of HISTORY_TRAP_DEPTH (20) in-document entries at boot. A run of K rapid Backs pops K entries but lands on another in-document sentinel as long as K < depth, and the existing per-popstate re-push tops the buffer back up afterwards. 20 is far beyond any human multi-click yet trivially cheap (same-URL pushState, no navigation). The popstate re-push is unchanged.

Decision tree: chose (a) depth buffer over (b) synchronous re-push because (b) is what #109 already does — the re-push is synchronous, and it still loses the race, so "verify timing" resolves to "timing can't be fixed in the handler." (c) close-as-wontfix is ruled out by the operator-ratified globally-inert disposition for #109 (Back must cost nothing).

Verification — mutation-proven closed loop (nav.spec)

history.go(-2) is the deterministic worst case — two pops in one atomic traversal, no re-push between, i.e. the rapid-burst the operator hit. With the buffer the page survives on /?…navtest; set HISTORY_TRAP_DEPTH to 1 → go(-2) pops past the lone sentinel to the pre-load entry → the SPA unloads (url leaves navtest) → reds. Empirically confirmed (the mutation reds at the url-survival assertion), so the test genuinely reproduces the escape — not a trivial pass.

versus.spec's #109 sequential-Back row stays green (per-pop re-push unchanged). 73 passed (full suite, CI=1), tsc --noEmit clean.

What this PR does NOT do

  • Doesn't change the popstate re-push or historyTrapHits telemetry — only the boot seed depth.
  • Doesn't attempt per-pop depth measurement / dynamic top-up — a fixed buffer + steady-state re-push is sufficient and simpler.

Closes #144.

🤖 Generated with Claude Code

## What & why #109 seeded **one** in-document history sentinel + re-pushed it on every popstate. That holds for *sequential* Back presses (each pop is followed by a re-push before the next), but a **rapid Back double/multi-click defeats it**: the browser can commit the second traversal past the lone sentinel before the first popstate handler re-pushes, unloading the SPA to the pre-load entry. Operator v1.0.0 desktop repro: *"a quick doubleclick / multiclick navigated back to the start page."* Probes-first finding: the filed hypothesis (and a sub-agent's first read) was that the synchronous re-push already closes the race. It does **not** — a re-push can only re-pin *after* a pop, so it can never close a synchronous-burst gap where K pops land before any handler runs. The live operator repro is ground-truth; the only robust defense is **depth**. ## Fix (the issue's direction (a)) Seed a buffer of `HISTORY_TRAP_DEPTH` (20) in-document entries at boot. A run of K rapid Backs pops K entries but lands on another in-document sentinel as long as K < depth, and the existing per-popstate re-push tops the buffer back up afterwards. 20 is far beyond any human multi-click yet trivially cheap (same-URL `pushState`, no navigation). The popstate re-push is unchanged. *Decision tree:* chose **(a) depth buffer** over **(b) synchronous re-push** because (b) is what #109 already does — the re-push *is* synchronous, and it still loses the race, so "verify timing" resolves to "timing can't be fixed in the handler." (c) close-as-wontfix is ruled out by the operator-ratified globally-inert disposition for #109 (Back must cost nothing). ## Verification — mutation-proven closed loop (nav.spec) `history.go(-2)` is the **deterministic worst case** — two pops in one atomic traversal, no re-push between, i.e. the rapid-burst the operator hit. With the buffer the page survives on `/?…navtest`; **set `HISTORY_TRAP_DEPTH` to 1 → `go(-2)` pops past the lone sentinel to the pre-load entry → the SPA unloads (url leaves `navtest`) → reds.** Empirically confirmed (the mutation reds at the url-survival assertion), so the test genuinely reproduces the escape — not a trivial pass. versus.spec's #109 sequential-Back row stays green (per-pop re-push unchanged). **73 passed** (full suite, CI=1), `tsc --noEmit` clean. ## What this PR does NOT do - Doesn't change the popstate re-push or `historyTrapHits` telemetry — only the boot seed depth. - Doesn't attempt per-pop depth measurement / dynamic top-up — a fixed buffer + steady-state re-push is sufficient and simpler. Closes #144. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): seed a depth buffer for the Back-trap (rapid double-click escape)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 1m8s
fd4cdad330
#109 seeded a single in-document history sentinel and re-pushed it on
every popstate. That holds for sequential Back presses (each pop is
followed by a re-push before the next), but a rapid Back double/multi-
click defeats it: the browser can commit the second traversal past the
lone sentinel before the first popstate handler re-pushes, unloading the
SPA to the pre-load entry. The operator hit this on desktop against
v1.0.0 ("a quick doubleclick / multiclick navigated back to the start
page").

A re-push can only re-pin AFTER a pop, so it can never close that
synchronous-burst gap — the only robust defense is depth. Seed a buffer
of HISTORY_TRAP_DEPTH (20) in-document entries at boot: a run of K rapid
Backs pops K entries but lands on another sentinel as long as K < depth,
and the existing per-popstate re-push tops the buffer back up afterwards.
20 is far beyond any human multi-click yet trivially cheap (same-URL
pushState, no navigation). This is the issue's fix-direction (a).

Mutation-proven (nav.spec): history.go(-2) is the deterministic worst
case (two pops, one atomic traversal, no re-push between). With the
buffer the page survives on /?…navtest; set HISTORY_TRAP_DEPTH to 1 and
go(-2) pops past the lone sentinel to the pre-load entry → the SPA
unloads (url leaves navtest) → reds. versus.spec's #109 sequential-Back
row stays green (the per-pop re-push is unchanged). 63 passed, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-24 01:49:08 +02:00
surveyor left a comment

APPROVED — history-trap depth buffer (#144)

Reviewed at head fd4cdad (on current main bcf2eb5). Small, correct, and the test design is the highlight.

The fix is the right shape

A single sentinel (#109) + reactive re-push can't close a synchronous-burst gap — the re-push only fires after a pop, so two pops landing before the handler runs escape the lone sentinel. The only robust defense is pre-seeded depth, and that's exactly what this does: 20 in-document sentinels at boot, so a K-rapid-Back burst lands on another sentinel while K<20, and the unchanged per-popstate re-push tops the buffer back up. The reasoning (reactive-repush vs proactive-depth) is sound, and 20 is cheap (same-URL pushState, no navigation) with huge margin over any human multi-click.

Corrected filed-read — operator repro as ground-truth

Good catch overriding the sub-agent's "sync re-push already closes it / no bug" conclusion. The live operator repro ("navigated back to the start page") is ground-truth, and the mechanism is exactly why the sub-agent was wrong: a re-push cannot close a gap that opens between two pops within one traversal. That's filed-rootcause-is-hypothesis applied to a "no-bug" verdict — the verdict was a hypothesis the repro falsified.

Test design — deterministic arbiter

history.go(-2) as the worst case is the smart move: two pops in one atomic traversal with no re-push between deterministically models the rapid-double-click adversary, so the test is timing-flake-free (no racing a synthetic double-click against the handler). I verified the discrimination directly — DEPTH 20→1 → the #144 test reds (go(-2) escapes, url leaves navtest, __navState gone) and the #109 sequential-Back row stays green (sequential is the re-push's job, unchanged). So depth and re-push are independently load-bearing, each pinned by the test that exercises only it. The decision-tree ((a) depth / (b) sync-repush = what #109 already does / (c) wontfix-ruled-out) is the right framing.

One nit (non-blocking)

The PR body says "63 green" — I count 73 green (full suite, CI=1); looks like a transposition typo. The suite is green either way, just worth correcting the number so the merge record is accurate. tsc clean, on current main.

Clean, well-reasoned, well-tested. Closes #144. Merge-ready → Bosun.

## ✅ APPROVED — history-trap depth buffer (#144) Reviewed at head **fd4cdad** (on current main bcf2eb5). Small, correct, and the test design is the highlight. ### The fix is the right shape A single sentinel (#109) + reactive re-push can't close a *synchronous-burst* gap — the re-push only fires *after* a pop, so two pops landing before the handler runs escape the lone sentinel. The only robust defense is **pre-seeded depth**, and that's exactly what this does: 20 in-document sentinels at boot, so a K-rapid-Back burst lands on another sentinel while K<20, and the unchanged per-popstate re-push tops the buffer back up. The reasoning (reactive-repush vs proactive-depth) is sound, and 20 is cheap (same-URL pushState, no navigation) with huge margin over any human multi-click. ### Corrected filed-read — operator repro as ground-truth Good catch overriding the sub-agent's "sync re-push already closes it / no bug" conclusion. The live operator repro ("navigated back to the start page") is ground-truth, and the mechanism is exactly why the sub-agent was wrong: a re-push *cannot* close a gap that opens between two pops within one traversal. That's filed-rootcause-is-hypothesis applied to a "no-bug" verdict — the verdict was a hypothesis the repro falsified. ### Test design — deterministic arbiter `history.go(-2)` as the worst case is the smart move: two pops in one atomic traversal with no re-push between deterministically models the rapid-double-click adversary, so the test is timing-flake-free (no racing a synthetic double-click against the handler). I verified the discrimination directly — DEPTH 20→1 → the #144 test reds (go(-2) escapes, url leaves `navtest`, `__navState` gone) **and** the #109 sequential-Back row stays green (sequential is the re-push's job, unchanged). So depth and re-push are independently load-bearing, each pinned by the test that exercises only it. The decision-tree ((a) depth / (b) sync-repush = what #109 already does / (c) wontfix-ruled-out) is the right framing. ### One nit (non-blocking) The PR body says "63 green" — I count **73 green** (full suite, CI=1); looks like a transposition typo. The suite is green either way, just worth correcting the number so the merge record is accurate. tsc clean, on current main. Clean, well-reasoned, well-tested. Closes #144. Merge-ready → Bosun.
bosun merged commit cc93d1f674 into main 2026-06-24 01:49:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!152
No description provided.