Skip to content

Commit 1741aaa

Browse files
committed
fix(cli): preserve empty error causes
1 parent c3dc471 commit 1741aaa

2 files changed

Lines changed: 86 additions & 25 deletions

File tree

packages/playground/cli/src/run-cli.ts

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -798,38 +798,75 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
798798
* plain objects (which lose their Error prototype during worker thread
799799
* transfer), and arbitrary values.
800800
*/
801-
function describeError(error: unknown): string {
801+
export function describeError(
802+
error: unknown,
803+
seen = new WeakSet<object>(),
804+
depth = 0
805+
): string {
806+
if (depth > 10) {
807+
return '[Max error cause depth exceeded]';
808+
}
802809
if (error instanceof Error) {
803-
return error.message;
810+
return (
811+
error.message ||
812+
describeErrorObject(error, seen, depth, {
813+
suppressGenericErrorName: true,
814+
})
815+
);
804816
}
805817
if (error && typeof error === 'object') {
806-
// Comlink-serialized errors arrive as plain objects like
807-
// { name: 'ErrnoError', errno: 20 } with no .message.
808-
const parts = [];
809-
const obj = error as Record<string, unknown>;
810-
if (obj['name']) {
811-
parts.push(String(obj['name']));
812-
}
813-
if (obj['message']) {
814-
parts.push(String(obj['message']));
815-
}
816-
if (obj['errno'] !== undefined) {
817-
parts.push(`errno: ${obj['errno']}`);
818-
}
819-
if (parts.length > 0) {
820-
return parts.join(' — ');
821-
}
822-
// Last resort: JSON-serialize the object so we at least see
823-
// what fields it has.
824-
try {
825-
return JSON.stringify(error);
826-
} catch {
827-
return String(error);
828-
}
818+
return describeErrorObject(
819+
error as Record<string, unknown>,
820+
seen,
821+
depth
822+
);
829823
}
830824
return String(error);
831825
}
832826

827+
function describeErrorObject(
828+
error: Record<string, unknown>,
829+
seen: WeakSet<object>,
830+
depth: number,
831+
options: { suppressGenericErrorName?: boolean } = {}
832+
): string {
833+
if (seen.has(error)) {
834+
return '[Circular error cause]';
835+
}
836+
seen.add(error);
837+
838+
// Comlink-serialized errors arrive as plain objects like
839+
// { name: 'ErrnoError', errno: 20 } with no .message.
840+
const parts = [];
841+
if (
842+
error['name'] &&
843+
!(options.suppressGenericErrorName && error['name'] === 'Error')
844+
) {
845+
parts.push(String(error['name']));
846+
}
847+
if (error['message']) {
848+
parts.push(String(error['message']));
849+
}
850+
if (error['cause']) {
851+
parts.push(
852+
`caused by: ${describeError(error['cause'], seen, depth + 1)}`
853+
);
854+
}
855+
if (error['errno'] !== undefined) {
856+
parts.push(`errno: ${error['errno']}`);
857+
}
858+
if (parts.length > 0) {
859+
return parts.join(' — ');
860+
}
861+
// Last resort: JSON-serialize the object so we at least see
862+
// what fields it has.
863+
try {
864+
return JSON.stringify(error);
865+
} catch {
866+
return String(error);
867+
}
868+
}
869+
833870
function getMountForVfsPath(
834871
mounts: Mount[],
835872
vfsPath: string

packages/playground/cli/tests/run-cli.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseOptionsAndRunCLI,
77
internalsKeyForTesting,
88
resolveWorkerCount,
9+
describeError,
910
} from '../src/run-cli';
1011
import type { RunCLIArgs, RunCLIServer } from '../src/run-cli';
1112
import type { MockInstance } from 'vitest';
@@ -1199,6 +1200,29 @@ describe('start command', () => {
11991200
}, 180000);
12001201
});
12011202

1203+
describe('describeError', () => {
1204+
test('falls back to Error cause when message is empty', () => {
1205+
const error = new Error('', {
1206+
cause: new Error(
1207+
'Error when executing the blueprint step #1: PHP.run() failed with exit code 255.'
1208+
),
1209+
});
1210+
1211+
const description = describeError(error);
1212+
expect(description).toContain(
1213+
'Error when executing the blueprint step #1'
1214+
);
1215+
expect(description.startsWith('Error —')).toBe(false);
1216+
});
1217+
1218+
test('terminates circular cause chains', () => {
1219+
const error = new Error('');
1220+
error.cause = error;
1221+
1222+
expect(describeError(error)).toContain('[Circular error cause]');
1223+
});
1224+
});
1225+
12021226
describe('php command', () => {
12031227
class ProcessExitCalled extends Error {
12041228
exitCode: number;

0 commit comments

Comments
 (0)