Resolve instead of crashing when a command cannot be spawned - #731
Conversation
`executeCommand` attaches only a 'close' listener to the spawned process. When
the process cannot be started at all - the executable is missing, or `cwd` does
not exist - Node emits an 'error' event instead. With no listener for it, that
event is unhandled and terminates the entire test process:
Error: spawn npm ENOENT
at ChildProcess._handle.onexit (node:internal/child_process:285:19)
The adapter developer sees a stack trace from node:internal and no indication
which command failed or why. A try/catch around the call does not help, because
this is an event, not a throw.
The surrounding try/catch has the same gap in its synchronous form: its comment
says "we return the exit code in the close handler", but when `spawn` itself
throws there is no child process and neither 'close' nor 'error' ever fires, so
the promise stays pending forever.
Both paths now resolve. `ExecuteCommandResult` gains an optional `error` field
carrying the cause; `exitCode` stays undefined, which existing callers already
treat as a failure (e.g. `ControllerSetup.setupJsController` checks
`exitCode !== 0`), so no caller needs to change.
Adds tests for both spawn failures plus the success and non-zero-exit paths.
Verified against the unpatched version: the two spawn-failure tests fail there
with "Uncaught Error: spawn ... ENOENT".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The newly added spawn-failure tests may be flaky due to relying on hard-coded “nonexistent” command/dir names that could exist in some environments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves executeCommand to handle child-process spawn failures gracefully (missing executable, missing cwd, or synchronous spawn exceptions) by resolving with an error field instead of crashing or leaving the promise pending. This strengthens the reliability of the testing utilities consumed by adapter developers.
Changes:
- Add an optional
error?: ErrortoExecuteCommandResultand resolve onchild_process'error'events. - Resolve (instead of hanging) when
spawn()throws synchronously. - Add unit tests covering success, non-zero exit, missing executable, and missing
cwd, plus a changelog entry.
File summaries
| File | Description |
|---|---|
| src/lib/executeCommand.ts | Resolve with an error result on spawn failures and synchronous exceptions. |
| src/lib/executeCommand.test.ts | Adds tests for the new spawn-failure behavior and existing success/non-zero exit paths. |
| CHANGELOG.md | Documents the user-facing behavioral fix under WORK IN PROGRESS. |
| build/lib/executeCommand.js | Compiled JS output reflecting the new spawn error handling. |
| build/lib/executeCommand.d.ts | Compiled type definitions reflecting the new error field. |
Review details
Suppressed comments (1)
src/lib/executeCommand.test.ts:43
- This test assumes the chosen temp directory does not exist. If it does (e.g. leftover from prior runs), the command will execute successfully and the test will fail. Using a unique per-run directory name (pid/timestamp) makes the test deterministic.
const result = await executeCommand(process.execPath, ['-v'], {
cwd: path.join(os.tmpdir(), 'io-broker-testing-nonexistent-dir'),
stdout: 'pipe',
stderr: 'pipe',
});
- Files reviewed: 4/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const result = await executeCommand('io-broker-testing-nonexistent-command', ['--version'], { | ||
| stdout: 'pipe', | ||
| stderr: 'pipe', | ||
| }); |
Problem
executeCommandattaches only a'close'listener to the spawned process. When the process cannot be started at all — the executable is missing, orcwddoes not exist — Node emits an'error'event instead. With no listener for it, that event is unhandled and terminates the entire test process:The adapter developer sees a stack trace from
node:internaland no indication which command failed or why, and Mocha never gets to report. Atry/catchat the call site does not help, because this is an event, not a throw.Reproducible with the released 5.3.0:
The surrounding
try/catchhas the same gap in its synchronous form. Its comment says "doesn't matter, we return the exit code in the close handler", but whenspawnitself throws there is no child process, so neither'close'nor'error'ever fires and the promise stays pending forever.Change
Both paths now resolve:
'error'listener resolves with the error. Node emits'close'after'error', and that secondresolveis a no-op because the promise is already settled.catchblock resolves as well, instead of leaving the promise pending.ExecuteCommandResultgains an optionalerrorfield carrying the cause.exitCodestaysundefined, which existing callers already treat as a failure — e.g.ControllerSetup.setupJsControllerchecksexitCode !== 0— so no caller needs to change and nothing that works today behaves differently.Tests
src/lib/executeCommand.test.tscovers both spawn failures plus the success and non-zero-exit paths. Verified against the unpatched version: the two spawn-failure tests fail there withUncaught Error: spawn ... ENOENT, and pass with the change.npm run check && npm run lint && npm run build && npm testis green (12 passing).Notes
Found while tracking down why a release pipeline's integration smoke test could hang. That particular hang had a different cause on our side, so this PR does not claim to fix it — the crash above is simply a separate defect found while reading the code, and it is easy to hit whenever
npmis not onPATHin a CI image.🤖 Generated with Claude Code