Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions packages/core/src/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import rimraf from 'rimraf';
import logger from '@percy/logger';
import install from './install.js';
import { MAX_CDP_PAYLOAD } from './network.js';
import Session from './session.js';
import Session, { cdpTimeout, cdpTimeoutMessage } from './session.js';
import Page from './page.js';

// Chrome features Percy disables for v143 new-headless asset discovery.
Expand Down Expand Up @@ -152,6 +152,7 @@ export class Browser extends EventEmitter {
// Reset state for fresh launch
this.readyState = null;
this._closed = null;
// close() above already cleared every pending callback and its deadline
this.#callbacks.clear();
this.sessions.clear();

Expand Down Expand Up @@ -204,6 +205,7 @@ export class Browser 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}): Browser closed.`
}));
Expand Down Expand Up @@ -294,9 +296,26 @@ export class Browser extends EventEmitter {
// send the message payload
this.ws.send(JSON.stringify({ id, 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. The
// browser process can go silent the same way a renderer can - target
// creation and attachment strand here otherwise, before any page exists.
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(() => {
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);
});
}
}
Expand Down Expand Up @@ -368,6 +387,7 @@ export class Browser 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 page._handleMessage() */
if (data.error) {
Expand Down
66 changes: 64 additions & 2 deletions packages/core/src/session.js
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] PERCY_STORY_RENDER_TIMEOUT has no consumer in this repo

This 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

) * 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() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Timeout resolves into process-global, first-caller-wins state

The resolved deadline is frozen on the Session class for the life of the process, so the first CDP send anywhere wins. Two Percy instances in one process with different config silently share the first value, and no shared test helper resets it — a latent cross-spec pollution vector (discovery.test.js:2137 sets PERCY_PAGE_LOAD_TIMEOUT=80000, which would feed inPageCeiling() if it were the first caller).

Recorded Low rather than Medium: this mirrors the pre-existing Page.TIMEOUT at page.js:75 / page.js:406-408 rather than introducing the pattern, and the new specs reset it in both beforeEach and afterEach.

Suggestion: scope the resolved value to the Browser/Percy instance, or export a reset hook wired into the shared setupTest(). Best done as a follow-up covering Page.TIMEOUT too.

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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] CDP timeout errors are not recognised by the browser-restart retry heuristic

discovery.js:722 restarts the browser on retry only when the error message contains Browser not connected, Browser closed, Session closed or Session crashed. The message this function builds matches none of them, so a protocol timeout never triggers percy.browser.restart().

Recorded Medium rather than the High originally proposed: browser.page(...) is called inside the retried generator (discovery.js:640), so each retry already gets a fresh page and renderer. For the frozen-renderer case this PR targets, the retry escapes the wedged renderer without a restart. The gap is real only for a wedged browser process, where the snapshot fails with a clear error after ~3x the deadline instead of hanging forever — slower recovery, not incorrect behaviour.

Suggestion: export an isCdpTimeoutError(error) predicate here next to cdpTimeoutMessage and use it in the onRetry check, so the restart path also fires on a protocol timeout and the contract is not a duplicated substring match.

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');
Expand Down Expand Up @@ -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(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Deadline timers are not unref'd

Same at browser.js:308. For a programmatic consumer that finishes without closing the session or browser, an in-flight command with no reply can hold the event loop open for the full deadline. Moot for percy exec, which calls process.exit() explicitly.

Suggestion: callback.timer.unref?.(), matching the existing precedent at server.js:226 — a watchdog timer should never be the reason a process cannot exit.

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);
});
}

Expand All @@ -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) {
Expand All @@ -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}`
}));
Expand Down
173 changes: 173 additions & 0 deletions packages/core/test/unit/session.test.js
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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Specs assert timing with real sleeps rather than fake timers

The 10ms tick() plus the 150/200/250ms waits add about a second of wall-clock time and make the "has not settled" assertions probabilistic under load rather than deterministic.

Suggestion: use jasmine.clock() for the scheduling assertions, keeping real timers only where the awaited browser.send() microtask genuinely needs them.

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', () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Low] Browser-scope specs live in session.test.js

This block is here because the shared cdpTimeout/cdpTimeoutMessage exports live in session.js, which hurts discoverability for anyone looking for browser specs.

Suggestion: move the shared exports into a small cdp-timeout.js imported by both — which also decouples browser.js from Session's internals — and give the browser specs their own file.

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);
});
});
Loading