Description
With throwOnError: true, a child process that exits with a nonzero code throws NonZeroExitError as expected — but a child killed by a signal (SIGABRT, SIGKILL, SIGSEGV, …) resolves normally, as if it succeeded.
Repro
import { x } from 'tinyexec'
// Child dies by signal — resolves normally, exitCode: undefined
const r = await x('node', ['-e', 'process.kill(process.pid, "SIGABRT")'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit' },
})
console.log('returned normally — exitCode:', r.exitCode) // undefined
// Child exits nonzero — throws NonZeroExitError as expected
await x('node', ['-e', 'process.exit(2)'], {
throwOnError: true,
nodeOptions: { stdio: 'inherit' },
}) // throws ✓
Reproduced on 1.2.2 and present on current main.
Root cause
When a child dies by signal, Node sets child.exitCode = null (and signalCode to the signal). The exitCode getter only forwards non-null codes, so signal death yields undefined:
https://github.com/tinylibs/tinyexec/blob/main/src/main.ts#L145-L147
…and the throwOnError condition explicitly excludes undefined:
this._options?.throwOnError && this.exitCode !== 0 && this.exitCode !== undefined
(The undefined exclusion presumably exists for the not-yet-exited case, but post-exit it also matches every signal-killed child.)
Real-world impact
Nuxt's nuxi typecheck spawns vue-tsc through tinyexec with throwOnError: true. When vue-tsc hits a V8 OOM (FATAL ERROR: Ineffective mark-compacts near heap limit → SIGABRT), nuxi typecheck exits 0 — our CI reported a green typecheck on a run where the type checker had crashed without checking anything. Any tool that trusts throwOnError for correctness has the same exposure: crashes read as success.
Suggested fix
After the child has exited, treat signal termination as failure under throwOnError — e.g. throw when exitCode !== 0 || process.signalCode != null (post-exit), with the signal name in the error message. Related: #28 (exposing the killing signal would make the error message straightforward).
Description
With
throwOnError: true, a child process that exits with a nonzero code throwsNonZeroExitErroras expected — but a child killed by a signal (SIGABRT, SIGKILL, SIGSEGV, …) resolves normally, as if it succeeded.Repro
Reproduced on 1.2.2 and present on current
main.Root cause
When a child dies by signal, Node sets
child.exitCode = null(andsignalCodeto the signal). TheexitCodegetter only forwards non-nullcodes, so signal death yieldsundefined:https://github.com/tinylibs/tinyexec/blob/main/src/main.ts#L145-L147
…and the
throwOnErrorcondition explicitly excludesundefined:(The
undefinedexclusion presumably exists for the not-yet-exited case, but post-exit it also matches every signal-killed child.)Real-world impact
Nuxt's
nuxi typecheckspawnsvue-tscthrough tinyexec withthrowOnError: true. When vue-tsc hits a V8 OOM (FATAL ERROR: Ineffective mark-compacts near heap limit→ SIGABRT),nuxi typecheckexits 0 — our CI reported a green typecheck on a run where the type checker had crashed without checking anything. Any tool that truststhrowOnErrorfor correctness has the same exposure: crashes read as success.Suggested fix
After the child has exited, treat signal termination as failure under
throwOnError— e.g. throw whenexitCode !== 0 || process.signalCode != null(post-exit), with the signal name in the error message. Related: #28 (exposing the killing signal would make the error message straightforward).