Skip to content

Commit 532b7a5

Browse files
committed
feat(spawn): enhance error messages with detailed context
Improve spawn error debugging by replacing generic "command failed" messages with detailed error information including command, arguments, exit code/signal, and stderr output. Key improvements: - Add enhanceSpawnError() function to enrich spawn errors with context - Include full command and arguments in error message (with truncation for long values) - Show exit code or termination signal - Include first line of stderr for immediate context - Use lazy stack trace computation with WeakMap cache for performance - Handle both synthetic errors (modify in-place) and non-synthetic errors (wrap with cause) - Export enhanceSpawnError for external use - Add pony-cause dependency for cause chain handling Technical details: - Lazy getter for stack traces avoids computation until accessed - WeakMap cache prevents memory leaks (auto-cleanup on GC) - Avoid infinite recursion by computing stack from original error - Preserve all spawn error properties (cmd, args, code, signal, stdout, stderr)
1 parent d315d05 commit 532b7a5

5 files changed

Lines changed: 143 additions & 11 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,10 @@
359359
"types": "./dist/env/xdg.d.ts",
360360
"default": "./dist/env/xdg.js"
361361
},
362+
"./errors": {
363+
"types": "./dist/errors.d.ts",
364+
"default": "./dist/errors.js"
365+
},
362366
"./fs": {
363367
"types": "./dist/fs.d.ts",
364368
"default": "./dist/fs.js"
@@ -760,6 +764,7 @@
760764
"npm-package-arg": "13.0.0",
761765
"pacote": "21.0.1",
762766
"picomatch": "2.3.1",
767+
"pony-cause": "2.1.11",
763768
"semver": "7.7.2",
764769
"signal-exit": "4.1.0",
765770
"spdx-correct": "3.2.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/errors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @fileoverview Error utilities with cause chain support.
3+
* Provides helpers for working with error causes and stack traces.
4+
*/
5+
6+
import { messageWithCauses, stackWithCauses } from './external/pony-cause'
7+
8+
export { messageWithCauses, stackWithCauses }

src/spawn.ts

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
*/
2828

2929
import { getAbortSignal } from './constants/process'
30+
import { stackWithCauses } from './errors'
3031

3132
import npmCliPromiseSpawn from './external/@npmcli/promise-spawn'
3233

@@ -55,6 +56,9 @@ import type { EventEmitter } from 'node:events'
5556
const abortSignal = getAbortSignal()
5657
const spinner = getDefaultSpinner()
5758

59+
// Cache for lazy stack trace computation.
60+
const stackCache = new WeakMap<Error, string>()
61+
5862
// Define BufferEncoding type for TypeScript compatibility.
5963
type BufferEncoding = globalThis.BufferEncoding
6064

@@ -246,6 +250,103 @@ export interface SpawnSyncReturns<T> {
246250
error?: Error | undefined
247251
}
248252

253+
/**
254+
* Enhances spawn error with better context.
255+
* Converts generic "command failed" to detailed error with command, exit code, and stderr.
256+
*/
257+
/*@__NO_SIDE_EFFECTS__*/
258+
export function enhanceSpawnError(error: unknown): unknown {
259+
if (error === null || typeof error !== 'object') {
260+
return error
261+
}
262+
263+
if (!isSpawnError(error)) {
264+
return error
265+
}
266+
267+
const err = error as SpawnError
268+
const { args, cmd, code, signal, stderr } = err
269+
const stderrText =
270+
typeof stderr === 'string' ? stderr : (stderr?.toString() ?? '')
271+
272+
// Build enhanced message.
273+
let enhancedMessage = `Command failed: ${cmd}`
274+
275+
if (args && args.length > 0) {
276+
const argsStr = args.join(' ')
277+
if (argsStr.length < 100) {
278+
enhancedMessage += ` ${argsStr}`
279+
} else {
280+
enhancedMessage += ` ${argsStr.slice(0, 97)}...`
281+
}
282+
}
283+
284+
if (signal) {
285+
enhancedMessage += ` (terminated by ${signal})`
286+
} else if (code !== undefined) {
287+
enhancedMessage += ` (exit code ${code})`
288+
}
289+
290+
// Add first line of stderr for context.
291+
const trimmedStderr = stderrText.trim()
292+
if (trimmedStderr) {
293+
const firstLine = trimmedStderr.split('\n')[0]
294+
if (firstLine.length < 200) {
295+
enhancedMessage += `\n${firstLine}`
296+
} else {
297+
enhancedMessage += `\n${firstLine.slice(0, 197)}...`
298+
}
299+
}
300+
301+
// Check if this is a synthetic error (generic "command failed" message).
302+
const isSynthetic = err.message === 'command failed'
303+
304+
if (isSynthetic) {
305+
// Modify the error directly.
306+
Object.defineProperty(err, 'message', {
307+
__proto__: null,
308+
value: enhancedMessage,
309+
writable: true,
310+
enumerable: false,
311+
configurable: true,
312+
} as PropertyDescriptor)
313+
314+
return err
315+
}
316+
317+
// Create enhanced error with original error as cause.
318+
const enhancedError = new Error(enhancedMessage, {
319+
cause: err,
320+
}) as SpawnError
321+
322+
// Copy all spawn error properties except message and stack.
323+
const descriptors = Object.getOwnPropertyDescriptors(err)
324+
delete descriptors.message
325+
delete descriptors.stack
326+
Object.defineProperties(enhancedError, descriptors)
327+
328+
// Build stack lazily on first access using WeakMap cache.
329+
Object.defineProperty(enhancedError, 'stack', {
330+
__proto__: null,
331+
configurable: true,
332+
enumerable: false,
333+
get() {
334+
let stack = stackCache.get(enhancedError)
335+
if (stack === undefined) {
336+
try {
337+
stack = stackWithCauses(err)
338+
} catch {
339+
stack = err.stack ?? new Error().stack ?? ''
340+
}
341+
stackCache.set(enhancedError, stack)
342+
}
343+
return stack
344+
},
345+
} as PropertyDescriptor)
346+
347+
return enhancedError
348+
}
349+
249350
/**
250351
* Check if a value is a spawn error with expected error properties.
251352
* Tests for common error properties from child process failures.
@@ -652,18 +753,25 @@ export function spawn(
652753
return strippedResult
653754
})
654755
.catch(error => {
655-
throw stripAnsiFromSpawnResult(error)
756+
const strippedError = stripAnsiFromSpawnResult(error)
757+
const enhancedError = enhanceSpawnError(strippedError)
758+
throw enhancedError
656759
}) as PromiseSpawnResult
657760
} else {
658-
newSpawnPromise = spawnPromise.then(result => {
659-
// Add exitCode as an alias for code.
660-
if ('code' in result) {
661-
const res = result as typeof result & { exitCode: number }
662-
res.exitCode = result.code
663-
return res
664-
}
665-
return result
666-
}) as PromiseSpawnResult
761+
newSpawnPromise = spawnPromise
762+
.then(result => {
763+
// Add exitCode as an alias for code.
764+
if ('code' in result) {
765+
const res = result as typeof result & { exitCode: number }
766+
res.exitCode = result.code
767+
return res
768+
}
769+
return result
770+
})
771+
.catch(error => {
772+
const enhancedError = enhanceSpawnError(error)
773+
throw enhancedError
774+
}) as PromiseSpawnResult
667775
}
668776
if (shouldRestartSpinner) {
669777
newSpawnPromise = newSpawnPromise.finally(() => {

test/integration/spawn.test.mts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ describe('spawn integration', () => {
3535
await spawn('node', ['--invalid-flag'])
3636
expect.fail('Should have thrown')
3737
} catch (error: any) {
38-
expect(error.message).toContain('command failed')
38+
expect(error.message).toContain('Command failed')
39+
expect(error.message).toContain('exit code')
40+
expect(error.code).toBe(9)
3941
}
4042
})
4143

0 commit comments

Comments
 (0)