Skip to content

test(react): eviction-* flake cluster — poll the controller settle, not a 20ms sleep (#548) - #567

Merged
blove merged 3 commits into
mainfrom
blove/eviction-flake-cluster
Sep 3, 2026
Merged

blove merged 3 commits into
mainfrom
blove/eviction-flake-cluster

Conversation

@blove

@blove blove commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes #548

Test-only. No product code changes; no changeset needed.

Mechanism (one per assertion, all the same class)

Every eviction-* file settled a window slide with act(() => setTimeout(20)). setRows lands synchronously in the layout effect, but the DOM rows come from the renderer-dom row layout controller, which draws a replacement across scheduler hops (MessageChannel macrotasks, row-layout-controller.ts fallbackScheduler). Under CPU starvation those hops outlast a 20ms timer, and the assertion reads a DOM that is one window behind the props.

Captured under load (run 4-1, load 241–270):

eviction-population-change.test.tsx:180  expected <div aria-rowindex="13" … data-pretable-row-id="row-1"> to be null

row-1 is still rendered from the previous window, but already announced at the NEW window's aria-rowindex (10 + 1 + 2) — the surface has published the new offset while the controller has not yet published the new rows. This is the "one render where a pager swap has moved the window but setRows has not yet settled" skew documented at pretable-surface.tsx (selectionWindow comment), caught mid-flight.

test assertion received verdict
population-change › paints nothing rather than rows the reader never selected :180 row-1 null row-1 still rendered (aria-rowindex=13) one-shot on controller settle
population-change › still paints the selection back… :229 paint report rows row-10..row-19 (previous window), nothing selected one-shot
gate-blip › keeps selection + cursor across an estimated total :179 after [] — DOM still at rows 10..19 one-shot
gate-blip › LOCAL MODE :216 cursor [] ["row-8","name"] — row-8 not yet removed from DOM one-shot
selection-surface › survives an incremental slide… :173 row-1 null row-1 still rendered (aria-rowindex=8) one-shot

No real race. Traced the eviction → selection → paint flow to rule out a product bug:

  • create-grid-ui-core.ts observeRowModelRevision: a controller observation whose revision no longer matches the model's current snapshot is skipped (capturedRevision !== revision), so a lagging intermediate revision is never reconciled against a newer window (the "chimera" the getWindowing comment warns about cannot happen through this path).
  • The transient render that draws new rows under the pre-reconcile selection fails closed: indexedRangeContainsCell → rangeDatasetSpan → spanReadableInWindow refuses a span whose datasetTotal differs from the window's, so a prepended population paints nothing even for one frame.
  • Every captured failure had zero rows painted selected that were not selected.

Deterministic reproduction without load

Temporarily replacing the controller's scheduler hop with setTimeout(task, 30) (not committed): 5 of 11 eviction tests failed, including all three of the issue's cluster members, with identical DOM-lag shapes. With the poll in place, 11/11 under the same injected delay.

Fix

Poll for what the controller drew (waitFor, 15s, per-test 20s timeouts like the sibling indexed-rendering file):

  • settledRows(container, ids) — exact row ids in DOM order. Once they match, the same commit has already run observeRowModelRevision, so the paint read next is the settled one.
  • gate-blip settledWindow(container, ids, spacerRows) — ids plus the leading spacer the plan placed the first row under (rows[].top is global). A shut gate draws 0 spacer rows, an open one windowStart — this is the only observable difference between the two gates, and it pins that a shut gate really draws no spacer.
  • gate-blip's "gate restored, same window" step gets no wait, because it never reached the engine: identical rows are not an effective setRows (create-local-row-model.ts bumps the revision only when drafted.effective) and the controller deliberately does not replan on a resultMeta-only change (pretable-surface.tsx, windowGap comment). The old 20ms sleep there was waiting for nothing; the test's real reconciles are the blip revision and the return slide, both polled.

No claim was weakened: the negative "paints nothing" assertion is read only after the positive ids poll settles, so it cannot pass on a stale DOM.

Mutation results (poll in place, each reverted)

mutation result
A: spanReadableInWindow fails open on datasetTotal paints nothing rather than rows the reader never selected RED — new-1..new-4, row-0 painted selected
B: observeRowModelRevision reads a shut gate as local mode (windowed: selectionWindow !== null) both shut-gate tests RED (after: []), CONTROL + LOCAL MODE green
C: spanReadableInWindow always false survives an incremental slide… then extends RED (extended span undefined), plus two other span readers

Loaded-run tallies (full @pretable/react suite, 3 concurrent + 10 yes spinners, 10-core Mac)

  • Before: 12 runs, 1-min load 138–270. 2 failed, both eviction-population-change (run 2-2 still paints the selection back…, run 4-1 paints nothing…). 1778 tests/run.
  • After: 12 runs, 1-min load 195–227, same method. 0 failed — 1778/1778 in every run.

Also under load, no file other than eviction-* failed in either series.

Residual (not changed here)

rejected-rows-data-honesty.test.tsx:597 uses the same act(setTimeout(20)) settle. It did not fail in the issue's runs or in these 24, so it is left alone; it is the same class if it ever does.

🤖 Generated with Claude Code

blove and others added 2 commits September 3, 2026 07:39
The three eviction-* files settled a window slide with a fixed 20ms
`setTimeout` inside `act`. `setRows` lands synchronously, but the row layout
controller draws the new rows across scheduler hops (MessageChannel
macrotasks), and under CPU starvation those hops outlast the sleep: the
assertion then reads a DOM that is one window BEHIND the props — `row-1`
still rendered, already announced at the new window's `aria-rowindex`.
Reproduced 2/12 loaded full-suite runs (1-min load 138-270) and, without
load, 5/11 tests by injecting a 30ms scheduler hop at the seam.

Every one of the five failures had that shape; none was a wrong paint. The
product's ordering is sound: grid-core skips a controller observation whose
revision no longer matches the model (`create-grid-ui-core.ts`,
`capturedRevision !== revision`), so a lagging revision is never reconciled
against a newer window, and the transient paint of new rows under the
pre-reconcile selection fails closed on `datasetTotal`.

Poll for what the controller drew: the exact row ids (and, in the gate-blip
file, the leading spacer the plan placed them under, which is the only
observable difference between a shut and an open gate). Once the ids match,
the same commit has run `observeRowModelRevision`, so the paint read next is
the settled one. The gate-blip "gate restored" step gets no wait at all,
because it never reached the engine: identical rows are not an effective
`setRows` (revision unchanged) and the controller deliberately does not
replan on a resultMeta-only change.

Mutation-checked with the poll in place: failing `spanReadableInWindow`
open on the population size paints `new-1..row-0` selected (negative test
red); reading a shut gate as local mode destroys the selection (both blip
tests red, CONTROL green); refusing remembered spans breaks the
extend-from-evicted-anchor test.

Refs #548

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gine

The restore step's comment claimed the revision never moves. True for the
estimated-total blip (identical rows are not an effective setRows), not for
the engine-sort blip, whose processing change recompiles the query as its
own revision. Either way it draws the same rows under the same spacer and
the assertions read the return slide, which is what the step now says.

Refs #548

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 3, 2026 3:15pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-7b00l7r9f-cacheplane.vercel.app
Commit: 444fe4834d4f42d6fb6604920ac848a4c3d0f1c3

Updated automatically by the deploy-preview job.

…cted-rows settle, shared window-settle helpers

Three review items on the #548 fix, all test-side.

1. eviction-gate-blip: the "gate restored" step was unawaited for every
   blip, and its comment claimed the revision never moves. True for the
   total blip (identical rows are not an effective setRows, and the
   controller does not replan on a resultMeta-only change) but not for the
   processing blip: restoring the sort authority recompiles the query as a
   revision, the controller redraws rows 10..19 under the reopened gate's
   10-row spacer, and grid-core reconciles the still-evicted selection
   under the open gate. Unawaited, whether that reconcile ran or was skipped
   as stale behind the return slide was timing-dependent. It is now awaited
   (`restoreSpacerRows: 10`) so the observed path is exercised every run;
   the total blip and the control keep `null` — nothing reaches the
   controller there.

2. rejected-rows-data-honesty: the same `act(setTimeout(20))` settle, with a
   WRONG-VERDICT failure mode rather than lag. The step under test lands
   asynchronously (setQuery transition, controller redraw, then
   observeRowModelRevision in that commit) and grid-core skips an
   observation the model has moved past; if the slide back landed first,
   the step never reconciled, both unwindowed legs read the full selection,
   and the differential passed vacuously (or the control failed with the
   wrong verdict). Settles are now per leg, by what each can observe:
   - keep-window leg: nothing observable (same rows, same spacer, retaining
     reconcile) — said at the site, not awaited;
   - control leg: the leading spacer collapsing to zero rows, the plan the
     controller draws once `resultMeta.window` is gone;
   - refused leg: no DOM observable — a rejected write RETAINS the window
     coherent with the rows on screen, so the spacer stays at ten rows — so
     it polls the engine's verdict through telemetry `selectedRowId`,
     matched to the control leg's settled verdict (pinned null) so the
     differential stays answer-agnostic.
   Mutation-checked: forcing the reconcile to `windowed: true` turns the
   control leg red at `toEqual([])` with the full selection received.

3. `settledRows`/`settledWindow`/`windowIds`/`renderedRowIds` hoisted into
   `__tests__/window-settle.ts`, following the directory's flat-module
   convention (`rejected-write-harness.ts`), and used by all four files.

Refs #548

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@blove

blove commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Review fixes — 444fe483

  1. gate-blip restore step — comment corrected; the processing-blip restore is now awaited (settledWindow(ids(10), 10): rows 10..19 under the reopened gate's 10-row spacer) via a restoreSpacerRows: number | null param, so the observed-N+2 reconcile is exercised every run. Total blip and CONTROL pass null — verified nothing reaches the controller there (identical rows → drafted.effective false → no revision; no replan on resultMeta-only change).
  2. rejected-rows-data-honesty:597 converted, per leg by observable: keep-window → none (said at the site, unawaited); control → leading spacer collapses to 0 rows (settledWindow(SECOND, 0)); refused → no DOM observable — a rejected write retains the coherent window so the spacer stays at 10 rows (the sibling test pins 102..104) — so it polls telemetry selectedRowId to the control leg's settled verdict (pinned null), keeping the differential answer-agnostic. Also checked controller.setViewport on queryChanged: it republishes a stale-snapshot plan only when viewport values change, and they don't here, so the spacer collapse cannot precede the revision landing. Mutation: forcing the reconcile to windowed: true → control leg red at toEqual([]), full selection received.
  3. Helpers hoisted to packages/react/src/__tests__/window-settle.ts (flat-module convention of the directory, like rejected-write-harness.ts); all four files import it.

Verification: 4 files normally → 21/21; lint, typecheck, prettier clean. Loaded pass (3 concurrent full suites + 10 spinners, 1-min load 176): 3/3 × 1778 passed.

Spacer-staleness behavior untouched, per instruction.

@blove
blove enabled auto-merge (squash) September 3, 2026 15:18
@blove
blove merged commit 0af4bc3 into main Sep 3, 2026
21 checks passed
@blove
blove deleted the blove/eviction-flake-cluster branch September 3, 2026 15:32
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.

Flake cluster: eviction-* react tests fail under load (5 of 12 full-suite runs)

1 participant