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
77 changes: 77 additions & 0 deletions src/lib/screenshot/fixtures/renderer-crash-child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { captureScreenshots } from '../screenshotter.ts';

const persistent = process.argv[2] === 'persistent';
const server = createServer((request, response) => {
if (request.url === '/pending') {
response.writeHead(200, { 'Content-Type': 'application/octet-stream' });
response.write('unfinished body');
request.on('close', () => response.destroy());
return;
}
response.end(`<!doctype html><html><head><title>Neutral fixture</title></head><body><header>Fixture header</header><main><h1>${request.url === '/healthy' ? 'Healthy later route' : 'Crash route'}</h1></main></body></html>`);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
mkdirSync('.tmp-test', { recursive: true });
const outputDir = mkdtempSync(join(process.cwd(), '.tmp-test/renderer-crash-'));
let crashes = 0;
let attempts = 0;
let browser;
const pending = [];
const cleanup = [];
const logs = [];
try {
const result = await captureScreenshots({
urls: [origin + '/', origin + '/healthy'], primaryUrl: origin + '/', outputDir,
concurrency: 1, settleMs: 0, evaluateTimeoutMs: 1000, captureImages: true,
viewports: [{ id: 'desktop', width: 800, height: 600 }],
server: { sendLoggingMessage: message => logs.push(message.data) },
observeSource: async page => {
browser = page.context().browser();
if (page.url() !== origin + '/') return;
attempts++;
if (!persistent && attempts > 1) return;
const cdp = await page.context().newCDPSession(page);
const response = page.waitForResponse(origin + '/pending');
pending.push(page.evaluate(() => fetch('/pending').then(r => r.text())).then(() => 'resolved', e => e.message));
pending.push((await response).body().then(() => 'resolved', e => e.message));
pending.push(page.evaluate(() => {
window.pendingEvaluationStarted = true;
return new Promise(() => {});
}).then(() => 'resolved', e => e.message));
await page.waitForFunction(() => window.pendingEvaluationStarted);
const crash = new Promise(resolve => page.once('crash', resolve));
pending.push(cdp.send('Page.crash').then(() => 'resolved', e => e.message));
await crash;
crashes++;
assert.equal(browser.isConnected(), true, 'only the renderer must fail');
},
onProgress: () => cleanup.push(browser.contexts().length),
});
const manifest = JSON.parse(readFileSync(result.manifestPath, 'utf8'));
const failurePath = join(outputDir, 'screenshots/failures.json');
const failures = existsSync(failurePath) ? JSON.parse(readFileSync(failurePath, 'utf8')) : [];
const healthy = manifest.entries[origin + '/healthy'];
assert.match(readFileSync(join(outputDir, healthy.html), 'utf8'), /Healthy later route/);
assert.ok(healthy.desktop, 'later route screenshot must exist');
const pendingOutcomes = await Promise.all(pending);
assert.ok(pendingOutcomes.every(message => /crashed|closed/i.test(message)), 'all pending work must reject and settle');
assert.deepEqual(cleanup, [0, 0], 'every route must close its contexts');
assert.equal(browser.isConnected(), false, 'capture must close its browser');
const require = createRequire(import.meta.url);
console.log(JSON.stringify({
node: process.version, platform: process.platform, playwright: require('playwright/package.json').version,
persistent, attempts, crashes, result, failures, cleanup, pendingOutcomes, logs,
entry: manifest.entries[origin + '/'], healthy,
}));
} finally {
await browser?.close().catch(() => {});
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
rmSync(outputDir, { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions src/lib/screenshot/screenshotter-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function makeHarvestPage() {
function makePage() {
let currentUrl = '';
return {
once: vi.fn(),
goto: vi.fn().mockImplementation( async ( url: string ) => {
currentUrl = url;
return { status: () => 200 };
Expand Down
40 changes: 40 additions & 0 deletions src/lib/screenshot/screenshotter-renderer-crash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, it } from 'vitest';

const run = promisify( execFile );
const fixture = fileURLToPath( new URL( './fixtures/renderer-crash-child.mjs', import.meta.url ) );

async function capture( mode: string ) {
// A fatal protocol assertion must fail the child, not disappear into Vitest's
// unhandled-error reporting. Exercise real Chromium and the capture pipeline.
const { stdout, stderr } = await run( process.execPath, [ '--import', 'tsx', fixture, mode ], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
} );
expect( stderr ).not.toMatch( /Assertion|uncaught|chrome-fidelity.*skipped/ );
return JSON.parse( stdout.trim().split( '\n' ).at( -1 )! );
}

describe( 'renderer crash recovery in a child process', () => {
it( 'retries once in a fresh context without restarting a connected browser', async () => {
const evidence = await capture( 'recover' );
expect( evidence.attempts ).toBe( 2 );
expect( evidence.crashes ).toBe( 1 );
expect( evidence.result ).toMatchObject( { captured: 2, failed: 0, browserRestarts: 0 } );
expect( evidence.failures ).toEqual( [] );
expect( evidence.entry ).toMatchObject( { html: 'html/homepage.html', desktop: 'screenshots/desktop/homepage.png' } );
expect( evidence.logs.filter( ( log: string ) => log.startsWith( '[retry]' ) ) ).toHaveLength( 1 );
}, 65_000 );

it( 'keeps a repeated crash failed with attempt two and captures the later route', async () => {
const evidence = await capture( 'persistent' );
expect( evidence.attempts ).toBe( 2 );
expect( evidence.crashes ).toBe( 2 );
expect( evidence.result ).toMatchObject( { captured: 1, failed: 1, browserRestarts: 0 } );
expect( evidence.failures ).toMatchObject( [ { stage: 'evaluate', error: 'source renderer crashed', attempt: 2 } ] );
expect( evidence.entry.html ).toBeUndefined();
expect( evidence.logs.filter( ( log: string ) => log.startsWith( '[retry]' ) ) ).toHaveLength( 1 );
}, 65_000 );
} );
1 change: 1 addition & 0 deletions src/lib/screenshot/screenshotter-resource-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function makePage( mobile: boolean, routedRequest?: object ) {
let routeHandler: ( route: object ) => Promise< void >;
let currentUrl = '';
return {
once: vi.fn(),
unroute: vi.fn().mockResolvedValue( undefined ),
route: vi.fn().mockImplementation( async ( _pattern, handler ) => {
routeHandler = handler;
Expand Down
1 change: 1 addition & 0 deletions src/lib/screenshot/screenshotter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function makeGoodPage(gotoStatus: number | ((url: string) => number) = 200) {
let currentUrl = '';
const statusOf = (url: string) => typeof gotoStatus === 'function' ? gotoStatus(url) : gotoStatus;
return {
once: vi.fn(),
goto: vi.fn().mockImplementation(async (url: string) => {
currentUrl = url;
return { status: () => statusOf(currentUrl) };
Expand Down
40 changes: 35 additions & 5 deletions src/lib/screenshot/screenshotter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ interface DesignCaptureContext {

interface CapturePerViewportArgs {
page: Page;
/** Stop best-effort stages after a crash; recovery belongs to the viewport loop. */
rendererCrashed: () => boolean;
learnFluid?: boolean;
fluidWidths?: number[];
collectResponsiveImages?: (
Expand Down Expand Up @@ -959,6 +961,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
return;
}
await args.observeSource?.( page, url, isDesktop ? 'desktop' : 'mobile', sourceErrors, args.browserProfile );
if ( args.rendererCrashed() ) return;
if ( plan.captureHtml || plan.captureMobileHtml ) {
try {
const native = await captureNativeViewTimelines( page, viewport.id, evaluateTimeoutMs );
Expand Down Expand Up @@ -1006,6 +1009,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
} );
}
}
if ( args.rendererCrashed() ) return;

await applyPagerSlideshowStates( page, pagerSlideshows ).catch( () => {
/* best-effort — a picker that will not advance must not block capture */
Expand All @@ -1019,6 +1023,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
/* best-effort — never block capture on a late platform widget */
} );
}
if ( args.rendererCrashed() ) return;

// Hydrated panels belong to the serialization transaction. Browser probes
// and width learning can rerender their source items, discarding injected
Expand Down Expand Up @@ -1054,6 +1059,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
}
}

if ( args.rendererCrashed() ) return;
if ( isDesktop && plan.captureHtml ) {
try {
// The cleanup observer may have exhausted its budget before the page
Expand Down Expand Up @@ -1115,6 +1121,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
} );
}
}
if ( args.rendererCrashed() ) return;

// --- mobile-DOM carry (mobile only) ---------------------------------------
// On the mobile pass, the mobile UA + isMobile emulation make JS builders like
Expand Down Expand Up @@ -1180,6 +1187,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
}

// --- scrolled screenshot --------------------------------------------------
if ( args.rendererCrashed() ) return;
if ( plan.captureScrolled ) {
try {
const docHeight = await page.evaluate( () => document.documentElement.scrollHeight );
Expand Down Expand Up @@ -1223,6 +1231,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
}

// --- desktop-only site analysis -------------------------------------------
if ( args.rendererCrashed() ) return;
if ( isDesktop && shouldAnalyze ) {
try {
const analysis = await analyzePage( page, evaluateTimeoutMs );
Expand All @@ -1238,6 +1247,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
attempt: 1,
} );
}
if ( args.rendererCrashed() ) return;
// Best-effort: capture source chrome computed-style fingerprint for later
// carry-vs-source fidelity audits. A failure here MUST NOT break the
// screenshot run — the try/catch ensures this is never propagated.
Expand All @@ -1261,6 +1271,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
}

// --- desktop-only design capture (page/post archetypes only) ---------------
if ( args.rendererCrashed() ) return;
if ( isDesktop && designCtx ) {
try {
// The design sidecar slug MUST match the WXR item slug used by adapters
Expand Down Expand Up @@ -1369,6 +1380,7 @@ async function capturePerViewport( args: CapturePerViewportArgs ): Promise< void
// HTML. Each viewport needs its own probe: a desktop dialog must not suppress
// a mobile-only trigger. Merge their bounded successful evidence rather than
// replacing a desktop-only dialog with a mobile-only menu.
if ( args.rendererCrashed() ) return;
const releaseNavigationLock = await lockMainFrameNavigation( page );
try {
await probeInteractions();
Expand Down Expand Up @@ -1841,13 +1853,13 @@ export async function captureScreenshots( opts: ScreenshotOpts ): Promise< Scree
const vpPlan = viewport.id === 'desktop' ? effectivePlan.desktop : effectivePlan.mobile;
if ( ! vpPlan.needsLoad ) continue;

// A browser that died under this viewport is replaced and the viewport
// retried once, so a crash costs the viewports in flight a retry rather
// than failing every route the pool claims afterwards.
// Retry a crashed renderer in a fresh context once. A disconnected browser
// also needs the shared relaunch before the retry.
for ( let crashRetry = false; ; crashRetry = true ) {
const attemptBrowser = browser;
const failuresBefore = urlFailures.length;
let context: BrowserContext | undefined;
let rendererCrashed = false;
try {
// deviceScaleFactor < 1 reduces the OUTPUT pixel count of every
// screenshot while keeping the rendered layout identical to a
Expand Down Expand Up @@ -1889,8 +1901,10 @@ export async function captureScreenshots( opts: ScreenshotOpts ): Promise< Scree
` );
await context.addInitScript( observeViewportEntrances );
const page = await context.newPage();
page.once( 'crash', () => { rendererCrashed = true; } );
await capturePerViewport( {
page,
rendererCrashed: () => rendererCrashed,
browserProfile: { isMobile: contextOptions.isMobile ?? false, hasTouch: contextOptions.hasTouch ?? false },
viewport,
plan: vpPlan,
Expand Down Expand Up @@ -1949,9 +1963,25 @@ export async function captureScreenshots( opts: ScreenshotOpts ): Promise< Scree
}
}
}
if ( rendererCrashed && urlFailures.length === failuresBefore ) {
urlFailures.push( {
url,
viewport: viewport.id,
stage: 'evaluate',
error: 'source renderer crashed',
timestamp: new Date().toISOString(),
attempt: crashRetry ? 2 : 1,
} );
}
for ( const failure of urlFailures.slice( failuresBefore ) ) {
failure.attempt = crashRetry ? 2 : failure.attempt;
}
const failed = urlFailures.length > failuresBefore;
if ( ! failed || crashRetry || attemptBrowser.isConnected() ) break;
if ( ! ( await replaceCrashedBrowser( attemptBrowser ) ) ) break;
if ( ! failed || crashRetry ) break;
if ( attemptBrowser.isConnected() ) {
if ( ! rendererCrashed ) break;
sendLog( server, `[retry] renderer crashed for ${ url } (${ viewport.id }); using a fresh context` );
} else if ( ! ( await replaceCrashedBrowser( attemptBrowser ) ) ) break;
urlFailures.length = failuresBefore;
}
}
Expand Down
Loading