Skip to content

test(interaction-harness): a busy proof excuses a hold at the block's start only at the position the page set - #9467

Open
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:test/busy-hold-set-before-block
Open

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:test/busy-hold-set-before-block

Conversation

@vjeux

@vjeux vjeux commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

The interaction harness's busy-main-thread proof excused too much at the block's start.

  • busyWindowProof excuses a hold that starts before the block and ends at a change inside it: WebKit holds a just-started compositor animation, such as a sheet's release, before presenting it.
  • It excused any such hold, including an animation that was already moving and froze at the block's start.
  • A 35 to 50 ms freeze from just before the block, followed by motion, passed.

Intended invariant

A hold at the block's start is excused only when it holds the position the page itself last set before the block, the one a late hand-off holds.

Change

  • busyWindowProof takes setBeforeBlock: the tracked value the page last set before the block, in the tracked box's coordinates, such as a drag's last position at its release.
    • The hold excuse applies only to a hold at that value, within one cast step (castStep): WebKit's cast reads positions on a 2 px grid, Chromium's on 1 px. A cast whose frames carry no step is matched within a pixel.
    • Without it, there is no hold excuse, and the hold is judged as a stall.
    • The at-rest start rule is unchanged.
  • The hold self-test holds at a half pixel (100.5 px) and passes that position as the page's, so WebKit's 2 px grid reads it up to a step off.
  • The README describes setBeforeBlock.

Evidence

  • Failure before the change: three new unit cases fail with main's busy.ts:
    • an animation moving up to the block, frozen from the frame before it until 50 ms in, then moving on, passes (excused);
    • the sheet's hold is excused with no position given;
    • the sheet's hold is excused when it holds a position the page did not set.
  • Success after the change: all three fail as they should, and the sheet's hold at its released position is still excused.
  • The excuse's other two rules stay pinned:
    • the case for a hold that starts inside the block passes the last position before the judged part as the page's;
    • a new case holds from the frame presented as the block starts, at the position the page set, which only the start rule refuses;
    • another shows the position the page set on one frame before the block, then no marker until 150 ms in, which only the two-frame rule refuses.
    • Removing either rule fails its case.
  • Each part fails without itself: without the position match, the case for a hold at a position the page did not set fails; with a 1 px match, the case for a 2 px cast that reads the hold a step off fails; without the start rule or the two-frame rule, its own case fails; the hold self-test without its position fails in Chromium (a 181.5 ms stall).
  • No caller on main loses the excuse. A local probe, not part of the change, logged every hold the old rule excused and this one does not. Over main's callers of busyWindowProof it logged none:
    • SegmentedControl, SliderTouch, Switch, usePager and the motion animator: 149 passed in Chromium, and 128 in WebKit with CI's settings;
    • the ProgressBar and CircularProgress motion specs in Chromium: 25 passed.
    • The probe does log the hold self-test run without its position.

Callers in open pull requests

Only a proof whose animation starts at a release just before its block, and is held at the block's start, used the hold excuse. If one of these now fails with a stall at the block's start, pass setBeforeBlock (the release position in the tracked box's coordinates) when rebasing.

Specs that call busyWindowProof in open pull requests, with the first pull request that adds each:

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

internal/interaction-harness: its unit tests, and harness.interaction.browser.spec.ts in Chromium and WebKit (playwright.interaction.config.ts).

Local gates at 0d8298e:

  • harness units: 132 of 132;
  • the harness spec in Chromium: 107 passed, 1 skipped;
  • the harness spec in WebKit, in the Playwright container with CI's WebKit settings (--cpus=4, CI=1, 1 worker): 95 passed, 13 Chromium-only skipped;
  • the half-pixel hold self-test ×3: 3 of 3 in Chromium and 3 of 3 in WebKit; with a 1 px match it fails 3 of 3 in WebKit (a 188 to 199 ms stall);
  • each rule fails a unit case without itself: the cast-step match, the start rule and the two-frame rule;
  • the harness typecheck, ESLint and Prettier on the changed files;
  • pnpm review:pre: no findings.

At the same change without the cast-step match, on the previous main (62e00cc7da): the self-test's rAF control, unchanged, pinned to two cores with 4 workers, passes 24 of 24.

Rebuilt on main f03257e as one commit. The earlier version also relaxed the harness self-test's rAF control ("a rAF one freezes"), which failed 5 runs in 24 on a loaded runner then. On this main the unchanged check passes 24 of 24 at that profile, so it stays as it is.

@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 3, 2026 11:53pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 4, 2026, 12:03 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 0d8298e

Storybook · Sandbox · built from this PR's head

Package size: no published package changed; no new dependencies.

API changes: none in this pull request

Committed API report behind this build: core 1 row; run pnpm api:report.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: narrowing the hold excuse to setBeforeBlock works and fails closed, but it leaves the excuse's other two rules untested. is not excused when it starts inside the block in busy.control.test.ts still passes no setBeforeBlock. Since this change, that test passes whatever the start rule and the two-frame rule in busy.ts say, and removing both rules keeps every harness unit test green. Passing the last value before the judged part as setBeforeBlock in that test restores its coverage, for example frames.filter(f => f.t < BLOCK.from + 20).at(-1)!.box!.y.

[Automated review]

// cast holds color in 2 by 2 pixel blocks, so a position reads up to a pixel
// off.
const heldFromBeforeBlock =
setBeforeBlock != null &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this gate, is not excused when it starts inside the block in busy.control.test.ts (which passes no setBeforeBlock) can no longer reach the start rule and the two-frame rule below. Removing both rules leaves every harness unit test passing. Passing frames.filter(f => f.t < BLOCK.from + 20).at(-1)!.box!.y as setBeforeBlock in that test restores the coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: that case now passes the last position before the judged part as setBeforeBlock, so it reaches both rules. At 0d8298e, deleting the start rule fails is not excused when its first held frame is the one at the block start, and deleting the two-frame rule fails is not excused when the position it holds shows on one frame only.

// block still reach the screen as it starts (one or two, more on a loaded
// runner), and then the compositor sends nothing while nothing changes,
// so the bound's verdict fails it as the control's does.
expect(setDuringBlock).toBe(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this assertion can't fail while blockMainThread without during spins inside a single page.evaluate, because no rAF callback runs during that loop. The stall and failure-message checks below are what prove the freeze.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, both addressed in f069d11.

  • Start and two-frame rules: is not excused when it starts inside the block now passes the last position before the judged part as setBeforeBlock, as you suggested, so it reaches the excuse's other rules again.
  • The start rule alone: a new case, is not excused when its first held frame is the one at the block start, holds from the frame presented as the block starts, at the position it is given. Only the start rule refuses it.
    • Removing the start rule fails that case.
    • Removing the start rule and the two-frame rule fails both cases.
  • The two-frame rule alone changes no verdict in these fixtures. A hold that starts before the block and reaches the judged part shows on every frame in between, so it has two frames whenever a frame lands there. When none does, judging starts at the next presented frame either way.
  • The non-blocking point: agreed, and the count of positions set during the block is gone. The 600 ms stall and the verdict's failures carry the freeze.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the start rule is now pinned and the description matches the head, but the two-frame rule still has no test. Deleting heldFrames >= 2 in busy.ts alone leaves every harness unit passing; judges a gap at the edge of the judged part from the screencast alone in busy.test.ts caught that on the base, and passing setBeforeBlock: frames.filter(f => f.t < BLOCK.from).at(-1)!.box!.y in that case restores the coverage.

[Automated review]

holdStart > 0 &&
prior[holdStart].t < window.from &&
heldFrames >= 2;
heldFrames >= 2 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting this line alone leaves every harness unit passing. judges a gap at the edge of the judged part from the screencast alone in busy.test.ts pinned it on the base, but it passes no setBeforeBlock, so since line 640 it never reaches this rule. Passing setBeforeBlock: frames.filter(f => f.t < BLOCK.from).at(-1)!.box!.y in that case restores the coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: is not excused when the position it holds shows on one frame only passes the position the page set, so it reaches this rule. At 0d8298e, deleting the rule fails that case.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Right, and I was wrong that it could not change a verdict. Fixed in 81a2fae.

  • The new case: is not excused when the position it holds shows on one frame only. The position the page set shows on one frame before the block, then the marker is not found on any frame until 150 ms in.
  • Why only that rule refuses it: a position shown on one frame is no hold, so the window is judged from the judged part's start. Without the two-frame rule, judging would start at the first position after the gap.
  • Mutants: removing heldFrames >= 2 now fails that case, and removing the start rule fails its own case.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further changes needed: the hold excuse now requires setBeforeBlock, each of its three conditions (the 1 px match, the start rule and the two-frame rule) fails its own unit case when removed, and the changed self-tests pass in Chromium and WebKit. Open pull requests whose proofs rely on the hold excuse will need to pass setBeforeBlock with the release position when they rebase.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: narrowing the hold excuse to setBeforeBlock works and fails closed, but its 1 px match is too tight for WebKit.

  • In busy.ts, a hold must sit within 1 px of setBeforeBlock, but WebKit's cast reads positions on a 2 px grid: the hold self-test's animation held at 100.5 px reads 1.5 px off in WebKit and fails as a stall in 3 of 3 runs, so match within one cast step (castStep) and add a half-pixel or odd-offset hold that pins it.

At 99, 100 and 101 px WebKit reads at most 1 px off and passes, Chromium passes every offset, and with the match under 2 px the 100.5 px hold passes in WebKit while a hold with no position still fails.

[Automated review]

prior[holdStart].t < window.from &&
heldFrames >= 2;
heldFrames >= 2 &&
Math.abs(lead[lead.length - 1] - setBeforeBlock) <= 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebKit's cast puts positions on a 2 px grid, so a held value can read more than 1 px off. This hold self-test's animation, held at 100.5 px with setBeforeBlock set to the rest position plus 100.5, reads 142 against 140.5 in WebKit and fails as a stall in 3 of 3 runs (99 and 101 px read 1 px off and pass). Matching within one cast step (castStep(tracked, axis), 1 px when no frame carries a step) passes it, while a hold with no position still fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d8298e: the hold matches setBeforeBlock within one cast step (castStep(tracked, axis), 1 px when no frame carries a step). The hold self-test now holds at 100.5 px. It passes 3 of 3 in WebKit with CI's settings, and fails 3 of 3 with the 1 px match. Two unit cases pin both sides: a 2 px cast that reads the hold 1.5 px off is excused, and the same offset on a stepless cast is not.

busyWindowProof excused any hold that starts before the block and ends
at a change inside it, as WebKit holds a just-started compositor
animation, such as a sheet's release, before presenting it. An animation
that was already moving and froze at the block's start passed too: a
35 to 50 ms freeze from just before the block, then motion.

The proof now takes setBeforeBlock, the tracked value the page last set
before the block, in the tracked box's coordinates. The hold excuse
applies only to a hold at that value, within one cast step: WebKit's
cast reads positions on a 2 px grid. Without it, the hold is judged as
a stall. The at-rest start rule is unchanged.

The hold self-test holds at a half pixel and passes that position as
the page's, and the README describes setBeforeBlock.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further changes needed: the hold now matches setBeforeBlock within one cast step, so WebKit's 2 px cast reading the self-test's 100.5 px hold 1.5 px off is excused, while a hold with no position or at another position still fails.

  • Non-blocking: no unit case refuses a hold at another position on frames that carry a step, the branch every real cast takes, so replacing castStep(tracked, axis) with Infinity keeps every unit passing; giving the 6 px-off case step: {x: 2, y: 2} would pin it.

Open pull requests whose WebKit proofs rely on the hold excuse need to pass setBeforeBlock with the release position when they rebase.

[Automated review]

Comment on lines +480 to +488
it('is not excused when it holds a position the page did not set', () => {
const proof = busyWindowProof(sheet(), BLOCK, {
axis: 'y',
control,
setBeforeBlock: released + 6,
});
expect(proof.verdict.pass, proof.summary).toBe(false);
expect(proof.onScreenFromMs).toBeNull();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this case runs on frames with no step, so it takes the 1 px fallback. Every real cast of the default marker carries a step and takes the castStep branch in busy.ts, where no case refuses a hold at another position: replacing castStep(tracked, axis) with Infinity keeps every unit passing. A stepped copy of this case pins it:

const grid = sheet().map(f => ({...f, step: {x: 2, y: 2}}));
const proof = busyWindowProof(grid, BLOCK, {axis: 'y', control, setBeforeBlock: released + 6});
expect(proof.verdict.pass, proof.summary).toBe(false);

@vjeux
vjeux marked this pull request as ready for review November 4, 2026 00:08
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 4, 2026
@cixzhang
cixzhang marked this pull request as draft November 4, 2026 01:32
@cixzhang
cixzhang marked this pull request as ready for review November 4, 2026 01:32

This branch was successfully deployed

1 active deployment
Preview — 0d8298e6 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants