Skip to content

Commit 2b9cec4

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

2 files changed

Lines changed: 89 additions & 25 deletions

File tree

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

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -798,38 +798,78 @@ 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(error, seen, depth);
829819
}
830820
return String(error);
831821
}
832822

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