Repository navigation
fix(core): bound CDP sends so a stalled browser can't hang the CLI #2401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,52 @@ | ||
| import EventEmitter from 'events'; | ||
| import logger from '@percy/logger'; | ||
|
|
||
| // Node-side ceiling on a single CDP round trip. Chrome does not always answer: | ||
| // a renderer that is frozen, starved of CPU, or swapped out keeps its target | ||
| // attached and simply never replies. In-page deadlines cannot rescue that case | ||
| // — their timers live in the very renderer that stopped running — so with no | ||
| // deadline here `send()` never settles and the whole CLI blocks forever with | ||
| // no error, no log and an unfinalized build. Reproducible by freezing the | ||
| // renderer (`Page.setWebLifecycleState`) or descheduling it (SIGSTOP), both of | ||
| // which a small, memory-pressured CI agent can do on its own. | ||
| export const DEFAULT_CDP_TIMEOUT = 120000; | ||
|
|
||
| // The longest wait a caller could legitimately be running *inside* the page, | ||
| // doubled. Keeps the deadline above those so raising one of them can never | ||
| // turn into a spurious protocol timeout. | ||
| function inPageCeiling() { | ||
| return Math.max( | ||
| parseInt(process.env.PERCY_PAGE_LOAD_TIMEOUT, 10) || 0, | ||
| parseInt(process.env.PERCY_STORY_RENDER_TIMEOUT, 10) || 0 | ||
| ) * 2; | ||
| } | ||
|
|
||
| // Resolved once per process, and memoized so a mid-run env change cannot make | ||
| // concurrent sessions disagree. `PERCY_CDP_TIMEOUT=0` disables the deadline. | ||
| export function cdpTimeout() { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Timeout resolves into process-global, first-caller-wins state The resolved deadline is frozen on the Recorded Low rather than Medium: this mirrors the pre-existing Suggestion: scope the resolved value to the Reviewer: stack-code-reviewer |
||
| if (Session.TIMEOUT !== undefined) return Session.TIMEOUT; | ||
| let configured = parseInt(process.env.PERCY_CDP_TIMEOUT, 10); | ||
|
|
||
| Session.TIMEOUT = Number.isNaN(configured) | ||
| ? Math.max(DEFAULT_CDP_TIMEOUT, inPageCeiling()) | ||
| : Math.max(0, configured); | ||
|
|
||
| return Session.TIMEOUT; | ||
| } | ||
|
|
||
| // Shared by both CDP deadlines - the session-scoped one below and the | ||
| // browser-scoped one in browser.js. | ||
| export function cdpTimeoutMessage(method, timeout) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] CDP timeout errors are not recognised by the browser-restart retry heuristic
Recorded Medium rather than the High originally proposed: Suggestion: export an Reviewer: stack-code-reviewer |
||
| return `Protocol error (${method}): Timed out after ${timeout}ms ` + | ||
| 'waiting for a response from the browser. The browser stopped responding — a ' + | ||
| 'renderer or browser process that is frozen, starved of CPU, or swapped out ' + | ||
| 'never replies and never runs its own timers. Raise PERCY_CDP_TIMEOUT if this ' + | ||
| 'command is legitimately slower than that, or set it to 0 to disable the deadline.'; | ||
| } | ||
|
|
||
| export class Session extends EventEmitter { | ||
| static TIMEOUT = undefined; | ||
|
|
||
| #callbacks = new Map(); | ||
|
|
||
| log = logger('core:session'); | ||
|
|
@@ -45,9 +90,24 @@ export class Session extends EventEmitter { | |
| // send a raw message to the browser so we can provide a sessionId | ||
| let id = await this.browser.send({ sessionId: this.sessionId, method, params }); | ||
|
|
||
| // will resolve or reject when a matching response is received | ||
| // will resolve or reject when a matching response is received, or when the | ||
| // deadline below expires because no response is ever coming | ||
| return new Promise((resolve, reject) => { | ||
| this.#callbacks.set(id, { error: new Error(), resolve, reject, method }); | ||
| let callback = { error: new Error(), resolve, reject, method }; | ||
| let timeout = cdpTimeout(); | ||
|
|
||
| if (timeout > 0) { | ||
| callback.timer = setTimeout(() => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Deadline timers are not Same at Suggestion: Reviewer: stack-code-reviewer |
||
| this.#callbacks.delete(id); | ||
| this.log.debug(`Protocol timeout (${method}) after ${timeout}ms`); | ||
|
|
||
| reject(Object.assign(callback.error, { | ||
| message: cdpTimeoutMessage(method, timeout) | ||
| })); | ||
| }, timeout); | ||
| } | ||
|
|
||
| this.#callbacks.set(id, callback); | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -56,6 +116,7 @@ export class Session extends EventEmitter { | |
| // resolve or reject a pending promise created with #send() | ||
| let callback = this.#callbacks.get(data.id); | ||
| this.#callbacks.delete(data.id); | ||
| clearTimeout(callback.timer); | ||
|
|
||
| /* istanbul ignore next: races with browser._handleMessage() */ | ||
| if (data.error) { | ||
|
|
@@ -77,6 +138,7 @@ export class Session extends EventEmitter { | |
|
|
||
| // reject any pending callbacks | ||
| for (let callback of this.#callbacks.values()) { | ||
| clearTimeout(callback.timer); | ||
| callback.reject(Object.assign(callback.error, { | ||
| message: `Protocol error (${callback.method}): ${this.closedReason}` | ||
| })); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import { setupTest } from '../helpers/index.js'; | ||
| import { Session } from '../../src/session.js'; | ||
| import { Browser } from '../../src/browser.js'; | ||
|
|
||
| // A browser stub whose send() only hands back a message id and never delivers | ||
| // a reply, which is what a frozen or starved renderer looks like from Node. | ||
| function makeBrowser() { | ||
| let lastId = 0; | ||
| return { | ||
| sessions: new Map(), | ||
| percy: { config: { discovery: {} } }, | ||
| sent: [], | ||
| async send(message) { | ||
| this.sent.push(message); | ||
| return ++lastId; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| // browser.send() is awaited before the callback is registered, so give that | ||
| // microtask a chance to land before simulating a reply | ||
| const tick = () => new Promise(r => setTimeout(r, 10)); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Specs assert timing with real sleeps rather than fake timers The 10ms Suggestion: use Reviewer: stack-code-reviewer |
||
|
|
||
| function makeSession(browser) { | ||
| return new Session(browser, { | ||
| params: { | ||
| sessionId: 'session-1', | ||
| targetInfo: { targetId: 'target-1', type: 'page' } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| describe('Unit / Session', () => { | ||
| let browser, session; | ||
|
|
||
| beforeEach(async () => { | ||
| await setupTest(); | ||
| Session.TIMEOUT = undefined; | ||
| delete process.env.PERCY_CDP_TIMEOUT; | ||
| browser = makeBrowser(); | ||
| session = makeSession(browser); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| Session.TIMEOUT = undefined; | ||
| delete process.env.PERCY_CDP_TIMEOUT; | ||
| }); | ||
|
|
||
| // A renderer that stops running (frozen, starved, swapped out) | ||
| // keeps its target attached and simply never replies. In-page deadlines | ||
| // cannot save us — their timers live in that same stalled renderer — so | ||
| // without a Node-side deadline the whole CLI blocks forever, silently. | ||
| it('rejects a pending command when the browser never replies', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '150'; | ||
|
|
||
| await expectAsync(session.send('Runtime.callFunctionOn', {})) | ||
| .toBeRejectedWithError(/Protocol error \(Runtime\.callFunctionOn\): Timed out after 150ms/); | ||
| }); | ||
|
|
||
| it('names the command and points at the override in the error', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '100'; | ||
|
|
||
| await expectAsync(session.send('Page.navigate', {})) | ||
| .toBeRejectedWithError(/Page\.navigate[\s\S]*PERCY_CDP_TIMEOUT/); | ||
| }); | ||
|
|
||
| it('does not reject when a reply arrives before the deadline', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '2000'; | ||
|
|
||
| let pending = session.send('Runtime.evaluate', {}); | ||
| await tick(); | ||
| session._handleMessage({ id: 1, result: { value: 'ok' } }); | ||
|
|
||
| await expectAsync(pending).toBeResolvedTo({ value: 'ok' }); | ||
| }); | ||
|
|
||
| it('clears the deadline once a reply arrives, so it cannot fire later', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '100'; | ||
|
|
||
| let pending = session.send('Runtime.evaluate', {}); | ||
| await tick(); | ||
| session._handleMessage({ id: 1, result: { value: 'ok' } }); | ||
| await expectAsync(pending).toBeResolvedTo({ value: 'ok' }); | ||
|
|
||
| // outlive the deadline - nothing should blow up or reject afterwards | ||
| await new Promise(r => setTimeout(r, 250)); | ||
| }); | ||
|
|
||
| it('clears the deadline when the session closes first', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '5000'; | ||
|
|
||
| let pending = session.send('Runtime.evaluate', {}); | ||
| await tick(); | ||
| session._handleClose(); | ||
|
|
||
| await expectAsync(pending).toBeRejectedWithError(/Session closed\./); | ||
| }); | ||
|
|
||
| it('can be disabled with PERCY_CDP_TIMEOUT=0', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '0'; | ||
|
|
||
| let settled = false; | ||
| session.send('Runtime.evaluate', {}).then(() => { settled = true; }, () => { settled = true; }); | ||
|
|
||
| await new Promise(r => setTimeout(r, 200)); | ||
| expect(settled).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| // The browser process can go silent the same way a renderer can. Target | ||
| // creation and attachment happen on the browser-scoped connection, before any | ||
| // page exists, so a deadline on Session alone leaves that path unbounded. | ||
| describe('Unit / Browser CDP deadline', () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Browser-scope specs live in This block is here because the shared Suggestion: move the shared exports into a small Reviewer: stack-code-reviewer |
||
| let browser; | ||
|
|
||
| beforeEach(async () => { | ||
| await setupTest(); | ||
| Session.TIMEOUT = undefined; | ||
| delete process.env.PERCY_CDP_TIMEOUT; | ||
| browser = new Browser({ config: { discovery: {} } }); | ||
| browser.ws = { send: () => {} }; | ||
| browser.isConnected = () => true; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| Session.TIMEOUT = undefined; | ||
| delete process.env.PERCY_CDP_TIMEOUT; | ||
| }); | ||
|
|
||
| it('rejects a pending browser command when no reply arrives', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '150'; | ||
|
|
||
| await expectAsync(browser.send('Target.createBrowserContext', {})) | ||
| .toBeRejectedWithError(/Protocol error \(Target\.createBrowserContext\): Timed out after 150ms/); | ||
| }); | ||
|
|
||
| it('does not reject when a reply arrives before the deadline', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '2000'; | ||
|
|
||
| let pending = browser.send('Target.createTarget', {}); | ||
| browser._handleMessage(JSON.stringify({ id: 1, result: { targetId: 't1' } })); | ||
|
|
||
| await expectAsync(pending).toBeResolvedTo({ targetId: 't1' }); | ||
| }); | ||
|
|
||
| it('clears the deadline once a reply arrives, so it cannot fire later', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '100'; | ||
|
|
||
| let pending = browser.send('Target.attachToTarget', {}); | ||
| browser._handleMessage(JSON.stringify({ id: 1, result: { sessionId: 's1' } })); | ||
| await expectAsync(pending).toBeResolvedTo({ sessionId: 's1' }); | ||
|
|
||
| await new Promise(r => setTimeout(r, 250)); | ||
| }); | ||
|
|
||
| it('leaves the raw-message path untimed, since a session owns that reply', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '100'; | ||
|
|
||
| // passing a raw message returns the id synchronously - no promise to bound | ||
| await expectAsync(browser.send({ sessionId: 's1', method: 'Runtime.evaluate' })) | ||
| .toBeResolvedTo(1); | ||
| }); | ||
|
|
||
| it('can be disabled with PERCY_CDP_TIMEOUT=0', async () => { | ||
| process.env.PERCY_CDP_TIMEOUT = '0'; | ||
|
|
||
| let settled = false; | ||
| browser.send('Target.createBrowserContext', {}).then(() => { settled = true; }, () => { settled = true; }); | ||
|
|
||
| await new Promise(r => setTimeout(r, 200)); | ||
| expect(settled).toBe(false); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low]
PERCY_STORY_RENDER_TIMEOUThas no consumer in this repoThis is its only occurrence in the monorepo, so a reader cannot tell from here whether it is intentional or a leftover. It is intentional — the Storybook SDK sets it outside this repo, and keeping the Node deadline above that in-page wait is the whole point of
inPageCeiling().Suggestion: name the owning package in the comment, e.g. "set by
@percy/storybook; not read anywhere else in this repo", so the reference explains itself.Reviewer: stack-code-reviewer