Skip to content

Resolve instead of crashing when a command cannot be spawned - #731

Merged
GermanBluefox merged 1 commit into
ioBroker:masterfrom
krobipd:fix/execute-command-spawn-error
Sep 5, 2026
Merged

Resolve instead of crashing when a command cannot be spawned#731
GermanBluefox merged 1 commit into
ioBroker:masterfrom
krobipd:fix/execute-command-spawn-error

Conversation

@krobipd

@krobipd krobipd commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem

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:

node:events:497
      throw er; // Unhandled 'error' event
      ^
Error: spawn io-broker-testing-nonexistent-command 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, and Mocha never gets to report. A try/catch at the call site does not help, because this is an event, not a throw.

Reproducible with the released 5.3.0:

const { executeCommand } = require('@iobroker/testing/build/lib/executeCommand');
await executeCommand('does-not-exist', ['--version'], { stdout: 'pipe', stderr: 'pipe' });
// -> process dies
await executeCommand('npm', ['-v'], { cwd: '/does/not/exist', stdout: 'pipe' });
// -> same

The surrounding try/catch has the same gap in its synchronous form. Its comment says "doesn't matter, we return the exit code in the close handler", but when spawn itself throws there is no child process, so neither 'close' nor 'error' ever fires and the promise stays pending forever.

Change

Both paths now resolve:

  • an 'error' listener resolves with the error. Node emits 'close' after 'error', and that second resolve is a no-op because the promise is already settled.
  • the catch block resolves as well, instead of leaving the promise pending.

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 and nothing that works today behaves differently.

Tests

src/lib/executeCommand.test.ts covers 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, and pass with the change.

npm run check && npm run lint && npm run build && npm test is 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 npm is not on PATH in a CI image.

🤖 Generated with Claude Code

`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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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?: Error to ExecuteCommandResult and resolve on child_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.

Comment on lines +29 to +32
const result = await executeCommand('io-broker-testing-nonexistent-command', ['--version'], {
stdout: 'pipe',
stderr: 'pipe',
});
@GermanBluefox
GermanBluefox merged commit 6232712 into ioBroker:master Sep 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants