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
Open
fix(core): bound CDP round-trips so an unanswered command can't hang the CLI (PER-10287)#2397pranavz28 wants to merge 1 commit into
pranavz28 wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
storyRenderedwait (PERCY_STORY_RENDER_TIMEOUT) and shipped in@percy/storybook10.0.1-beta.5/10.0.2. The stall still reproduces on10.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:
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 closednever logs for it, andRetrying Story:never appears — and the retry lives in the enclosingcatch, i.e. after thefinallythat awaitspage.close().Root cause
Browser.send/Session.sendregister a callback in a map and return a promise settled only when a matching response arrives over the websocket (or whenBrowser.close()rejects everything). There is no timeout — the onlysetTimeoutinbrowser.jsisspawn()'s launch guard, andSession._handleClose()rejects only session callbacks, not browser-level ones.So a
Target.closeTargetthat Chrome never answers — which a target closed mid-reload can produce — blocks forever, inside afinally. 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
pendingCommandhelper that attaches a deadline, so no protocol round-trip can hang the process — coveringTarget.closeTarget,Target.disposeBrowserContext,Runtime.callFunctionOnand every future command, for every SDK.DEFAULT_CDP_TIMEOUT = 300000. Deliberately far above any legitimate command — the longest real waits are page loads andRuntime.callFunctionOnwithawaitPromise: true, both tens of seconds — so it only ever converts an infinite hang into an actionable error.PERCY_CDP_TIMEOUToverrides it;0opts out entirely (single auditable escape hatch)._handleMessagein both classes,Browser.close,Session._handleClose) andunref'd, so they never hold the process open.2. A tighter bound on cleanup.
Target.closeTargetandTarget.disposeBrowserContextgetDEFAULT_CDP_CLOSE_TIMEOUT = 10000— these run where blocking is worst and should be near-instant. A failed session close no longer propagates out ofPage.close(), so cleanup can never swallow the real error or block the retry.3.
Page.evalno longer throws a rawexception.description. CDP omits that field when a page rejects with a string,undefined,nullorfalse, soevalthrewundefined:exception.descriptionthrowreject(new Error(…))"Error: …"reject({ … })"Object"reject(42)"42"reject("some-id")undefinedreject(undefined)/null/falseundefinedThis 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 emitsSTORY_MISSINGwith a bare story-id string when a story's chunk fails to load, which is a rejection with a string.normalizeEvalExceptionnow always returns anError, falling back toexception.valuethenexceptionDetails.text.Behaviour change worth flagging
Page.evalnow rejects with anErrorrather than a string. No in-repo consumer treats it as a string.@percy/storybook'swithPagehas atypeof 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.js— 14 specs, 0 failures:cdpTimeout— default, explicit override,PERCY_CDP_TIMEOUT, non-numeric env,0opt-outpendingCommand— resolves on response; rejects an unanswered command withProtocol error (…): Timed out after Nms; does not reject a command that settled before the deadline; registers no timer when disablednormalizeEvalException— keeps an error description; and returns a realErrorfor string /undefined/null/ no-details rejections, each of which previously producedthrow undefinedFull
@percy/coresuite left to CI.Not in this PR
The
@percy/storybookside is separate (different repo): optional-chaining theisExecutionContextDestroyedreads, wrapping non-Errorchannel payloads inevalSetCurrentStory, 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