fix(core): customElements wait read its timeout from the wrong argument, hanging the CLI forever (PER-10405) - #2383
Conversation
…nt, hanging the CLI forever (PER-10405)
`percy snapshot` hung indefinitely — no error, no timeout, no output — on any
page holding a custom-element tag that is never registered. Reported against
every live Next.js `next start` server: the app router mounts
`<next-route-announcer>`, which Next never passes to `customElements.define()`.
Regression in 1.32.0 (1.31.13 predates the wait); 1.32.0/1.32.3/1.32.6 all hang.
Root cause
----------
`serializeFunction()` wraps a string body as
`(async function eval(){ <body> })(percyHelpers, ...args)`, so `arguments[0]`
is the injected Percy helpers object and the caller's first argument lands at
`arguments[1]`. `WAIT_FOR_CUSTOM_ELEMENTS_BODY` read `arguments[0]`, so:
Date.now() + (arguments[0] || 500)
=> 1786389350594 + {config,snapshot,...}
=> "1786389350594[object Object]" // a String
`Date.now() >= deadline` then compared a number against that string, coerced to
NaN, and was permanently false. With `:not(:defined)` never clearing either, the
poll had no exit: it re-ticked every 100ms forever and the returned promise never
settled. The body is awaited over CDP with `awaitPromise: true`, which has no
protocol-level timeout, so `page.snapshot()` blocked forever. Nothing downstream
could report it — the run never even reached build creation, so no build appears
in Percy at all.
Fix
---
- Read the timeout from `arguments[1]`, validated as a finite positive number.
- Add a `settled` guard plus an absolute `setTimeout(finish, timeoutMs)` so the
promise cannot fail to settle even if the tick chain breaks. This is awaited
over CDP with no timeout of its own, so not settling must be structurally
impossible rather than merely unlikely.
- Route the tick chain's rejection path to `finish` and guard `whenDefined()`,
which throws SyntaxError on an invalid custom element name.
Verified against a bare `create-next-app` (next 16.3.0, node 22) served by
`next start`: 1.32.6 hangs past 120s and creates no build; patched, the same run
finalizes in 23s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cf387bc to
fb6efcd
Compare
pranavz28
left a comment
There was a problem hiding this comment.
We are changing core package. Recommended to run full SDK regression once
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| settled = true; | ||
| resolve(); | ||
| } | ||
| setTimeout(finish, timeoutMs); |
There was a problem hiding this comment.
[Low] Ceiling setTimeout is never cleared on early resolve
When finish() is reached via the normal tick path (the common case — :not(:defined) empties on the first tick), this ceiling timer stays pending in the page realm until timeoutMs elapses. Harmless, since finish() is idempotent via settled, but it is an avoidable lingering timer.
Suggestion: capture the id and clear it in finish():
var t = setTimeout(finish, timeoutMs);
function finish() {
if (settled) return;
settled = true;
clearTimeout(t);
resolve();
}Reviewer: stack-code-reviewer
| )).toBeResolved(); | ||
| let elapsed = Date.now() - start; | ||
|
|
||
| expect(elapsed).toBeLessThan(5000); |
There was a problem hiding this comment.
[Low] Elapsed assertion is upper-bound only
< 5000 against a 500 ms timeout guards against a hang but not much else. Since the fixture element is genuinely never defined, the only way this resolves well before the deadline is a future regression that exits the wait early for the wrong reason — which this assertion would not catch.
Suggestion: pin the deadline branch with a loose lower bound alongside it.
| expect(elapsed).toBeLessThan(5000); | |
| expect(elapsed).toBeGreaterThanOrEqual(400); | |
| expect(elapsed).toBeLessThan(5000); |
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2383 • Head: fb6efcd • Reviewers: stack-code-reviewer SummaryFixes an indefinite CLI hang (PER-10405) by correcting Review Table
Findings
Raised by other reviewers (not independently confirmed)
Verdict: PASS — root cause verified against the real |
Fixes PER-10405 / closes #2376
Symptom
percy snapshothangs indefinitely — no error, no timeout, no output — against any live Next.jsnext startserver. Bisects cleanly to@percy/cli: 1.31.13 works, 1.32.0 / 1.32.3 / 1.32.6 all hang identically. Reproduces on a completely unmodifiedcreate-next-app.The reporter ruled out (all correctly): Chromium version (forced 1.31.13's binary into a 1.32.3 run), custom
.percy.yml, snapshot count,localhostvs127.0.0.1, the disk-backed logger,autoConfigureAllowedHostnames, and page content (a byte-identical static mirror of the same page does not hang). Only live Next server + 1.32.x hangs.Root cause
packages/core/src/page.js:29—WAIT_FOR_CUSTOM_ELEMENTS_BODY(added in 1.32.0) read its timeout from the wrong argument index.serializeFunction()wraps a string body as:so
arguments[0]is the injected Percy helpers object and the caller's first argument lands atarguments[1]. The body readarguments[0]:The helpers object is truthy, so this became
number + object→ string concatenation. Probed live in the page during the repro:Date.now() >= deadlinetherefore coerced toNaNand was permanently false — the deadline could never fire.That is latent on most pages, because the other exit (
if (!undef.length) return resolve()) clears immediately. Next.js is what makes it fatal: the app router mounts<next-route-announcer>, a tag Next never passes tocustomElements.define(). It matches:not(:defined)forever, andcustomElements.whenDefined('next-route-announcer')never resolves. Probe from the same run:With both exits dead the poll re-ticked every 100 ms forever and the promise never settled. The body is awaited over CDP with
awaitPromise: true, which has no protocol-level timeout, sopage.snapshot()blocked forever.This also explains the two things that made the report confusing:
PERCY_NETWORK_IDLE_WAIT_TIMEOUTis not involved — instrumenting the run showsnetwork.idle()resolving normally twice before the hang.<next-route-announcer>is created by Next's client runtime at hydration, so it is absent from a static mirror of the server HTML →undef.length === 0→ immediate resolve.Not Next-specific in principle: any page holding a never-registered custom-element tag (blocked third-party widget loader, typo'd tag name) hits the same hang. Next.js just guarantees one on every page.
Blast radius
The run hangs before build creation — the CLI issues exactly one API call (
Fetching project domain config) and never callsPOST /builds. No build appears in Percy at all, so these failures leave no server-side trace and are invisible to backend telemetry. Users see a wedged CI job.Fix
packages/core/src/page.js:arguments[1], validated as a finite positive number. TheserializeFunctionargument-injection contract is now documented at the constant, since it is invisible from inside the body.settledguard plus an absolutesetTimeout(finish, timeoutMs). This promise is awaited over CDP with no timeout of its own, so failing to settle must be structurally impossible, not merely unlikely — a ceiling, independent of the poll's own bookkeeping.finish(previously.then(tick)with no rejection handler: a throw on any tick after the first orphaned the outer promise), and wrapwhenDefined(), which throwsSyntaxErroron an invalid custom element name.Why tests didn't catch this
Useful for anyone adding a page-realm body later: the existing specs never execute this one. Both identity-match the exported constant and then bypass it — one asserts the wait eval count is
0, the other replaces the eval withthrow new Error('boom'). The body only ever runs inside the page realm, whereserializeFunction's argument injection applies, so a node-side suite can't reach it by construction. The spec below is the first that runs it in a real browser.Testing
New regression spec in
packages/core/test/percy.test.js, using the real browser harness against<next-route-announcer>. It asserts the fixture really is stuck (:not(:defined)count is 1) before asserting the wait settles on its own ceiling, so it can't silently pass without exercising the deadline branch.End-to-end, against a bare
create-next-app(next@16.3.0,react@19.2.8, node 22) served bynext start -p 4176:@percy/cli@1.32.6timeout, no build created🤖 Generated with Claude Code