Skip to content

test(interaction-harness): a block that begins after the motion ended throws BusyWindowTooLateError - #9505

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:test/busy-window-too-late
Nov 3, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:test/busy-window-too-late

Conversation

@vjeux

@vjeux vjeux commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

On a starved runner, a busy-main-thread proof can block after the motion it is meant to catch has ended, and the result reads as the component failing.

  • blockMainThread's during counted an animation once it had run minProgressMs, however far along it was.
  • When the page first looked after a curve had reached its held tail (keyframes that end in a run of identical values), it blocked there.
  • busyWindowProof then judged an empty or negative window, for example "−19 ms judged … 0 frames presented … the compositor presented too little in this window to judge".

Intended invariant

A block that begins after the motion it was meant to catch had ended is named for what it is, a red that carries the times: BusyWindowTooLateError. It never reads as a judgment of the component, and it is never a pass.

Change

  • during counts an animation only before its curve's held tail. The curve end is read the way waitForRest reads it. It rejects with BusyWindowTooLateError (current time, curve end) in two cases:
    • the only animation running in that tail started while the page waited;
    • the wait times out with nothing moving, after it saw such an animation, even when that run has since finished or been cancelled (as Core's animator cancels a held run once its curve ends).
    • An older animation already holding its end when the wait begins is passed over, and the page waits for one that moves.
  • busyWindowProof throws the same error when the tracked value had settled before the judged part began (settle time, judged start, frames presented). A short positive window stays a verdict failure ("N frames < 4").
    • A sparse screencast can deliver the block's first frame after the judged part begins. Then the proof also throws when the frame before the block and the block's first frame are both within the bound, and it reads the settle time from the earlier frame.
  • The curve end moves into its own module, curve-end.ts, so the wait installs it without the animation recorder. installCurveEnd installs it and the hold in one round trip, as before.
  • The README describes both.

Evidence

  • Failure before the change:
    • a run that starts while the page waits, already 1.5 s into a 3 s run whose curve ends at 900 ms: main's wait blocks on it (3 of 3);
    • an older run held in its tail when the wait begins: main blocks before the moving run starts (3 of 3);
    • a run that settled 50 ms before the block: main's proof returns a "−N frames < 4" verdict;
    • a held run the wait saw, cancelled before it times out: the first version of this change timed out with "no animation started" (3 of 3).
    • a run that settled 100 ms before the block, as a screencast delivering one frame in six shows it: the previous version of this change returned the "judged … frames < 4" verdict in 4 of the 6 frame phases.
  • Success after the change:
    • the first rejects with BusyWindowTooLateError (curve end 900 ms, current time at least 1,500 ms, started while waiting);
    • the second blocks after the moving run starts;
    • the third throws the named error with its numbers;
    • the fourth rejects with BusyWindowTooLateError from the run it saw (curve end 900 ms, started before the wait).
    • the fifth throws BusyWindowTooLateError in all 6 frame phases, with the settle read from the frame before the block (for example, "from 50 ms before the block began").
    • A run that settles 40 ms into the block keeps its verdict failure.
  • Gates:
    • harness units: 105 of 105;
    • the harness spec in Chromium: 87 passed, 1 skipped;
    • the harness spec, the motion animator spec and the month scroller spec in WebKit (in the Playwright container): 93 passed, 17 Chromium-only skipped;
    • the animator and month scroller specs in Chromium: 22 of 22.
  • The existing compositor-control self-test, which blocks with during: same time, pinned, 8 of 8 on main and 8 of 8 here.

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); packages/core specs that use blockMainThread with during.

Rebuilt on main 547d90a. busy.ts and busy.test.ts conflicted with three changes main made since this change's first base:

The rebuild keeps all three. The late-block branches time out on that real clock, and the settled-before check uses the bound busyWindowProof now computes (settledWithin, or settledWithinSteps cast steps), so its message prints that bound. The other four files carry this change's diff unchanged.

Local gates at 0d4adb1:

  • harness units: 125 of 125;
  • the harness spec in Chromium: 107 passed, 1 skipped;
  • the animator and month scroller specs in Chromium: 21 of 21;
  • the harness, animator and month scroller specs in WebKit, in the Playwright container with CI's WebKit settings (--cpus=4, CI=1, 1 worker): 112 passed, 17 Chromium-only skipped;
  • each part fails without itself: without the late-block rejection, both new browser tests fail; without the settled-before check, the two late-block unit tests fail; without the frame-before rule, the sparse-screencast unit test fails;
  • the harness typecheck, ESLint and Prettier on the changed files;
  • pnpm review:pre: no findings.

Local gates at 3561649, on the earlier base:

  • harness units (105);
  • the specs above;
  • the harness typecheck;
  • pnpm lint (0 errors);
  • changeset coverage (nothing ships).

@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 7:58pm UTC

Request Review

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

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 8:09 PM UTC

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 0d4adb1

Storybook · Sandbox · built from this PR's head

Current run: The current CI run concluded failure. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for one gap in the new during wait. When it passes over an animation that was already in its held tail, and that animation finishes or is cancelled before the timeout (Core's animator cancels held runs once the curve ends), blockMainThread rejects with "no animation started" rather than BusyWindowTooLateError. For example, the test's keyframes started 1.5 s into a 3 s run with timeout: 2_000 give that message, while a run still held at the deadline gets the named error. Remembering the held run the wait saw and rejecting with BusyWindowTooLate at the deadline, plus a browser case where that run ends before the timeout, would close it.

[Automated review]

Comment on lines +306 to +310
// In its held tail: started while the page waited, or what is left
// when the wait times out.
const late =
ran.find(a => !before.has(a)) ??
(performance.now() > deadline ? ran[0] : undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An animation already in before and in its held tail is only read again here, at the deadline, through ran[0]. If it finishes or is cancelled first (Core's animator cancels a held run once the main thread passes its curve end), ran is empty and the wait falls through to "no animation started". Remembering the held run the wait saw, for example heldSeen ??= ran[0] when nothing in ran is running, and using it here would keep this a BusyWindowTooLateError.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in 3561649.

  • The held run is remembered: the wait now keeps the first run it saw in its held tail, as it was then.
  • A timeout with nothing moving rejects with BusyWindowTooLateError from that run, even after the run has finished or been cancelled, rather than with "no animation started".
  • The new browser case: a run already held when the wait begins, cancelled 300 ms into a 1 s wait.
    • It now gets the named error (curve end 900 ms, started before the wait).
    • The previous head timed out with "no animation started", 3 runs of 3.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks ready to approve: the follow-up commit closes the earlier gap, so a held run the wait saw that ends before the timeout now rejects with BusyWindowTooLateError instead of "no animation started". The new browser case in harness.interaction.browser.spec.ts pins that path, and nothing else needs to change.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for one gap in the proof half of this change. The held-tail handling in blockMainThread with during works as described.

  • busyWindowProof takes the settle time from the first frame at or after window.from (busy.ts:696-709), so on a one-in-six screencast a block that starts 100 ms after the value settled still returns "judged = 0 frames < 4: the animation under test was not moving during the block" in 4 of 6 frame phases, although the title and README say such a block throws BusyWindowTooLateError.

Also throwing when the last frame before the block and the first frame in it are both within the bound, with settledAtMs read from the earlier frame and a one-in-six unit case, catches all six phases without changing the existing cases. Narrowing the title and README to a frame in the first marginMs would work too.

  • Not blocking: the branch now conflicts with main in busy.ts and busy.test.ts after #9545. Once rebased, the new throw sits in the settledWithinPx block, and its message should read settledWithinPx, not settledWithin.

[Automated review]

// block started after the motion had settled, so the window holds
// nothing to judge. That is when the block ran, not how the component
// moved.
if (arrived != null && arrived.t <= window.from + marginMs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On a sparse screencast this misses a late block. The first frame at or after window.from can land after marginMs even when the frame before the block already shows the final value. With one frame in six and the value settled 100 ms before the block, 4 of 6 phases fall through to the "judged = 0 frames < 4" verdict. Checking the last valued frame before window.from as well, with settledAtMs read from it, catches all six phases.

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 0d4adb1. When the block's first frame comes after the judged part begins, the proof now also reads the last frame before the block. If that frame and the block's first frame are both within the bound, it throws BusyWindowTooLateError, with settledAtMs from the earlier frame. A new unit case keeps one frame in six, with the value settled 100 ms before the block, and all six phases throw; it fails without the new rule. The message now prints settledWithinPx.

blockMainThread's `during` counted an animation once it had run
`minProgressMs`, however far along. On a starved runner the page first
looked when a curve was already in its held tail (its keyframes ending
in a run of identical values), blocked there, and the busy proof then
judged an empty or negative window, which read as the component
failing.

The wait now counts an animation only before its curve's held tail,
read by the same curve end `waitForRest` reads, now defined in its own
module so the wait can install it without the animation recorder. When
the only animation running there started while the page waited, or the
wait times out with only such animations, it rejects with
BusyWindowTooLateError, with the times. It keeps the first held run it
saw, so a run that finishes or is cancelled first still names the late
block. busyWindowProof throws the same error when the tracked value had
settled before the judged part began; a short positive window stays a
verdict failure. It also reads the frame before the block, so a sparse
screencast whose first frame in the block comes after the judged part
begins still names a block that began late.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks ready to approve. The frame-before rule closes the sparse-screencast gap: a block that began after the value settled now throws BusyWindowTooLateError in every phase of a one-in-six cast. The held-tail handling in blockMainThread with during works as described in Chromium and WebKit.

One non-blocking point you may want to fix while you are here:

  • The proof's error does not report when the value settled. settledAtMs is the first in-block frame within the bound, or the last frame before the block (busy.ts:788-797), so a value that settled 100 ms early reports anywhere from +17 ms to −67 ms by cast phase, and 0 on a dense cast. Walking back through the frames still within the bound would report the real settle time.

[Automated review]

before = f;
}
const firstInBlock = valued.find(f => f.t >= window.from);
const settledBy =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: settledBy is the first in-block frame within the bound, or the last frame before the block. It is never when the value settled. On a one-in-six cast, a value that settled 100 or 300 ms before the block reports settledAtMs of 0, +17, −67, −50, −33 or −17 ms by phase. A dense cast reports 0 for a settle 50, 100 or 200 ms early, and the message reads "from 0 ms after the block began". Walking back from that frame while Math.abs(value - final) <= settledWithinPx holds would report the settle time the error promises.

// nothing to judge. That is when the block ran, not how the component
// moved.
if (settledBy != null) {
const presented = tracked.filter(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this presented shadows the presented option, which holds the compositor's presentation times. These are screencast frames, so delivered would keep the two apart in both the code and the message.

@vjeux
vjeux marked this pull request as ready for review November 3, 2026 20:16
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 3, 2026
@cixzhang
cixzhang marked this pull request as draft November 3, 2026 20:57
@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 20:57
@cixzhang
cixzhang merged commit 9a74db4 into meta:main Nov 3, 2026
151 of 156 checks passed
@vjeux
vjeux deleted the test/busy-window-too-late branch November 3, 2026 21:41

This branch was successfully deployed

1 active deployment
Preview — 0d4adb19 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