fix: resolve error causes on the session that reported the exception (#3220)

Fixes #2950.

### Problem

Uncaught exceptions are collected from the page's Puppeteer CDP session
(`PageCollector` listens to `Runtime.exceptionThrown` on
`page._client()`), but `SymbolizedError` resolved the exception's
`cause` through the DevTools universe target's runtime agent, a
different session. Remote object ids are scoped to the session that
issued them, so `Runtime.getProperties` failed with `Could not find
object with given id`, the error was swallowed, and the `Caused by`
section was dropped.

Errors thrown during load appeared to work because the ids happened to
line up: both sessions had wrapped the same objects in the same order at
that point. For errors thrown later the ids diverge, which is why the
result depended on timing.

### Change

- `UncaughtError` keeps the CDP session that reported it (page collector
and service worker collector).
- `SymbolizedError.fromDetails` / `fromError` take an optional `session`
and, when present, use it for `Runtime.getProperties` (cause lookup) and
`Runtime.getExceptionDetails` (cause details). Without a session the
existing DevTools-universe path is unchanged.

### Testing

- New test in `tests/tools/console.test.ts`: an error with a `cause`
thrown 100 ms after `load`. It fails on `main` (no `Caused by`) and
passes with this change.
- Existing console and `ConsoleFormatter` tests pass.
- Ran the fix check from the issue against the built server (`--headless
--isolated`): with delays 0/50/300/1000 ms, `list_console_messages` with
`includeStackTraces` and `get_console_message` both print `Caused by:
Error: async-inner` for every delay (on `main` only the 0 ms case did).
An error without a `cause` still prints no `Caused by`.
This commit is contained in:
Mankhong Garden authored and GitHub committed 2026-10-19 11:56:32 +00:00
1 parent 968e4e5c50
commit b2d37e851a
6 files changed
+156 -9

No files matched your search

+8 -2
View File
@@ -27,10 +27,16 @@ import {logger} from '../utils/logger.js';
export class UncaughtError {
readonly details: Protocol.Runtime.ExceptionDetails;
readonly targetId: string;
readonly session?: CDPSession;
constructor(details: Protocol.Runtime.ExceptionDetails, targetId: string) {
constructor(
details: Protocol.Runtime.ExceptionDetails,
targetId: string,
session?: CDPSession,
) {
this.details = details;
this.targetId = targetId;
this.session = session;
}
}
@@ -296,7 +302,7 @@ class PageEventSubscriber {
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent): void => {
this.#page.emit(
'uncaughtError',
new UncaughtError(event.exceptionDetails, this.#targetId),
new UncaughtError(event.exceptionDetails, this.#targetId, this.#session),
);
};
+3 -1
View File
@@ -61,7 +61,9 @@ export class ServiceWorkerSubscriber {
const extensionId = extractExtensionId(url);
if (extensionId) {
this.#callback(new UncaughtError(event.exceptionDetails, extensionId));
this.#callback(
new UncaughtError(event.exceptionDetails, extensionId, this.#session),
);
}
};
}
+45 -6
View File
@@ -255,12 +255,15 @@ export class SymbolizedError {
devTools?: TargetUniverse;
details: ExceptionDetailsLike;
targetId: string;
// The session that reported `details`. Remote object ids are scoped to
// the session that issued them, so the cause has to be resolved there.
session?: CDPSession;
includeStackAndCause?: boolean;
resolvedStackTraceForTesting?: DevTools.StackTrace.StackTrace.StackTrace;
resolvedCauseForTesting?: SymbolizedError;
}): Promise<SymbolizedError> {
const message = SymbolizedError.#getMessage(opts.details);
if (!opts.includeStackAndCause || !opts.devTools) {
if (!opts.includeStackAndCause || (!opts.devTools && !opts.session)) {
return new SymbolizedError(
message,
opts.resolvedStackTraceForTesting,
@@ -271,7 +274,7 @@ export class SymbolizedError {
let stackTrace: DevTools.StackTrace.StackTrace.StackTrace | undefined;
if (opts.resolvedStackTraceForTesting) {
stackTrace = opts.resolvedStackTraceForTesting;
} else if (opts.details.stackTrace) {
} else if (opts.devTools && opts.details.stackTrace) {
try {
stackTrace = await createStackTrace(
opts.devTools,
@@ -294,12 +297,14 @@ export class SymbolizedError {
opts.devTools,
opts.details.exception,
opts.targetId,
opts.session,
);
if (causeRemoteObj) {
cause = await SymbolizedError.fromError({
devTools: opts.devTools,
error: causeRemoteObj,
targetId: opts.targetId,
session: opts.session,
});
}
} catch {
@@ -313,17 +318,20 @@ export class SymbolizedError {
devTools?: TargetUniverse;
error: RemoteObjectLike;
targetId: string;
session?: CDPSession;
}): Promise<SymbolizedError> {
const details = await SymbolizedError.#getExceptionDetails(
opts.devTools,
opts.error,
opts.targetId,
opts.session,
);
if (details) {
return await SymbolizedError.fromDetails({
details,
devTools: opts.devTools,
targetId: opts.targetId,
session: opts.session,
includeStackAndCause: true,
});
}
@@ -370,8 +378,24 @@ export class SymbolizedError {
devTools: TargetUniverse | undefined,
error: RemoteObjectLike,
targetId: string,
): Promise<DevTools.Protocol.Runtime.ExceptionDetails | null> {
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
session?: CDPSession,
): Promise<ExceptionDetailsLike | null> {
if (error.type !== 'object' && error.subtype !== 'error') {
return null;
}
if (session) {
if (!error.objectId) {
return null;
}
const {exceptionDetails} = await session.send(
'Runtime.getExceptionDetails',
{errorObjectId: error.objectId},
);
return exceptionDetails ?? null;
}
if (!devTools) {
return null;
}
@@ -391,8 +415,23 @@ export class SymbolizedError {
devTools: TargetUniverse | undefined,
error: RemoteObjectLike,
targetId: string,
): Promise<DevTools.Protocol.Runtime.RemoteObject | null> {
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
session?: CDPSession,
): Promise<RemoteObjectLike | null> {
if (error.type !== 'object' && error.subtype !== 'error') {
return null;
}
if (session) {
if (!error.objectId) {
return null;
}
const {result} = await session.send('Runtime.getProperties', {
objectId: error.objectId,
});
return result.find(prop => prop.name === 'cause')?.value ?? null;
}
if (!devTools) {
return null;
}
+1
View File
@@ -135,6 +135,7 @@ export class ConsoleFormatter {
devTools: options?.devTools,
details: msg.details,
targetId: msg.targetId,
session: msg.session,
includeStackAndCause:
options?.fetchDetailedData || options?.fetchStackTrace,
resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting,
+68
View File
@@ -7,6 +7,8 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';
import sinon from 'sinon';
import {SymbolizedError} from '../../src/devtools/DevtoolsUtils.js';
import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js';
import {UncaughtError} from '../../src/collectors/PageCollector.js';
@@ -15,6 +17,7 @@ import type {
Protocol,
DevTools,
} from '../../src/third_party/index.js';
import {createMockCDPSession} from '../mocks.js';
interface MockConsoleMessage {
type: () => string;
@@ -850,6 +853,71 @@ describe('ConsoleFormatter', () => {
},
);
});
it('resolves the cause of an UncaughtError on its session', async t => {
const session = createMockCDPSession();
const send: sinon.SinonStub = session.send;
send.onFirstCall().resolves({
result: [
{
name: 'cause',
configurable: true,
enumerable: false,
value: {
type: 'object',
subtype: 'error',
objectId: 'cause-id',
description: 'Error: root cause',
},
},
],
});
send.onSecondCall().resolves({
exceptionDetails: {
exceptionId: 2,
lineNumber: 0,
columnNumber: 0,
text: 'root cause',
},
});
const error = new UncaughtError(
{
exceptionId: 1,
lineNumber: 0,
columnNumber: 5,
exception: {
type: 'object',
subtype: 'error',
objectId: 'error-id',
description: 'Error: wrapper',
},
text: 'Uncaught',
},
'<mock target ID>',
session,
);
const formatter = await ConsoleFormatter.from(error, {
id: 18,
fetchDetailedData: true,
resolvedStackTraceForTesting: {
syncFragment: {
frames: [{line: 1, column: 1, url: 'main.js', name: 'main'}],
},
asyncFragments: [],
} as unknown as DevTools.StackTrace.StackTrace.StackTrace,
});
sinon.assert.calledWithExactly(send.firstCall, 'Runtime.getProperties', {
objectId: 'error-id',
});
sinon.assert.calledWithExactly(
send.secondCall,
'Runtime.getExceptionDetails',
{errorObjectId: 'cause-id'},
);
t.assert.match(formatter.toStringDetailed(), /Caused by: root cause/);
});
});
describe('ConsoleFormatter arguments', () => {
+31
View File
@@ -719,6 +719,37 @@ console.error('inline-line-4');
});
});
it('resolves the cause of an uncaught exception thrown after load', async () => {
server.addHtmlRoute(
'/index.html',
`<!DOCTYPE html><script>
window.addEventListener('load', () => {
setTimeout(() => {
window.thrown = true;
throw new Error('outer', {cause: new Error('inner')});
}, 100);
});
</script>`,
);
await withMcpContext(async (response, context, args) => {
const page = context.getSelectedMcpPage();
await page.pptrPage.goto(server.getRoute('/index.html'));
await page.pptrPage.waitForFunction(() => 'thrown' in window);
await getConsoleMessage(args).handler(
{params: {msgid: 1}, page: context.getSelectedMcpPage()},
response,
context,
);
const formattedResponse = await response.handle(context);
const rawText = getTextContent(formattedResponse.content[0]);
assert.ok(rawText.includes('Uncaught Error: outer'), rawText);
assert.ok(rawText.includes('Caused by: Error: inner'), rawText);
});
});
it('applies source maps to stack traces of Error object (with cause) console.log arguments', async t => {
server.addRoute('/main.min.js', (_req, res) => {
res.setHeader('Content-Type', 'text/javascript');