Skip to content

Commit b079fff

Browse files
authored
Fix CLI formatting for empty Blueprint error messages (#3615)
## What? Preserves diagnostic details when the CLI catches an `Error` with an empty top-level `message` but a populated `cause` chain. This fixes `wp-playground-cli run-blueprint` printing only: ```text Error: ``` for Blueprint `runPHP` fatals whose PHP failure details are available through the nested cause. Fixes #3614. ## Why? The CLI `describeError()` helper returned `error.message` immediately for `Error` instances. When that message was empty, it never inspected `cause`, even though the Blueprint/PHP failure details were present there. ## Evidence A Homeboy rig was added first to reproduce and measure this path: - Repro harness: chubes4/homeboy-rigs#107 - Baseline run: `79d1d8c6-0bb5-4abb-8e18-5467e708cff2` - Candidate run: `52652822-759d-438c-a56e-8d4b6605dc33` Baseline metrics: ```text control_exit_code=0 fatal_exit_code=1 fatal_blank_error=1 fatal_has_php_diagnostic=0 ``` Candidate metrics: ```text control_exit_code=0 fatal_exit_code=1 fatal_blank_error=0 fatal_has_php_diagnostic=1 ``` Candidate output includes the PHP fatal: ```text Error: Error — caused by: Error when executing the blueprint step #1: PHP.run() failed with exit code 255. === Stdout === Fatal error: Uncaught Error: Call to undefined function playground_cli_missing_probe_function() ``` ## Testing Instructions ```sh npx nx test-playground-cli playground-cli --testNamePattern="describeError" ``` Also verified with the Homeboy rig: ```sh homeboy bench --force-hot \ --rig playground-cli-diagnostics \ --path /Users/chubes/Developer/wordpress-playground@improve-runphp-error-output \ --scenario playground-cli-runphp-errors \ --iterations 1 \ --shared-state /var/folders/lr/c_cmmt7s0592m4njz99v5yb40000gn/T/opencode/playground-cli-final-confirm \ --output /var/folders/lr/c_cmmt7s0592m4njz99v5yb40000gn/T/opencode/playground-cli-final-confirm/bench-output.json ``` ## AI assistance - **AI assistance:** Yes - **Tool(s):** OpenCode (GPT-5.5) - **Used for:** Diagnosed the CLI formatter issue using the Homeboy repro rig, implemented the minimal formatter fix, added focused test coverage, and ran verification.
1 parent dd79439 commit b079fff

4 files changed

Lines changed: 144 additions & 76 deletions

File tree

packages/php-wasm/universal/src/lib/error-reporting.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,98 @@ export async function prettyPrintFullStackTrace(e: any) {
4747
process.stderr.write('\n');
4848
}
4949

50+
/**
51+
* Describe an error for display. Handles Error instances, Comlink-serialized
52+
* plain objects (which lose their Error prototype during worker thread
53+
* transfer), and arbitrary values.
54+
*/
55+
export function describeError(
56+
error: unknown,
57+
seen = new WeakSet<object>(),
58+
depth = 0,
59+
options: { suppressGenericErrorName?: boolean } = {}
60+
): string {
61+
if (depth > 10) {
62+
return '[Max error cause depth exceeded]';
63+
}
64+
if (error instanceof Error) {
65+
if (error.message) {
66+
return error.message;
67+
}
68+
return describeErrorObject(error, seen, depth, {
69+
...options,
70+
suppressGenericErrorName: true,
71+
});
72+
}
73+
if (error && typeof error === 'object') {
74+
return describeErrorObject(error, seen, depth, options);
75+
}
76+
return String(error);
77+
}
78+
79+
type ErrorLikeObject = object & {
80+
name?: unknown;
81+
message?: unknown;
82+
cause?: unknown;
83+
errno?: unknown;
84+
code?: unknown;
85+
stack?: unknown;
86+
};
87+
88+
function describeErrorObject(
89+
error: ErrorLikeObject,
90+
seen: WeakSet<object>,
91+
depth: number,
92+
options: { suppressGenericErrorName?: boolean } = {}
93+
): string {
94+
if (seen.has(error)) {
95+
return '[Circular error cause]';
96+
}
97+
seen.add(error);
98+
99+
// Comlink-serialized errors arrive as plain objects like
100+
// { name: 'ErrnoError', errno: 20 } with no .message.
101+
const parts = [];
102+
if (
103+
error['name'] &&
104+
!(options.suppressGenericErrorName && error['name'] === 'Error')
105+
) {
106+
parts.push(String(error['name']));
107+
}
108+
if (error['message']) {
109+
parts.push(String(error['message']));
110+
}
111+
if (error['errno'] !== undefined) {
112+
parts.push(`errno: ${error['errno']}`);
113+
}
114+
if (error['code'] !== undefined) {
115+
parts.push(`code: ${error['code']}`);
116+
}
117+
if (error['cause']) {
118+
parts.push(
119+
`caused by: ${describeError(
120+
error['cause'],
121+
seen,
122+
depth + 1,
123+
options
124+
)}`
125+
);
126+
}
127+
if (parts.length > 0) {
128+
return parts.join(' — ');
129+
}
130+
if (typeof error['stack'] === 'string') {
131+
return error['stack'];
132+
}
133+
// Last resort: JSON-serialize the object so we at least see
134+
// what fields it has.
135+
try {
136+
return JSON.stringify(error);
137+
} catch {
138+
return String(error);
139+
}
140+
}
141+
50142
export function printResponseDebugDetails(response: PHPResponse) {
51143
// Print a short summary of what we have:
52144
process.stderr.write(

packages/php-wasm/universal/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
printDebugDetails,
1919
prettyPrintFullStackTrace,
2020
printResponseDebugDetails,
21+
describeError,
2122
} from './error-reporting';
2223
export { UnhandledRejectionsTarget } from './wasm-error-reporting';
2324
export { HttpCookieStore } from './http-cookie-store';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describeError } from '../lib/error-reporting';
2+
3+
describe('describeError', () => {
4+
it('falls back to Error cause when message is empty', () => {
5+
const error = new Error('', {
6+
cause: new Error(
7+
'Error when executing the blueprint step #1: PHP.run() failed with exit code 255.'
8+
),
9+
});
10+
11+
const description = describeError(error);
12+
expect(description).toContain(
13+
'Error when executing the blueprint step #1'
14+
);
15+
expect(description.startsWith('Error —')).toBe(false);
16+
});
17+
18+
it('terminates circular cause chains', () => {
19+
const error = new Error('');
20+
error.cause = error;
21+
22+
expect(describeError(error)).toContain('[Circular error cause]');
23+
});
24+
25+
it('preserves empty-message formatting through nested causes', () => {
26+
const error = new Error('', {
27+
cause: new Error('', {
28+
cause: new Error('Inner failure'),
29+
}),
30+
});
31+
32+
const description = describeError(error);
33+
expect(description).toContain('Inner failure');
34+
expect(description).not.toContain('Error — caused by:');
35+
});
36+
37+
it('describes local fields before cause', () => {
38+
const description = describeError({
39+
name: 'ErrnoError',
40+
errno: 20,
41+
code: 'ENOTDIR',
42+
cause: new Error('Inner failure'),
43+
});
44+
45+
expect(description).toBe(
46+
'ErrnoError — errno: 20 — code: ENOTDIR — caused by: Inner failure'
47+
);
48+
});
49+
});

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

Lines changed: 2 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
exposeAPI,
1616
exposeSyncAPI,
1717
printDebugDetails,
18+
describeError,
1819
} from '@php-wasm/universal';
1920
import type {
2021
BlueprintBundle,
@@ -791,28 +792,7 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
791792
if (debug) {
792793
printDebugDetails(e);
793794
} else {
794-
const messageChain: string[] = [];
795-
const seenErrors = new Set<Error>();
796-
let currentError: Error | undefined = e;
797-
for (let depth = 0; currentError && depth < 20; depth++) {
798-
if (seenErrors.has(currentError)) {
799-
messageChain.push('[Circular error cause]');
800-
currentError = undefined;
801-
break;
802-
}
803-
seenErrors.add(currentError);
804-
messageChain.push(describeError(currentError));
805-
currentError =
806-
currentError.cause instanceof Error
807-
? currentError.cause
808-
: undefined;
809-
}
810-
if (currentError) {
811-
messageChain.push('[Error cause chain truncated]');
812-
}
813-
console.error(
814-
'\x1b[1m' + messageChain.join(' caused by: ') + '\x1b[0m'
815-
);
795+
console.error('\x1b[1m' + describeError(e) + '\x1b[0m');
816796
}
817797
} else {
818798
console.error('\x1b[1m' + describeError(e) + '\x1b[0m');
@@ -821,60 +801,6 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
821801
}
822802
}
823803

824-
/**
825-
* Describe an error for display. Handles Error instances, Comlink-serialized
826-
* plain objects (which lose their Error prototype during worker thread
827-
* transfer), and arbitrary values.
828-
*/
829-
function describeError(error: unknown): string {
830-
if (error instanceof Error) {
831-
if (error.message) {
832-
return error.message;
833-
}
834-
const parts: string[] = [];
835-
if (error.name && error.name !== 'Error') {
836-
parts.push(error.name);
837-
}
838-
const obj = error as Error & Record<string, unknown>;
839-
if (obj['errno'] !== undefined) {
840-
parts.push(`errno: ${obj['errno']}`);
841-
}
842-
if (obj['code'] !== undefined) {
843-
parts.push(`code: ${obj['code']}`);
844-
}
845-
if (parts.length > 0) {
846-
return parts.join(' — ');
847-
}
848-
return error.stack || String(error);
849-
}
850-
if (error && typeof error === 'object') {
851-
// Comlink-serialized errors arrive as plain objects like
852-
// { name: 'ErrnoError', errno: 20 } with no .message.
853-
const parts = [];
854-
const obj = error as Record<string, unknown>;
855-
if (obj['name']) {
856-
parts.push(String(obj['name']));
857-
}
858-
if (obj['message']) {
859-
parts.push(String(obj['message']));
860-
}
861-
if (obj['errno'] !== undefined) {
862-
parts.push(`errno: ${obj['errno']}`);
863-
}
864-
if (parts.length > 0) {
865-
return parts.join(' — ');
866-
}
867-
// Last resort: JSON-serialize the object so we at least see
868-
// what fields it has.
869-
try {
870-
return JSON.stringify(error);
871-
} catch {
872-
return String(error);
873-
}
874-
}
875-
return String(error);
876-
}
877-
878804
function getMountForVfsPath(
879805
mounts: Mount[],
880806
vfsPath: string

0 commit comments

Comments
 (0)