fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287) - #2397

Open
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends
Open

fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397
pranavz28 wants to merge 1 commit into
percy:masterfrom
pranavz28:fix/PER-10287-bound-cdp-sends

Conversation

@pranavz28

@pranavz28pranavz28 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10287 — "Percy visual tests are stalling intermittently when run in CI pipeline".

Why this exists when PER-10287 already shipped a fix

percy-storybook#1354 bounded the storyRendered wait (PERCY_STORY_RENDER_TIMEOUT) and shipped in @percy/storybook10.0.1-beta.5 / 10.0.2. The stall still reproduces on 10.0.2 — 3 times in ~19 runs against the reported Storybook build, once on a completely idle machine (frozen at 4 snapshots, same story, polled 4+ minutes).

A CDP trace of a real stall shows the render wait was never the problem:

t=18874ms probe armed hero--home-page 30000
t=18979ms probe rendered hero--home-page <-- rendered
t=18983ms nav Page.frameScheduledNavigation reason=reload
t=19041ms evalSettle cdpError: Protocol error (Runtime.callFunctionOn):
Inspected target navigated or closed <-- settled
t=19043ms ctxCleared ... ctxCreated ... navigatedWithinDocument
... nothing from this target again, for the remaining ~180s.

The story rendered, the deadline was correctly cleared, and the eval settled with a proper Error. Execution stops after that. Page accounting in the stalled run: 7 pages created, 6 closed — the unclosed one is the story page. Page closed never logs for it, and Retrying Story: never appears — and the retry lives in the enclosing catch, i.e. after the finally that awaits page.close().

Root cause

Browser.send/Session.send register a callback in a map and return a promise settled only when a matching response arrives over the websocket (or when Browser.close() rejects everything). There is no timeout — the only setTimeout in browser.js is spawn()'s launch guard, and Session._handleClose() rejects only session callbacks, not browser-level ones.

withPage finally → Page.close() → Session.close()
→ await browser.send('Target.closeTarget', { targetId }) // unbounded

So a Target.closeTarget that Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside a finally. No error, no retry, no log line, and the build is never finalized; server-side it is force-closed by the reaper hours later, which is exactly the reported symptom.

Fix

1. One deadline at the chokepoint. Both callback maps now route through a single pendingCommand helper that attaches a deadline, so no protocol round-trip can hang the process — covering Target.closeTarget, Target.disposeBrowserContext, Runtime.callFunctionOn and every future command, for every SDK.

  • Default DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads and Runtime.callFunctionOn with awaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.
  • PERCY_CDP_TIMEOUT overrides it; 0 opts out entirely (single auditable escape hatch).
  • Timers are cleared on every settle path (_handleMessage in both classes, Browser.close, Session._handleClose) and unref'd, so they never hold the process open.

2. A tighter bound on cleanup.Target.closeTarget and Target.disposeBrowserContext get DEFAULT_CDP_CLOSE_TIMEOUT = 10000 — these run where blocking is worst and should be near-instant. A failed session close no longer propagates out of Page.close(), so cleanup can never swallow the real error or block the retry.

3. Page.eval no longer throws a raw exception.description. CDP omits that field when a page rejects with a string, undefined, null or false, so eval threw undefined:

in-page rejectionexception.descriptionold throw
reject(new Error(…))"Error: …"string
reject({ … })"Object"string
reject(42)"42"string
reject("some-id")absentundefined
reject(undefined) / null / falseabsentundefined

This is how the reported run surfaced as TypeError: Cannot read properties of undefined (reading 'isExecutionContextDestroyed') — the real cause was destroyed and the run aborted after 3 retries. Storybook emits STORY_MISSING with a bare story-id string when a story's chunk fails to load, which is a rejection with a string. normalizeEvalException now always returns an Error, falling back to exception.value then exceptionDetails.text.

Behaviour change worth flagging

Page.eval now rejects with an Error rather than a string. No in-repo consumer treats it as a string. @percy/storybook's withPage has a typeof error !== 'string' branch that strips stacks from string errors — that branch simply stops being taken, so its messages keep the stack. Cosmetic, but reviewers should see it.

Testing

packages/core/test/unit/cdp-timeout.test.js14 specs, 0 failures:

  • cdpTimeout — default, explicit override, PERCY_CDP_TIMEOUT, non-numeric env, 0 opt-out
  • pendingCommand — resolves on response; rejects an unanswered command with Protocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disabled
  • normalizeEvalException — keeps an error description; and returns a real Error for string / undefined / null / no-details rejections, each of which previously produced throw undefined

Full @percy/core suite left to CI.

Not in this PR

The @percy/storybook side is separate (different repo): optional-chaining the isExecutionContextDestroyed reads, wrapping non-Error channel payloads in evalSetCurrentStory, and a channel listener leak found in the same trace — channel.on('storyRendered', …) is never removed, so with one page serving many stories every prior story's handler fires on each render.

🤖 Generated with Claude Code

…the CLI (PER-10287)
Every `Browser.send`/`Session.send` promise was settled only when a matching
response arrived over the websocket. A command Chrome never answers therefore
blocked its caller forever, with no error and no log line.
That is not theoretical. Closing a target while it is mid-reload can leave
`Target.closeTarget` unanswered, and page cleanup awaits it from `Page.close()`
inside a `finally` — so the run goes silent, the retry that lives in the
enclosing `catch` never happens, and the build is never finalized. Server-side
the build is force-closed by the reaper hours later. Traced on a Storybook run
whose preview page reloads itself mid-transition: the story rendered, the eval
settled with a proper error, and then the story page's close never returned.
Route both callback maps through one `pendingCommand` helper that attaches a
deadline, so no protocol round-trip can hang the process — this covers
`Target.closeTarget`, `Target.disposeBrowserContext`, `Runtime.callFunctionOn`
and every future command. The default is deliberately far above any legitimate
command (the longest real waits are page loads and `awaitPromise` evals, both
tens of seconds) so it only ever converts an infinite hang into an actionable
error. `PERCY_CDP_TIMEOUT` overrides it; 0 opts out.
Cleanup commands run where blocking is worst and should be near-instant, so
`Target.closeTarget` and `Target.disposeBrowserContext` get a tighter 10s
bound, and a failed session close no longer propagates out of `Page.close()`.
Also stop `Page.eval` throwing a raw `exception.description`. CDP omits that
field when a page rejects with a string, `undefined`, `null` or `false`, so
`eval` threw `undefined` and callers reading a property off it died with an
unrelated `TypeError` that destroyed the real cause. Always throw an Error,
falling back to the exception value and then the exception text.
@pranavz28
pranavz28 requested a review from a team as a code ownerAugust 24, 2026 21:44
Sign up for freeto 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.

1 participant

@pranavz28