diff --git a/stasis-core/bin/stasis-core.js b/stasis-core/bin/stasis-core.js index 94e8b3a..bbe2d25 100755 --- a/stasis-core/bin/stasis-core.js +++ b/stasis-core/bin/stasis-core.js @@ -6,6 +6,7 @@ import { once } from 'node:events' import { fileURLToPath } from 'node:url' import { basename, resolve } from 'node:path' import { existsSync, realpathSync } from 'node:fs' +import { constants as osConstants } from 'node:os' import assert from 'node:assert/strict' import pkg from '../package.json' with { type: 'json' } @@ -103,8 +104,9 @@ if (command === '-v' || command === '--version') { setEnv('EXODUS_STASIS_RESOURCES', resources) const nodeArgs = ['--import', import.meta.resolve('../src/loader.js')] const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' }) - const [code] = await once(child, 'close') - process.exitCode = code + const [code, signal] = await once(child, 'close') + // code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0 + process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0) } else if (command === 'prune') { if (argv.length > 1) usage('Error: prune takes at most one path argument') const root = argv[0] ? resolve(argv[0]) : process.cwd() diff --git a/stasis/bin/stasis.js b/stasis/bin/stasis.js index 948a664..61afaed 100755 --- a/stasis/bin/stasis.js +++ b/stasis/bin/stasis.js @@ -6,6 +6,7 @@ import { once } from 'node:events' import { fileURLToPath } from 'node:url' import { basename, dirname, resolve } from 'node:path' import { existsSync, realpathSync } from 'node:fs' +import { constants as osConstants } from 'node:os' import assert from 'node:assert/strict' import pkg from '../package.json' with { type: 'json' } @@ -167,8 +168,9 @@ if (command === '-v' || command === '--version') { const loaderEntry = values.mock ? '../src/loader-mock.js' : '@exodus/stasis-core/loader' nodeArgs.push('--import', import.meta.resolve(loaderEntry)) const child = spawn(process.execPath, [...nodeArgs, ...argv], { stdio: 'inherit' }) - const [code] = await once(child, 'close') - process.exitCode = code + const [code, signal] = await once(child, 'close') + // code is null when the child died from a signal; report 128+signo (shell convention) instead of the implicit 0 + process.exitCode = code ?? 128 + (osConstants.signals[signal] ?? 0) } else if (command === 'bundle') { const flags = [] const valueFlags = new Set(['--mapping', '--output', '--scope', '--lockfile', '--conditions', '--mainFields', '--platforms', '-o']) diff --git a/tests/stasis-core-cli.test.js b/tests/stasis-core-cli.test.js index 4559e9c..367a1fc 100644 --- a/tests/stasis-core-cli.test.js +++ b/tests/stasis-core-cli.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import { spawnSync } from 'node:child_process' -import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -117,6 +117,15 @@ test('run: a forked child is enforced from the bundle but does not write (via th t.assert.deepEqual(readFileSync(join(tmp, 'stasis.lock.json')), lockBaseline, 'child must not rewrite the lockfile') })) +test('run reports 128+signo when the child dies from a signal', withTmp((t, tmp) => { + // A signal-killed child (SIGSEGV, SIGKILL, OOM-kill) closes with code=null; the launcher + // must not turn that into an implicit exit 0. 137 = 128 + SIGKILL(9), the shell convention. + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'sigkill-fixture', version: '0.0.0', private: true, type: 'module' })) + writeFileSync(join(tmp, 'entry.js'), "process.kill(process.pid, 'SIGKILL')\n") + const r = run(['run', '--lock=ignore', 'entry.js'], { cwd: tmp }) + t.assert.equal(r.status, 137, `stderr: ${r.stderr}`) +})) + test('prune validates and removes against the lockfile', withTmp((t, tmp) => { cpSync(pruneFixture, tmp, { recursive: true }) const r = run(['prune', tmp])