From 3580fd30d70c2a0251ad6594f0cf38a0685a3253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:25:32 +0000 Subject: [PATCH 1/4] fix(metro): flush a force-killed transform worker's shard on SIGTERM Metro ends its jest-worker transform workers BY SIGNAL when they don't drain in time: end() sends the END message, gives a worker 500ms, then forceExit()s it (SIGTERM; SIGKILL another 500ms later) -- routine when a transformer leaves a ref'd handle behind. A signal death bypasses beforeExit/exit, so the worker's --child-process shard silently vanished from the capture, taking everything only that worker observed: its own fork-target entry (jest-worker's processChild.js, which the parent only ever require.resolve()s -- resolution edges never carry bytes) and the per-transform babel toolchain. The lockfile then named ./processChild in the import map with no hash behind it, and a bundle=load run failed closed in the forked child: stasis: file not attested in bundle: .../jest-worker/build/workers/processChild.js The loader now flushes a capturing child's shard when the child is ended by SIGTERM, then re-delivers the signal. Semantics are preserved: the `once` listener is already removed when the handler runs, so with no user listener left the default disposition is restored and the re-kill keeps death-by-signal (the parent still sees code=null, signal=SIGTERM); with a user handler present we only flush and leave process lifetime to it. Signal listeners don't ref the event loop, so cleanly-draining workers are untouched. Scoped to Metro, not a global default: a signal listener changes a child's default-kill disposition, which in arbitrary user children is the user's domain. StasisMetro's capture wiring opts its build's workers in by setting EXODUS_STASIS_SHARD_SIGNAL_FLUSH on process.env at construction time -- the plugin cannot run inside the workers (only the loader does), so an env opt-in inherited by the forked workers is the seam. SIGTERM only (what forceExit sends; Metro doesn't override killSignal); SIGKILL stays uncatchable and documented as the best-effort residual, and a later frozen/load run still fails closed on anything genuinely missing. The cli-run-fork-resolve fixture reproduces the jest-worker shape exactly: the parent require.resolve()s and forks processChild, which lazily loads a worker-only dep (the babel-toolchain analog) and is force-killed via HOLD_HANDLE. With the opt-in, the killed worker's shard still lands -- fork target and worker-only dep attested, and bundle=load then runs the whole flow with node_modules removed; without it, the shard is still lost (default behavior unchanged). metro.test.js pins that the plugin sets the flag on capturing runs only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i --- stasis-core/src/hooks.js | 42 ++++++- stasis-core/src/state.js | 9 +- stasis-plugins/src/metro.js | 30 +++-- tests/cli.test.js | 112 ++++++++++++++++++ .../fake-jest-worker/build/index.js | 14 +++ .../fake-jest-worker/build/types.js | 3 + .../fake-jest-worker/build/workerDep.js | 7 ++ .../build/workers/ChildProcessWorker.js | 47 ++++++++ .../build/workers/processChild.js | 27 +++++ .../fake-jest-worker/package.json | 1 + .../cli-run-fork-resolve/package.json | 1 + .../cli-run-fork-resolve/pnpm-workspace.yaml | 2 + .../cli-run-fork-resolve/src/entry.js | 9 ++ tests/metro-run.helper.js | 8 ++ tests/metro.test.js | 24 ++++ 15 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/index.js create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/types.js create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workerDep.js create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js create mode 100644 tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/package.json create mode 100644 tests/fixtures/cli-run-fork-resolve/package.json create mode 100644 tests/fixtures/cli-run-fork-resolve/pnpm-workspace.yaml create mode 100644 tests/fixtures/cli-run-fork-resolve/src/entry.js diff --git a/stasis-core/src/hooks.js b/stasis-core/src/hooks.js index b5979701..f665732b 100644 --- a/stasis-core/src/hooks.js +++ b/stasis-core/src/hooks.js @@ -469,7 +469,10 @@ function initState(root) { // not taint, so the files captured so far ARE still written. That is intentional and // safe: those recorded hashes are accurate (nothing drifted is baked in), and a later // lock=frozen run fails closed on anything missing. Unhandled signals (SIGINT/SIGTERM - // with no handler) bypass exit hooks entirely; servers own that behavior, we don't touch it. + // with no handler) bypass exit hooks entirely; servers own that behavior, we don't touch + // it -- except for a capturing CHILD under the EXODUS_STASIS_SHARD_SIGNAL_FLUSH opt-in + // (StasisMetro sets it for its force-killed transform workers), which flushes its shard + // on SIGTERM and re-delivers the signal -- see the handler registered below. // // save() runs on EVERY beforeExit/exit firing rather than latching after the first. // These handlers were registered at initState -- before any user code ran -- so they fire @@ -521,6 +524,43 @@ function initState(root) { process.on('beforeExit', save) process.on('exit', save) + + // EXODUS_STASIS_SHARD_SIGNAL_FLUSH (opt-in; StasisMetro's capture wiring sets it for its + // build's children -- see the plugin's constructor): flush the shard when a capturing + // child is ended BY SIGNAL. Metro's transform workers are routinely killed that way: + // jest-worker's end() sends its END message, gives the worker 500ms to drain its event + // loop, then forceExit()s it -- SIGTERM, and SIGKILL another 500ms later -- and a worker + // whose loop doesn't drain in time (a watcher or ref'd timer some transformer left + // behind) dies by SIGTERM, which bypasses beforeExit/exit entirely. Everything ONLY that + // worker observed (its fork-target entry, jest-worker's processChild.js; babel.config.js + // + the preset/plugin graph loaded per transform) then silently vanished from the + // capture, surfacing only later at bundle=load as "file not attested in bundle" when the + // forked child's loader can't be served its own entry. The handler flushes the shard and + // re-delivers the signal; semantics are preserved: + // - the `once` listener is already removed when the handler runs, so when no user + // listener remains the OS default disposition is restored and the re-kill terminates + // the process BY that signal (the parent still observes code=null, signal=SIGTERM); + // - when user code registered its own handler, we only flush and do NOT re-kill -- the + // user's handler owns process lifetime, and if it later exits normally the exit-hook + // write dedups via lastShardWritten; + // - signal listeners don't ref the event loop, so a cleanly-draining worker still + // exits (and writes) exactly as before. + // NOT a global default: a signal listener changes a process's default-kill disposition, + // which in arbitrary user children is the user's domain -- the Metro plugin opts in its + // KNOWN tool workers, where flush-then-redeliver is the right trade. Gated additionally + // to a CHILD (the root's signal behavior stays untouched -- servers own it) that is + // actually capturing into an inherited shard channel. SIGTERM only -- it's what + // jest-worker's forceExit sends (Metro doesn't override killSignal); SIGKILL remains + // uncatchable (jest-worker's memory-limit killChild), the documented best-effort + // residual -- a later frozen/load run fails closed on anything genuinely missing. + if (isChildProcess && shardForwardingEnabled() + && process.env.EXODUS_STASIS_SHARD_DIR && process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH) { + process.once('SIGTERM', () => { + // Same taint gate as save(): a rejected capture forwards nothing. + if (!aborted) writeChildShard() + if (process.listenerCount('SIGTERM') === 0) process.kill(process.pid, 'SIGTERM') + }) + } } function load(url, context, nextLoad) { diff --git a/stasis-core/src/state.js b/stasis-core/src/state.js index cea3874a..ae617f76 100644 --- a/stasis-core/src/state.js +++ b/stasis-core/src/state.js @@ -2098,7 +2098,14 @@ export class State { // worker child like worker-farm's, a .node addon). The resolution is real and // must be attested so the same require.resolve() returns the same path at // load; the file simply has no bytes -- require.resolve returns a path, load() - // never runs for it, so getFile is never asked to serve it. + // never runs for it, so getFile is never asked to serve it. Bytes are attested + // only by whoever actually LOADS the file: a fork/worker target (jest-worker's + // processChild) is loaded in the forked CHILD as its entry, and that child's + // own capture contributes them via the --child-process shard -- flushed even on + // SIGTERM under the Metro plugin's opt-in (hooks.js), so a force-killed + // transform worker still reports. Deliberately NOT seeded here from the parent: + // attesting bytes this process never loaded would widen the trust set to + // everything that merely resolves. // Guards: // - skip out-of-scope targets (mirroring #collectMissingImportedFiles) so a // node_modules-scope artifact doesn't attest workspace resolutions it isn't diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index 1d5cf1e2..cd4a4969 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -123,13 +123,17 @@ function metroDefaultSerializer() { // earliest refusal point. Load mode is unaffected and runs fine under a dev server -- // the serializer passes through and the worker transformer only reads immutable // attested bytes. -// - Child-process capture is best-effort within that one shot. A worker KILLED by signal -// before its exit hook (jest-worker forceExit / pool overflow) loses its shard SILENTLY at -// capture time -- a later frozen run still catches the gap (fail-closed), but nothing warns -// during capture. And it relies on the loader's exit-time write, which standalone (no stasis -// loader) Metro never reaches -- it mints no shard dir and produces no toolchain-complete -// lockfile. Use a one-shot `metro build` under `stasis run --child-process` to capture, then -// verify with `--lock=frozen`. +// - Child-process capture is best-effort within that one shot. A writing plugin opts the +// build's workers into the loader's SIGTERM shard flush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, +// set in the constructor), so a worker force-killed by jest-worker's forceExit -- SIGTERM +// 500ms after END, routine for a transform worker whose event loop doesn't drain -- still +// contributes its shard. SIGKILL remains uncatchable (jest-worker's memory-limit killChild, +// a killSignal:'SIGKILL' override): such a worker loses its shard SILENTLY at capture time +// -- a later frozen run still catches the gap (fail-closed), but nothing warns during +// capture. And it relies on the loader's exit/signal-time writes, which standalone (no +// stasis loader) Metro never reaches -- it mints no shard dir and produces no +// toolchain-complete lockfile. Use a one-shot `metro build` under +// `stasis run --child-process` to capture, then verify with `--lock=frozen`. // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel // scales/files arrays), NOT as separate graph modules, so only the base file is attested @@ -171,6 +175,18 @@ export class StasisMetro { 'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' + 'or "childProcess": true in stasis.config.json.' ) + // Metro ends its transform workers BY SIGNAL when they don't drain in time: jest-worker's + // end() gives a worker 500ms after the END message, then forceExit()s it (SIGTERM) -- and + // a signal death bypasses the worker's exit hooks, silently dropping its shard: the + // fork-target entry (jest-worker's processChild.js, which only the worker ever loads -- + // the parent merely require.resolve()s it) plus the per-transform babel toolchain. Opt + // this build's children into the loader's SIGTERM shard flush (hooks.js): the flag rides + // process.env, so the workers Metro forks AFTER config time inherit it. Scoped HERE, not + // globally -- a signal listener changes a child's default-kill disposition, which is the + // user's domain in arbitrary children, but Metro's transform workers are known tool + // processes where flush-then-redeliver is the right trade. The plugin can't do this in + // the workers itself (only the loader runs there), so an env opt-in is the seam. + process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH = '1' } // resources is a Config field now (validated + coordinated against any preload in // resolvePluginState); cache the resolved Set for the per-file classify hot path. diff --git a/tests/cli.test.js b/tests/cli.test.js index 492a5c4e..04f0c051 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -30,6 +30,7 @@ const { EXODUS_STASIS_CHILD_PROCESS: _cp, EXODUS_STASIS_SHARD_DIR: _sd, EXODUS_STASIS_SHARD_KEY: _sk, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...cleanEnv } = process.env @@ -2419,3 +2420,114 @@ test('run --lock=frozen: a tampered child-only dependency is still rejected (fai // bytes no longer match the lockfile-attested hash. t.assert.match(r.stderr, /childdep|Conflict|integrity|mismatch/i, 'must fail on the tampered child file specifically') })) + +// --- force-killed workers (require.resolve -> fork, the jest-worker shape) ----------- +// +// jest-worker's ChildProcessWorker forks `require.resolve('./processChild')` -- the parent +// process resolves the file but NEVER loads it; the bytes are loaded exclusively in the +// forked child, as that child's entry, so ONLY the child's --child-process shard can attest +// them (bytes are never attested for merely-resolved files -- see commonjs.test.js's +// resolve-only test). But jest-worker's end() force-kills a worker whose event loop hasn't +// drained within 500ms of the END message (SIGTERM -- routine when a transformer leaves a +// ref'd handle behind), and a signal death bypasses beforeExit/exit -- the shard, with the +// fork target AND the worker-only toolchain it loaded, silently vanished from the capture. +// At bundle=load, require.resolve() was then served the attested edge, fork() spawned the +// child, and the child's loader failed closed on its own entry -- "file not attested in +// bundle: .../processChild.js". Under EXODUS_STASIS_SHARD_SIGNAL_FLUSH -- set for its +// build's children by StasisMetro's capture wiring, NOT a global default -- the loader +// flushes a capturing child's shard on SIGTERM and re-delivers the signal (hooks.js), so a +// force-killed worker still contributes and still dies by the same signal. The +// cli-run-fork-resolve fixture reproduces the jest-worker shape exactly (incl. the +// forceExit kill via HOLD_HANDLE). + +const forkResolveFixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'cli-run-fork-resolve') + +// The `./processChild` edge from ChildProcessWorker.js, under whichever conditions bucket the +// capture recorded it (require-condition sets vary across Node versions). +const processChildEdge = (lock) => { + for (const byParent of Object.values(lock.imports ?? {})) { + const target = byParent['node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js']?.['./processChild'] + if (target !== undefined) return target + } + return undefined +} + +test('run --child-process: a force-killed worker still contributes its shard under the Metro-plugin signal-flush opt-in', withTmp((t, tmp) => { + cpSync(forkResolveFixture, tmp, { recursive: true }) + // HOLD_HANDLE keeps the worker's event loop busy after the END message, so the parent + // force-kills it jest-worker-style. Only the SIGTERM flush can save this worker's shard. + // EXODUS_STASIS_SHARD_SIGNAL_FLUSH stands in for StasisMetro's capture wiring, which sets + // it on process.env so the build's forked workers inherit it (metro.test.js pins that). + const cap = run( + ['run', '--lock=add', '--bundle=add', '--bundle-file=snapshot.br', '--child-process', 'src/entry.js'], + { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } + ) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + t.assert.match(cap.stdout, /ENTRY result=worked on input-1/, 'the forked worker ran') + // code=null + signal=SIGTERM also proves the flush handler re-delivered the signal -- + // the worker still died BY SIGTERM, not by a hook-invented clean exit. + t.assert.match(cap.stdout, /PARENT worker-exit code=null signal=SIGTERM/, 'the worker was force-killed, and still died by the signal') + + const lock = JSON.parse(readFileSync(join(tmp, 'stasis.lock.json'), 'utf-8')) + const files = lock.modules['node_modules/fake-jest-worker'].files + t.assert.equal(processChildEdge(lock), 'node_modules/fake-jest-worker/build/workers/processChild.js', + 'the require.resolve edge is attested') + // Both can only come from the killed worker's flushed shard: the fork-target entry the + // parent never loads, and the worker-only lazy dep (the babel-toolchain analog). + t.assert.ok(files['build/workers/processChild.js']?.startsWith('sha512-'), + 'the fork target is attested via the flushed shard') + t.assert.ok(files['build/workerDep.js']?.startsWith('sha512-'), + 'the worker-only toolchain module is attested via the flushed shard') + t.assert.equal(lock.formats['node_modules/fake-jest-worker/build/workers/processChild.js'], 'commonjs') + + // The load half of the regression: with node_modules gone, the bundle is the only place + // the forked child's entry and its worker-only dep can come from. This threw "file not + // attested in bundle: .../processChild.js" when the kill lost the shard. + rmSync(join(tmp, 'node_modules'), { recursive: true }) + const r = run( + ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], + { cwd: tmp } + ) + t.assert.equal(r.status, 0, `load stderr: ${r.stderr}`) + t.assert.match(r.stdout, /ENTRY result=worked on input-1 with types-shared-by-parent-and-child/) + t.assert.match(r.stdout, /PARENT worker-exit code=0 signal=null/) +})) + +test('run --child-process: WITHOUT the signal-flush opt-in a force-killed worker still loses its shard (no global default)', withTmp((t, tmp) => { + cpSync(forkResolveFixture, tmp, { recursive: true }) + // The flush changes a child's signal disposition, so it is strictly opt-in (the Metro + // plugin sets it for its known tool workers); a plain --child-process run must keep the + // documented best-effort behavior -- kill loses the shard, capture completes without it. + const cap = run( + ['run', '--lock=add', '--child-process', 'src/entry.js'], + { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1' } } + ) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + t.assert.match(cap.stdout, /PARENT worker-exit code=null signal=SIGTERM/, 'the worker was force-killed') + const lock = JSON.parse(readFileSync(join(tmp, 'stasis.lock.json'), 'utf-8')) + const files = lock.modules['node_modules/fake-jest-worker'].files + t.assert.equal(files['build/workers/processChild.js'], undefined, 'no flush without the opt-in: the killed worker contributed nothing') + t.assert.equal(files['build/workerDep.js'], undefined, 'no flush without the opt-in: the killed worker contributed nothing') +})) + +test('run --bundle=load: a jest-worker-style forked child runs from the bundle with node_modules removed', withTmp((t, tmp) => { + cpSync(forkResolveFixture, tmp, { recursive: true }) + // Graceful path (no kill): the worker's exit-hook shard contributes the fork target and + // its worker-only loads, exactly as before -- the signal flush must not perturb it. + const cap = run( + ['run', '--lock=add', '--bundle=add', '--bundle-file=snapshot.br', '--child-process', 'src/entry.js'], + { cwd: tmp } + ) + t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) + t.assert.match(cap.stdout, /PARENT worker-exit code=0 signal=null/, 'the worker exited gracefully (exit-hook shard)') + // Remove the dependency tree: the bundle is the only place the package -- including the + // fork target the parent only ever require.resolve()d -- can come from. + rmSync(join(tmp, 'node_modules'), { recursive: true }) + const r = run( + ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], + { cwd: tmp } + ) + t.assert.equal(r.status, 0, `load stderr: ${r.stderr}`) + t.assert.match(r.stdout, /ENTRY result=worked on input-1 with types-shared-by-parent-and-child/) + t.assert.match(r.stdout, /PARENT worker-exit code=0 signal=null/) +})) diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/index.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/index.js new file mode 100644 index 00000000..65de533b --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/index.js @@ -0,0 +1,14 @@ +'use strict'; + +const { ChildProcessWorker } = require('./workers/ChildProcessWorker'); + +// jest-worker-shaped farm: run one call on a forked worker, then end() it the way +// jest-worker's BaseWorkerPool.end() does (an END message, then a delayed forceExit). +async function runTask(input) { + const worker = new ChildProcessWorker(); + const result = await worker.call(input); + await worker.end(); + return result; +} + +module.exports = { runTask }; diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/types.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/types.js new file mode 100644 index 00000000..05f2f51f --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/types.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = { hello: 'types-shared-by-parent-and-child' }; diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workerDep.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workerDep.js new file mode 100644 index 00000000..2bc7ef50 --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workerDep.js @@ -0,0 +1,7 @@ +'use strict'; + +// Loaded ONLY inside the forked worker, lazily on the first call -- models the +// transform toolchain a Metro worker loads per task (babel.config.js, the preset + +// plugin graph), which no other process in the tree ever sees. Its attestation can +// come from nothing but the worker's own shard. +module.exports = { transform: (input, hello) => `worked on ${input} with ${hello}` }; diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js new file mode 100644 index 00000000..c8c2001c --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js @@ -0,0 +1,47 @@ +'use strict'; + +const { fork } = require('child_process'); +const { hello } = require('../types'); + +// Mirrors jest-worker's BaseWorkerPool.end(): the worker gets an END message and this +// long to exit gracefully before it is force-killed by SIGTERM (losing its exit hooks). +const FORCE_EXIT_DELAY = 500; + +// Mirrors jest-worker's ChildProcessWorker: the parent process NEVER require()s +// ./processChild -- it only require.resolve()s it and forks the resolved path, so the +// file's bytes are loaded exclusively in the forked child, as that child's entry. +class ChildProcessWorker { + constructor() { + this._child = fork(require.resolve('./processChild'), [], { + env: { ...process.env, FAKE_JW: hello }, + // jest-worker filters only --debug/--inspect, keeping e.g. --import intact. + execArgv: process.execArgv.filter((v) => !/^--(debug|inspect)/.test(v)), + }); + } + + call(input) { + return new Promise((resolve, reject) => { + this._child.once('message', (msg) => { + if (msg[0] === 'ok') resolve(msg[1]); + else reject(new Error(`worker error: ${msg[1]}`)); + }); + this._child.send(['call', input]); + }); + } + + end() { + return new Promise((resolve) => { + const forceExitTimeout = setTimeout(() => { + this._child.kill('SIGTERM'); + }, FORCE_EXIT_DELAY); + this._child.once('exit', (code, signal) => { + clearTimeout(forceExitTimeout); + console.log(`PARENT worker-exit code=${code} signal=${signal}`); + resolve(); + }); + this._child.send(['end']); + }); + } +} + +module.exports = { ChildProcessWorker }; diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js new file mode 100644 index 00000000..b672e786 --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js @@ -0,0 +1,27 @@ +'use strict'; + +// Loaded ONLY as the forked child's entry (mirrors jest-worker's processChild.js) -- +// the parent resolves this path via require.resolve but never loads it in-process. +const { hello } = require('../types'); + +// HOLD_HANDLE simulates a worker whose event loop never drains after the END message +// (a ref'd interval, a watcher some transformer left behind): the parent then +// force-kills it by SIGTERM after FORCE_EXIT_DELAY, exactly like jest-worker's +// forceExit. beforeExit/exit hooks never fire on a signal death -- the loader's +// SIGTERM shard flush is the only thing that saves this worker's capture. +if (process.env.HOLD_HANDLE) setInterval(() => {}, 1 << 30); + +const messageListener = (msg) => { + if (msg[0] === 'call') { + // Lazy, child-only load (mirrors processChild's dynamic require of the module it + // was initialized with): workerDep is observed by NO process but this worker. + const { transform } = require('../workerDep'); + process.send(['ok', transform(msg[1], hello)]); + } else if (msg[0] === 'end') { + // jest-worker's exitProcess(): drop the listener + unref the IPC channel so the + // event loop drains and the process exits gracefully (beforeExit/exit hooks run). + process.removeListener('message', messageListener); + if (process.channel) process.channel.unref(); + } +}; +process.on('message', messageListener); diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/package.json b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/package.json new file mode 100644 index 00000000..4a16fbda --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/package.json @@ -0,0 +1 @@ +{ "name": "fake-jest-worker", "version": "1.0.0", "main": "./build/index.js" } diff --git a/tests/fixtures/cli-run-fork-resolve/package.json b/tests/fixtures/cli-run-fork-resolve/package.json new file mode 100644 index 00000000..89c397ef --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/package.json @@ -0,0 +1 @@ +{ "name": "stasis-cli-run-fork-resolve", "version": "0.0.0", "private": true } diff --git a/tests/fixtures/cli-run-fork-resolve/pnpm-workspace.yaml b/tests/fixtures/cli-run-fork-resolve/pnpm-workspace.yaml new file mode 100644 index 00000000..a21a36ab --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +# Marks the fixture root so the copied-to-tmp fixture never walks up past itself. +packages: [] diff --git a/tests/fixtures/cli-run-fork-resolve/src/entry.js b/tests/fixtures/cli-run-fork-resolve/src/entry.js new file mode 100644 index 00000000..73428a24 --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/src/entry.js @@ -0,0 +1,9 @@ +'use strict'; + +// Mirrors how Metro drives jest-worker: the whole chain is CommonJS, and the fork +// TARGET (processChild.js) is reached only via require.resolve() in this process. +const { runTask } = require('fake-jest-worker'); + +runTask('input-1').then((result) => { + console.log(`ENTRY result=${result}`); +}); diff --git a/tests/metro-run.helper.js b/tests/metro-run.helper.js index 0d44937e..14f325d7 100644 --- a/tests/metro-run.helper.js +++ b/tests/metro-run.helper.js @@ -133,3 +133,11 @@ if (mode === 'customSerializer') { } if (State.preload) State.preload.write() + +// Test-only visibility (STASIS_TEST_REPORT_SIGNAL_FLUSH=1): report whether the plugin opted +// this build's children into the loader's SIGTERM shard flush -- metro.js sets +// EXODUS_STASIS_SHARD_SIGNAL_FLUSH on process.env for CAPTURING runs so the workers Metro +// forks inherit it; non-writing runs (frozen/load/inert) must leave it unset. +if (process.env.STASIS_TEST_REPORT_SIGNAL_FLUSH) { + console.log(`SIGNAL_FLUSH=${process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ?? ''}`) +} diff --git a/tests/metro.test.js b/tests/metro.test.js index 71e8b8d0..745c473d 100644 --- a/tests/metro.test.js +++ b/tests/metro.test.js @@ -57,6 +57,7 @@ const { EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_DEBUG: _d, EXODUS_STASIS_CHILD_PROCESS: _cp, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...cleanEnv } = process.env @@ -374,6 +375,29 @@ test('frozen verify is EXEMPT from the child-process assert (no shards minted in t.assert.equal(r.status, 0, `frozen verify must not require --child-process; stderr: ${r.stderr}`) })) +test('a CAPTURING plugin opts the build children into the SIGTERM shard flush (env flag set)', withTmp((t, tmp) => { + // Metro ends transform workers by signal when they don't drain in time (jest-worker's + // forceExit), which would silently drop their shards; the plugin therefore sets + // EXODUS_STASIS_SHARD_SIGNAL_FLUSH on process.env at construction so the workers Metro + // forks later inherit it and the loader flushes their shard on SIGTERM (hooks.js). The + // forked-worker behavior itself is pinned in cli.test.js (cli-run-fork-resolve). + cpSync(fullFixture, tmp, { recursive: true }) + rmSync(join(tmp, 'stasis.lock.json')) + const r = run('src/entry.js', { cwd: tmp, env: { ...withOpts({ lock: 'add' }), STASIS_TEST_REPORT_SIGNAL_FLUSH: '1' } }) + t.assert.equal(r.status, 0, `stderr: ${r.stderr}`) + t.assert.match(r.stdout, /^SIGNAL_FLUSH=1$/m, 'capturing run must set the flush opt-in for its children') +})) + +test('a NON-writing plugin does not opt children into the SIGTERM shard flush', withTmp((t, tmp) => { + // Same gate as the child-process assert (writeLockfile || writeBundle): a frozen verify + // mints no shard channel, so its children have nothing to flush -- the flag must stay + // unset, keeping the loader's signal disposition untouched outside capturing Metro builds. + cpSync(fullFixture, tmp, { recursive: true }) + const r = run('src/entry.js', { cwd: tmp, env: { EXODUS_STASIS_LOCK: 'frozen', EXODUS_STASIS_SCOPE: 'full', STASIS_TEST_REPORT_SIGNAL_FLUSH: '1' } }) + t.assert.equal(r.status, 0, `stderr: ${r.stderr}`) + t.assert.match(r.stdout, /^SIGNAL_FLUSH=$/m, 'a non-writing run must not set the flush opt-in') +})) + test('a Rule-6 sidecar inherits childProcess from the preload (env-less programmatic option)', withTmp((t, tmp) => { cpSync(fullFixture, tmp, { recursive: true }) // Empty env => no env opinion, so childProcess is driven purely by the explicit preload option. From a1e2851af099390a37c6eb0a1d1b047b252218eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:52:51 +0000 Subject: [PATCH 2/4] refactor: parse the shard-signal-flush opt-in through Config, reuse save() in the handler Review cleanups, no behavior change on well-formed input: - EXODUS_STASIS_SHARD_SIGNAL_FLUSH is now a Config field parsed with the strict envBool (like every other boolean toggle -- '0'/'false' disable instead of truthily enabling) and consumed as config.shardSignalFlush next to config.childProcess; it stays env-only plumbing (no option, no stasis.config.json key), set by StasisMetro exactly as before. - The SIGTERM handler runs save() instead of copying its child branch, so the child-flush policy (the aborted taint gate included) lives at one site; the redundant SHARD_DIR conjunct in the registration guard is dropped (writeChildShard's own dir+key guard already covers it). - metro.js's constructor comment points at the KNOWN LIMITATIONS bullet for the forceExit mechanics instead of retelling them. - fixture: drop the write-only FAKE_JW env var (the parent-side ../types require now shows in the worker-exit log instead); FORCE_EXIT_DELAY is env-overridable so kill-path tests don't sleep the full 500ms. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i --- stasis-core/src/config.js | 21 +++++++++++ stasis-core/src/hooks.js | 37 ++++++++++--------- stasis-plugins/src/metro.js | 19 ++++------ .../build/workers/ChildProcessWorker.js | 11 ++++-- 4 files changed, 56 insertions(+), 32 deletions(-) diff --git a/stasis-core/src/config.js b/stasis-core/src/config.js index 199018e4..fb0ca2fb 100644 --- a/stasis-core/src/config.js +++ b/stasis-core/src/config.js @@ -86,6 +86,7 @@ export class Config { #resources #debug #childProcess + #shardSignalFlush #fs #brotliQuality @@ -112,6 +113,7 @@ export class Config { debug: process.env.EXODUS_STASIS_DEBUG || undefined, resources: process.env.EXODUS_STASIS_RESOURCES || undefined, childProcess: process.env.EXODUS_STASIS_CHILD_PROCESS || undefined, + shardSignalFlush: process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH || undefined, fs: process.env.EXODUS_STASIS_FS || undefined, brotliQuality: process.env.EXODUS_STASIS_BROTLI_QUALITY || undefined, } @@ -160,6 +162,11 @@ export class Config { this.#resourcesBundleFile = this.#env.resourcesBundleFile || resourcesBundleFile || undefined this.#debug = this.#env.debug !== undefined ? envBool('EXODUS_STASIS_DEBUG', this.#env.debug) : (debug ?? false) this.#childProcess = this.#env.childProcess !== undefined ? envBool('EXODUS_STASIS_CHILD_PROCESS', this.#env.childProcess) : (childProcess ?? false) + // Env-only (no option, no stasis.config.json key): shard-channel plumbing a parent + // process sets for its descendants, not a user-facing knob -- see the getter. + this.#shardSignalFlush = this.#env.shardSignalFlush !== undefined + ? envBool('EXODUS_STASIS_SHARD_SIGNAL_FLUSH', this.#env.shardSignalFlush) + : false // env wins over the option, like scope/lock/bundle; undefined means "fs untouched" (off). this.#fs = this.#env.fs || fs || undefined // env wins over the option; no `||` chaining -- 0 is a valid quality and falsy. @@ -365,6 +372,20 @@ export class Config { return this.#childProcess } + // Opt-in (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, env-only): a capturing CHILD flushes its + // shard when ended by SIGTERM, then re-delivers the signal (hooks.js). Set on + // process.env by StasisMetro's capture wiring so the transform workers Metro forks + // inherit it -- jest-worker force-kills a worker whose event loop doesn't drain within + // 500ms of its END message, which would otherwise silently drop the worker's shard. + // Deliberately NOT a global default (a signal listener changes a child's default-kill + // disposition -- the user's domain in arbitrary children) and NOT a user option: it is + // channel plumbing like EXODUS_STASIS_SHARD_DIR/_KEY, but parsed strictly through + // envBool like every other boolean toggle so '0'/'false' disable rather than enable. + // Not attested (not in `values`), like childProcess. + get shardSignalFlush() { + return this.#shardSignalFlush + } + // The --fs hook mode: 'sync' patches the sync fs readers, 'async' additionally patches // their async (callback + fs.promises) counterparts, undefined leaves fs untouched. Set via // --fs / EXODUS_STASIS_FS / "fs" in stasis.config.json; the runtime loader (hooks.js) reads diff --git a/stasis-core/src/hooks.js b/stasis-core/src/hooks.js index f665732b..0f36b842 100644 --- a/stasis-core/src/hooks.js +++ b/stasis-core/src/hooks.js @@ -525,18 +525,20 @@ function initState(root) { process.on('beforeExit', save) process.on('exit', save) - // EXODUS_STASIS_SHARD_SIGNAL_FLUSH (opt-in; StasisMetro's capture wiring sets it for its - // build's children -- see the plugin's constructor): flush the shard when a capturing - // child is ended BY SIGNAL. Metro's transform workers are routinely killed that way: - // jest-worker's end() sends its END message, gives the worker 500ms to drain its event - // loop, then forceExit()s it -- SIGTERM, and SIGKILL another 500ms later -- and a worker - // whose loop doesn't drain in time (a watcher or ref'd timer some transformer left - // behind) dies by SIGTERM, which bypasses beforeExit/exit entirely. Everything ONLY that - // worker observed (its fork-target entry, jest-worker's processChild.js; babel.config.js - // + the preset/plugin graph loaded per transform) then silently vanished from the - // capture, surfacing only later at bundle=load as "file not attested in bundle" when the - // forked child's loader can't be served its own entry. The handler flushes the shard and - // re-delivers the signal; semantics are preserved: + // config.shardSignalFlush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, opt-in; StasisMetro's + // capture wiring sets it for its build's children -- see the plugin's constructor): + // flush the shard when a capturing child is ended BY SIGNAL. Metro's transform workers + // are routinely killed that way: jest-worker's end() sends its END message, gives the + // worker 500ms to drain its event loop, then forceExit()s it -- SIGTERM, and SIGKILL + // another 500ms later -- and a worker whose loop doesn't drain in time (a watcher or + // ref'd timer some transformer left behind) dies by SIGTERM, which bypasses + // beforeExit/exit entirely. Everything ONLY that worker observed (its fork-target + // entry, jest-worker's processChild.js; babel.config.js + the preset/plugin graph + // loaded per transform) then silently vanished from the capture, surfacing only later + // at bundle=load as "file not attested in bundle" when the forked child's loader can't + // be served its own entry. The handler runs the same save() the exit hooks run (for a + // child: forward the shard unless the capture aborted) and re-delivers the signal; + // semantics are preserved: // - the `once` listener is already removed when the handler runs, so when no user // listener remains the OS default disposition is restored and the re-kill terminates // the process BY that signal (the parent still observes code=null, signal=SIGTERM); @@ -548,16 +550,15 @@ function initState(root) { // NOT a global default: a signal listener changes a process's default-kill disposition, // which in arbitrary user children is the user's domain -- the Metro plugin opts in its // KNOWN tool workers, where flush-then-redeliver is the right trade. Gated additionally - // to a CHILD (the root's signal behavior stays untouched -- servers own it) that is - // actually capturing into an inherited shard channel. SIGTERM only -- it's what + // to a CHILD (the root's signal behavior stays untouched -- servers own it) with shard + // forwarding on; a channel that was never minted (root mint failure cleared DIR+KEY) just + // makes the flush a no-op via writeChildShard's own guard. SIGTERM only -- it's what // jest-worker's forceExit sends (Metro doesn't override killSignal); SIGKILL remains // uncatchable (jest-worker's memory-limit killChild), the documented best-effort // residual -- a later frozen/load run fails closed on anything genuinely missing. - if (isChildProcess && shardForwardingEnabled() - && process.env.EXODUS_STASIS_SHARD_DIR && process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH) { + if (isChildProcess && shardForwardingEnabled() && state.config.shardSignalFlush) { process.once('SIGTERM', () => { - // Same taint gate as save(): a rejected capture forwards nothing. - if (!aborted) writeChildShard() + save() if (process.listenerCount('SIGTERM') === 0) process.kill(process.pid, 'SIGTERM') }) } diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index cd4a4969..463fadf7 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -175,17 +175,14 @@ export class StasisMetro { 'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' + 'or "childProcess": true in stasis.config.json.' ) - // Metro ends its transform workers BY SIGNAL when they don't drain in time: jest-worker's - // end() gives a worker 500ms after the END message, then forceExit()s it (SIGTERM) -- and - // a signal death bypasses the worker's exit hooks, silently dropping its shard: the - // fork-target entry (jest-worker's processChild.js, which only the worker ever loads -- - // the parent merely require.resolve()s it) plus the per-transform babel toolchain. Opt - // this build's children into the loader's SIGTERM shard flush (hooks.js): the flag rides - // process.env, so the workers Metro forks AFTER config time inherit it. Scoped HERE, not - // globally -- a signal listener changes a child's default-kill disposition, which is the - // user's domain in arbitrary children, but Metro's transform workers are known tool - // processes where flush-then-redeliver is the right trade. The plugin can't do this in - // the workers itself (only the loader runs there), so an env opt-in is the seam. + // Opt this build's children into the loader's SIGTERM shard flush: jest-worker's + // forceExit would otherwise silently drop a non-draining worker's shard (mechanics in + // the KNOWN LIMITATIONS bullet above; the handler lives in hooks.js). The flag rides + // process.env, so the workers Metro forks AFTER config time inherit it -- the plugin + // can't act inside the workers (only the loader runs there), so an env opt-in is the + // seam. Scoped HERE, not globally: a signal listener changes a child's default-kill + // disposition, which is the user's domain in arbitrary children, but Metro's transform + // workers are known tool processes where flush-then-redeliver is the right trade. process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH = '1' } // resources is a Config field now (validated + coordinated against any preload in diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js index c8c2001c..3a5601c7 100644 --- a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js @@ -5,7 +5,10 @@ const { hello } = require('../types'); // Mirrors jest-worker's BaseWorkerPool.end(): the worker gets an END message and this // long to exit gracefully before it is force-killed by SIGTERM (losing its exit hooks). -const FORCE_EXIT_DELAY = 500; +// jest-worker hardcodes 500ms; env-overridable so kill-path tests (which wait out the +// full delay in real time) can shorten it -- the kill semantics are delay-independent +// once the call round-trip has completed. +const FORCE_EXIT_DELAY = Number(process.env.FORCE_EXIT_DELAY ?? 500); // Mirrors jest-worker's ChildProcessWorker: the parent process NEVER require()s // ./processChild -- it only require.resolve()s it and forks the resolved path, so the @@ -13,7 +16,6 @@ const FORCE_EXIT_DELAY = 500; class ChildProcessWorker { constructor() { this._child = fork(require.resolve('./processChild'), [], { - env: { ...process.env, FAKE_JW: hello }, // jest-worker filters only --debug/--inspect, keeping e.g. --import intact. execArgv: process.execArgv.filter((v) => !/^--(debug|inspect)/.test(v)), }); @@ -36,7 +38,10 @@ class ChildProcessWorker { }, FORCE_EXIT_DELAY); this._child.once('exit', (code, signal) => { clearTimeout(forceExitTimeout); - console.log(`PARENT worker-exit code=${code} signal=${signal}`); + // `hello` keeps the PARENT-side require('../types') load -- real jest-worker's + // ChildProcessWorker requires ../types too, which is what puts the shared types.js + // in the main process's capture alongside the child's. + console.log(`PARENT worker-exit code=${code} signal=${signal} types=${hello}`); resolve(); }); this._child.send(['end']); From b6218accce597f7032d44d2161da9d0c6affbd8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:52:51 +0000 Subject: [PATCH 3/4] test: probe-gate the off-disk require.resolve load half for Node lines that can't serve it test(26.0.0) failed on the two bundle=load runs with node_modules removed: on the affected Node lines the ESM->CJS translator's require.resolve skips the sync hooks and stats disk, so an off-disk fork target can't be resolved from the bundle -- the documented limitation commonjs.test.js probes as RESOLVE_RESOLVE_FROM_BUNDLE (24.14, 24.x and 26.3+ serve it; 26.0-26.2 don't). The killed-worker test's load half now runs with sources on disk (Metro's model, and the shape of the original report -- resolution from disk, bytes served and verified from the bundle), which works on every Node line; the pruned self-containment test keeps its rmSync but gates the load half on the same lazy behavior probe, skipping with a diagnostic where Node can't do it, and pins the graceful-shard attestation unconditionally. Also folds in the review cleanups on the same tests: FORCE_EXIT_DELAY=50 for the kill paths and a deduped absence assertion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i --- tests/cli.test.js | 69 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/tests/cli.test.js b/tests/cli.test.js index 04f0c051..51ccf4ea 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -2452,15 +2452,48 @@ const processChildEdge = (lock) => { return undefined } +// Can THIS Node serve require.resolve() of an OFF-DISK target from a bundle-served CJS +// module? Some Node lines can't (the ESM->CJS translator's require.resolve skips the sync +// hooks and stats disk) -- see commonjs.test.js's RESOLVE_RESOLVE_FROM_BUNDLE for the full +// story; this is the same behavior probe, lazy + memoized since only the pruned-load test +// below needs it. On a "no" Node that test skips its load half with a diagnostic, exactly +// like the commonjs suite; the disk-present load path (Metro's model) works on every Node +// and stays asserted unconditionally. +let resolveResolveFromBundleMemo +const resolveResolveFromBundle = () => { + if (resolveResolveFromBundleMemo !== undefined) return resolveResolveFromBundleMemo + const dir = mkdtempSync(join(tmpdir(), 'stasis-fork-resolve-probe-')) + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', private: true })) + writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages: []\n') + writeFileSync(join(dir, 'index.mjs'), "import './r.cjs'\n") + writeFileSync(join(dir, 'r.cjs'), "require.resolve('pd')\nconsole.log('ok')\n") // resolve-only + const pd = join(dir, 'node_modules', 'pd') + mkdirSync(pd, { recursive: true }) + writeFileSync(join(pd, 'package.json'), JSON.stringify({ name: 'pd', version: '1.0.0', main: 'index.js' })) + writeFileSync(join(pd, 'index.js'), 'module.exports = 1\n') + const bundlePath = join(dir, 'p.br') + const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) + if (save.status !== 0) return (resolveResolveFromBundleMemo = false) + rmSync(join(dir, 'node_modules'), { recursive: true, force: true }) + const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) + return (resolveResolveFromBundleMemo = load.status === 0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + test('run --child-process: a force-killed worker still contributes its shard under the Metro-plugin signal-flush opt-in', withTmp((t, tmp) => { cpSync(forkResolveFixture, tmp, { recursive: true }) // HOLD_HANDLE keeps the worker's event loop busy after the END message, so the parent - // force-kills it jest-worker-style. Only the SIGTERM flush can save this worker's shard. - // EXODUS_STASIS_SHARD_SIGNAL_FLUSH stands in for StasisMetro's capture wiring, which sets - // it on process.env so the build's forked workers inherit it (metro.test.js pins that). + // force-kills it jest-worker-style (FORCE_EXIT_DELAY shortened -- the call round-trip + // has completed, so the kill path is identical at any positive delay). Only the SIGTERM + // flush can save this worker's shard. EXODUS_STASIS_SHARD_SIGNAL_FLUSH stands in for + // StasisMetro's capture wiring, which sets it on process.env so the build's forked + // workers inherit it (metro.test.js pins that). const cap = run( ['run', '--lock=add', '--bundle=add', '--bundle-file=snapshot.br', '--child-process', 'src/entry.js'], - { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } + { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', FORCE_EXIT_DELAY: '50', EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /ENTRY result=worked on input-1/, 'the forked worker ran') @@ -2480,10 +2513,11 @@ test('run --child-process: a force-killed worker still contributes its shard und 'the worker-only toolchain module is attested via the flushed shard') t.assert.equal(lock.formats['node_modules/fake-jest-worker/build/workers/processChild.js'], 'commonjs') - // The load half of the regression: with node_modules gone, the bundle is the only place - // the forked child's entry and its worker-only dep can come from. This threw "file not - // attested in bundle: .../processChild.js" when the kill lost the shard. - rmSync(join(tmp, 'node_modules'), { recursive: true }) + // The load half of the regression, with sources ON DISK (Metro's model -- and the shape + // of the original report): resolution goes to disk, but the forked child's entry is + // SERVED from the bundle, which threw "file not attested in bundle: .../processChild.js" + // when the kill lost the shard. Disk-present so it runs on every Node line; the pruned + // (off-disk require.resolve) variant is the next test, gated on the Node behavior probe. const r = run( ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], { cwd: tmp } @@ -2500,14 +2534,15 @@ test('run --child-process: WITHOUT the signal-flush opt-in a force-killed worker // documented best-effort behavior -- kill loses the shard, capture completes without it. const cap = run( ['run', '--lock=add', '--child-process', 'src/entry.js'], - { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1' } } + { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', FORCE_EXIT_DELAY: '50' } } ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /PARENT worker-exit code=null signal=SIGTERM/, 'the worker was force-killed') const lock = JSON.parse(readFileSync(join(tmp, 'stasis.lock.json'), 'utf-8')) const files = lock.modules['node_modules/fake-jest-worker'].files + // One absence suffices: the whole shard is one atomically-written file, so its records + // (processChild.js, workerDep.js) can't land partially. t.assert.equal(files['build/workers/processChild.js'], undefined, 'no flush without the opt-in: the killed worker contributed nothing') - t.assert.equal(files['build/workerDep.js'], undefined, 'no flush without the opt-in: the killed worker contributed nothing') })) test('run --bundle=load: a jest-worker-style forked child runs from the bundle with node_modules removed', withTmp((t, tmp) => { @@ -2520,8 +2555,18 @@ test('run --bundle=load: a jest-worker-style forked child runs from the bundle w ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /PARENT worker-exit code=0 signal=null/, 'the worker exited gracefully (exit-hook shard)') - // Remove the dependency tree: the bundle is the only place the package -- including the - // fork target the parent only ever require.resolve()d -- can come from. + const files = JSON.parse(readFileSync(join(tmp, 'stasis.lock.json'), 'utf-8')).modules['node_modules/fake-jest-worker'].files + t.assert.ok(files['build/workers/processChild.js']?.startsWith('sha512-'), 'fork target attested via the exit-hook shard') + t.assert.ok(files['build/workerDep.js']?.startsWith('sha512-'), 'worker-only dep attested via the exit-hook shard') + + // Self-containment: with the dependency tree REMOVED, the bundle is the only place the + // package -- including the fork target the parent only ever require.resolve()s -- can come + // from. That needs the off-disk require.resolve served from the bundle, which some Node + // lines can't do (see the probe above); the attestation asserts above ran regardless. + if (!resolveResolveFromBundle()) { + t.diagnostic(`skipping bundle-only require.resolve load on ${process.version} (Node loader limitation)`) + return + } rmSync(join(tmp, 'node_modules'), { recursive: true }) const r = run( ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], From 3b244980901f29138bbede557ff4e721958dd170 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 06:44:39 +0000 Subject: [PATCH 4/4] fix(hooks): honor pre-registered SIGTERM once-handlers, harden re-delivery; address review findings Applies the max-effort review of the SIGTERM shard-flush branch. Behavioral fixes (stasis-core/src/hooks.js, stasis-plugins/src/metro.js): - The re-kill decision now also remembers listeners that existed at registration time: a --require preload's once('SIGTERM') handler has already self-removed by the time the flush handler runs in the same emit, so the post-emit listenerCount alone re-killed the process out from under the user's in-flight graceful cleanup (reproduced end to end; the preload's cleanup now completes). Conservative direction: when in doubt, don't re-kill -- jest-worker's own SIGKILL escalation bounds the lifetime. - save() runs under try/finally so the documented death-by-signal contract cannot be skipped by a future throw path. - StasisMetro sets the opt-in with ||= (an explicit ambient '0'/'false' opt-out -- envBool's disable values -- is honored, only unset/empty is claimed) and keys it on the PRELOAD where one exists (a Rule-6 sidecar's own bundle mode says nothing about the shard channel, so the flag is no longer set where it could never engage). Documentation accuracy: - metro.js KNOWN LIMITATIONS no longer over-claims: the flush is best-effort within forceExit's 500ms SIGTERM->SIGKILL window, and non-SIGTERM kills (group SIGINT/SIGHUP, killSignal overrides) plus a nested orchestration's Metro MAIN process (its config snapshots before the plugin sets the flag) remain lossy residuals, fail-closed at verify. hooks.js/config.js scope comments now own the real blast radius (every capturing child forked after config time). - webpack.js's signal-loss comment no longer equates its deferred main-process write with the (now mitigated) metro worker story, and states why the opt-in cannot help it. Tests: - fixture knobs renamed to STASIS_TEST_HOLD_HANDLE / STASIS_TEST_FORCE_EXIT_DELAY (ambient generic names leaked through cleanEnv and could flip tests; demonstrated live) and the delay parses with || so empty/garbage can't zero it; end() now mirrors jest-worker's SIGTERM->SIGKILL escalation so a re-kill regression fails the signal assertion instead of hanging the suite. - the graceful test now runs WITH the flush opt-in engaged (the dominant production combination -- flag set, worker drains -- was previously uncovered) and the killed-worker test gains a probe-gated pruned load, covering flush-shard + bundle-only self-containment. - EXODUS_STASIS_SHARD_SIGNAL_FLUSH joins config.test.js's ENV_KEYS with a strict-envBool unit matrix (mirroring childProcess), and the five suites with enumerated env strip-lists now strip it too. - the resolve-from-bundle probe drops its unreachable memoization (single call site) while staying lazy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i --- stasis-core/src/config.js | 7 ++- stasis-core/src/hooks.js | 36 ++++++++--- stasis-plugins/src/metro.js | 44 ++++++++----- stasis-plugins/src/webpack.js | 12 ++-- tests/build-cmd.test.js | 1 + tests/bundle-cmd.test.js | 1 + tests/cli-fs.test.js | 1 + tests/cli.test.js | 62 ++++++++++++------- tests/config.test.js | 38 ++++++++++++ tests/extract-cmd.test.js | 1 + .../build/workers/ChildProcessWorker.js | 18 +++++- .../build/workers/processChild.js | 6 +- tests/popular-npm-modules.test.js | 1 + 13 files changed, 172 insertions(+), 56 deletions(-) diff --git a/stasis-core/src/config.js b/stasis-core/src/config.js index fb0ca2fb..57085df0 100644 --- a/stasis-core/src/config.js +++ b/stasis-core/src/config.js @@ -374,9 +374,10 @@ export class Config { // Opt-in (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, env-only): a capturing CHILD flushes its // shard when ended by SIGTERM, then re-delivers the signal (hooks.js). Set on - // process.env by StasisMetro's capture wiring so the transform workers Metro forks - // inherit it -- jest-worker force-kills a worker whose event loop doesn't drain within - // 500ms of its END message, which would otherwise silently drop the worker's shard. + // process.env by StasisMetro's capture wiring, so every capturing child the build forks + // after config time inherits it -- Metro's transform workers are the intended audience: + // jest-worker force-kills a worker whose event loop doesn't drain within 500ms of its + // END message, which would otherwise silently drop the worker's shard. // Deliberately NOT a global default (a signal listener changes a child's default-kill // disposition -- the user's domain in arbitrary children) and NOT a user option: it is // channel plumbing like EXODUS_STASIS_SHARD_DIR/_KEY, but parsed strictly through diff --git a/stasis-core/src/hooks.js b/stasis-core/src/hooks.js index 0f36b842..13a18abd 100644 --- a/stasis-core/src/hooks.js +++ b/stasis-core/src/hooks.js @@ -542,9 +542,19 @@ function initState(root) { // - the `once` listener is already removed when the handler runs, so when no user // listener remains the OS default disposition is restored and the re-kill terminates // the process BY that signal (the parent still observes code=null, signal=SIGTERM); - // - when user code registered its own handler, we only flush and do NOT re-kill -- the - // user's handler owns process lifetime, and if it later exits normally the exit-hook - // write dedups via lastShardWritten; + // the finally makes that re-delivery unconditional even if a future save() edit + // grows a throw path (today it cannot throw on the child branch); + // - when user code owns process lifetime we only flush and do NOT re-kill. "User code" + // is detected two ways, because a `once` listener that ran EARLIER in this same emit + // has already removed itself and is invisible to a post-emit listenerCount: listeners + // present at REGISTRATION time (a --require preload's graceful-shutdown hook -- those + // always precede this initState-time registration) are remembered in + // `userOwnedSigterm`, and listeners added later are still attached when the handler + // runs. A user handler that exits normally re-flushes via the exit hooks + // (lastShardWritten dedups); if a SECOND signal kills it instead, capture accrued + // after this one-shot flush is lost -- an accepted residual, fail-closed at verify. + // The conservative direction is deliberate: when in doubt, don't re-kill -- the + // worst case is jest-worker's own SIGKILL landing 500ms later; // - signal listeners don't ref the event loop, so a cleanly-draining worker still // exits (and writes) exactly as before. // NOT a global default: a signal listener changes a process's default-kill disposition, @@ -553,13 +563,23 @@ function initState(root) { // to a CHILD (the root's signal behavior stays untouched -- servers own it) with shard // forwarding on; a channel that was never minted (root mint failure cleared DIR+KEY) just // makes the flush a no-op via writeChildShard's own guard. SIGTERM only -- it's what - // jest-worker's forceExit sends (Metro doesn't override killSignal); SIGKILL remains - // uncatchable (jest-worker's memory-limit killChild), the documented best-effort - // residual -- a later frozen/load run fails closed on anything genuinely missing. + // jest-worker's forceExit sends (Metro doesn't override killSignal). Best-effort + // residuals, all fail-closed at a later frozen/load run: the flush must fit inside + // forceExit's 500ms SIGTERM->SIGKILL window; SIGKILL and any OTHER signal (group + // SIGINT/SIGHUP, a killSignal override) are not covered; and a capturing child that + // is itself the process EVALUATING metro.config.js (a nested orchestration) snapshots + // its Config before the plugin sets the flag, so only its DESCENDANTS are covered -- + // see the plugin's KNOWN LIMITATIONS bullet. if (isChildProcess && shardForwardingEnabled() && state.config.shardSignalFlush) { + const userOwnedSigterm = process.listenerCount('SIGTERM') > 0 process.once('SIGTERM', () => { - save() - if (process.listenerCount('SIGTERM') === 0) process.kill(process.pid, 'SIGTERM') + try { + save() + } finally { + if (!userOwnedSigterm && process.listenerCount('SIGTERM') === 0) { + process.kill(process.pid, 'SIGTERM') + } + } }) } } diff --git a/stasis-plugins/src/metro.js b/stasis-plugins/src/metro.js index 463fadf7..e466ef50 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -126,14 +126,19 @@ function metroDefaultSerializer() { // - Child-process capture is best-effort within that one shot. A writing plugin opts the // build's workers into the loader's SIGTERM shard flush (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, // set in the constructor), so a worker force-killed by jest-worker's forceExit -- SIGTERM -// 500ms after END, routine for a transform worker whose event loop doesn't drain -- still -// contributes its shard. SIGKILL remains uncatchable (jest-worker's memory-limit killChild, -// a killSignal:'SIGKILL' override): such a worker loses its shard SILENTLY at capture time -// -- a later frozen run still catches the gap (fail-closed), but nothing warns during -// capture. And it relies on the loader's exit/signal-time writes, which standalone (no -// stasis loader) Metro never reaches -- it mints no shard dir and produces no -// toolchain-complete lockfile. Use a one-shot `metro build` under -// `stasis run --child-process` to capture, then verify with `--lock=frozen`. +// 500ms after END, routine for a transform worker whose event loop doesn't drain -- +// contributes its shard best-effort: the flush must complete inside forceExit's follow-up +// 500ms SIGTERM->SIGKILL window. A worker killed any OTHER way still loses its shard +// SILENTLY at capture time -- SIGKILL (jest-worker's memory-limit killChild, a +// killSignal:'SIGKILL' override), a non-SIGTERM signal (group SIGINT/SIGHUP, another +// killSignal override; the flush handler is SIGTERM-only), or a nested orchestration's +// Metro MAIN process (itself a capturing child, it snapshots its config before this +// constructor sets the flag, so only its descendants are covered). A later frozen run +// catches every such gap (fail-closed), but nothing warns during capture. And it relies +// on the loader's exit/signal-time writes, which standalone (no stasis loader) Metro +// never reaches -- it mints no shard dir and produces no toolchain-complete lockfile. +// Use a one-shot `metro build` under `stasis run --child-process` to capture, then +// verify with `--lock=frozen`. // - Scaled asset variants: `import x from './logo.png'` is ONE module in the graph, keyed // by the base path; Metro discovers `logo@2x.png` / `@3x` via its AssetData (parallel // scales/files arrays), NOT as separate graph modules, so only the base file is attested @@ -178,12 +183,23 @@ export class StasisMetro { // Opt this build's children into the loader's SIGTERM shard flush: jest-worker's // forceExit would otherwise silently drop a non-draining worker's shard (mechanics in // the KNOWN LIMITATIONS bullet above; the handler lives in hooks.js). The flag rides - // process.env, so the workers Metro forks AFTER config time inherit it -- the plugin - // can't act inside the workers (only the loader runs there), so an env opt-in is the - // seam. Scoped HERE, not globally: a signal listener changes a child's default-kill - // disposition, which is the user's domain in arbitrary children, but Metro's transform - // workers are known tool processes where flush-then-redeliver is the right trade. - process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH = '1' + // process.env, so EVERY capturing child forked after config time inherits it -- + // Metro's transform workers are the intended audience, but a codegen fork the build + // spawns rides along; the loader's own gates (child + shard forwarding) bound what it + // can do there to a flush-then-redeliver on SIGTERM. The plugin can't act inside the + // workers (only the loader runs there), so an env opt-in is the seam; scoped HERE, + // not globally, because changing a child's default-kill disposition is otherwise the + // user's domain. Two deliberate softenings: + // - keyed on the PRELOAD where one exists: the shard channel is minted by, and the + // children's capture mode follows, the preload's config -- a Rule-6 sidecar's own + // bundle mode says nothing about either, and setting the flag under a frozen + // preload would claim an opt-in that can never engage; + // - `||=`, not `=`: an explicit ambient opt-out ('0'/'false', envBool's disable + // values) is the user's call and is honored; only unset/empty is claimed. + const channel = State.preload ?? state + if (channel.config.writeLockfile || channel.config.writeBundle) { + process.env.EXODUS_STASIS_SHARD_SIGNAL_FLUSH ||= '1' + } } // resources is a Config field now (validated + coordinated against any preload in // resolvePluginState); cache the resolved Set for the per-file classify hot path. diff --git a/stasis-plugins/src/webpack.js b/stasis-plugins/src/webpack.js index 351c8142..4d72c745 100644 --- a/stasis-plugins/src/webpack.js +++ b/stasis-plugins/src/webpack.js @@ -536,10 +536,14 @@ export class StasisWebpack { // KNOWN LIMITATION: the flush runs at process exit, so a multi-compiler one-shot build inside // a process that never exits normally -- one that stays alive after building and is then // killed by a signal (SIGINT/SIGTERM/SIGKILL), which bypass both beforeExit and exit -- would - // not flush. That is the same capture-then-exit assumption the preload already relies on - // (hooks.js) and metro documents; the common build-and-exit flow is unaffected, a - // single-compiler build never reaches here (it writes on `done`), and watch (`--watch`, - // dev-server) capture is refused outright at watchRun (see #applyCaptureMode). + // not flush. That is the same capture-then-exit assumption the preload's ROOT write relies + // on (hooks.js). NB: the SIGTERM shard flush metro opts its workers into + // (EXODUS_STASIS_SHARD_SIGNAL_FLUSH, hooks.js) does NOT help here and setting it wouldn't + // either -- it covers a capturing CHILD's shard forward, while this deferred write is the + // MAIN process's State.write via THIS plugin's own beforeExit/exit listeners, which a signal + // death (or the loader's re-delivered SIGTERM) still bypasses. The common build-and-exit + // flow is unaffected, a single-compiler build never reaches here (it writes on `done`), and + // watch (`--watch`, dev-server) capture is refused outright at watchRun (see #applyCaptureMode). #deferWrite() { this.#deferredWriteDirty = true if (this.#deferredWriteArmed) return diff --git a/tests/build-cmd.test.js b/tests/build-cmd.test.js index 57734a59..8a874c70 100644 --- a/tests/build-cmd.test.js +++ b/tests/build-cmd.test.js @@ -25,6 +25,7 @@ const { EXODUS_STASIS_BUNDLE: _b, EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_DEBUG: _d, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...cleanEnv } = process.env diff --git a/tests/bundle-cmd.test.js b/tests/bundle-cmd.test.js index a147d74a..4420f529 100644 --- a/tests/bundle-cmd.test.js +++ b/tests/bundle-cmd.test.js @@ -46,6 +46,7 @@ const cleanEnv = (() => { EXODUS_STASIS_BUNDLE: _b, EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_DEBUG: _d, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...rest } = process.env return rest diff --git a/tests/cli-fs.test.js b/tests/cli-fs.test.js index c2580de3..9a4f5bd7 100644 --- a/tests/cli-fs.test.js +++ b/tests/cli-fs.test.js @@ -22,6 +22,7 @@ const { EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_RESOURCES_BUNDLE_FILE: _rbf, EXODUS_STASIS_DEBUG: _d, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, EXODUS_STASIS_FS: _fs, ...cleanEnv } = process.env diff --git a/tests/cli.test.js b/tests/cli.test.js index 51ccf4ea..e6ea824d 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -2455,13 +2455,14 @@ const processChildEdge = (lock) => { // Can THIS Node serve require.resolve() of an OFF-DISK target from a bundle-served CJS // module? Some Node lines can't (the ESM->CJS translator's require.resolve skips the sync // hooks and stats disk) -- see commonjs.test.js's RESOLVE_RESOLVE_FROM_BUNDLE for the full -// story; this is the same behavior probe, lazy + memoized since only the pruned-load test -// below needs it. On a "no" Node that test skips its load half with a diagnostic, exactly -// like the commonjs suite; the disk-present load path (Metro's model) works on every Node -// and stays asserted unconditionally. -let resolveResolveFromBundleMemo +// story; this is the same behavior probe, made LAZY (a plain function, called at most twice +// below) instead of that suite's module-load IIFE, since only the pruned-load halves need +// it. On a "no" Node those halves skip with a diagnostic, exactly like the commonjs suite; +// the disk-present load path (Metro's model) works on every Node and stays asserted +// unconditionally. A probe-save failure reads as "no" -- same stance as the commonjs copy, +// backstopped by this suite's many unconditional capture asserts, which make a real capture +// regression loud regardless. const resolveResolveFromBundle = () => { - if (resolveResolveFromBundleMemo !== undefined) return resolveResolveFromBundleMemo const dir = mkdtempSync(join(tmpdir(), 'stasis-fork-resolve-probe-')) try { writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', private: true })) @@ -2474,10 +2475,10 @@ const resolveResolveFromBundle = () => { writeFileSync(join(pd, 'index.js'), 'module.exports = 1\n') const bundlePath = join(dir, 'p.br') const save = run(['run', '--lock=add', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) - if (save.status !== 0) return (resolveResolveFromBundleMemo = false) + if (save.status !== 0) return false rmSync(join(dir, 'node_modules'), { recursive: true, force: true }) const load = run(['run', '--lock=frozen', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: dir }) - return (resolveResolveFromBundleMemo = load.status === 0) + return load.status === 0 } finally { rmSync(dir, { recursive: true, force: true }) } @@ -2485,15 +2486,15 @@ const resolveResolveFromBundle = () => { test('run --child-process: a force-killed worker still contributes its shard under the Metro-plugin signal-flush opt-in', withTmp((t, tmp) => { cpSync(forkResolveFixture, tmp, { recursive: true }) - // HOLD_HANDLE keeps the worker's event loop busy after the END message, so the parent - // force-kills it jest-worker-style (FORCE_EXIT_DELAY shortened -- the call round-trip - // has completed, so the kill path is identical at any positive delay). Only the SIGTERM - // flush can save this worker's shard. EXODUS_STASIS_SHARD_SIGNAL_FLUSH stands in for - // StasisMetro's capture wiring, which sets it on process.env so the build's forked - // workers inherit it (metro.test.js pins that). + // STASIS_TEST_HOLD_HANDLE keeps the worker's event loop busy after the END message, so + // the parent force-kills it jest-worker-style (FORCE_EXIT_DELAY shortened -- the call + // round-trip has completed, so the kill path is identical at any positive delay). Only + // the SIGTERM flush can save this worker's shard. EXODUS_STASIS_SHARD_SIGNAL_FLUSH + // stands in for StasisMetro's capture wiring, which sets it on process.env so the + // build's forked workers inherit it (metro.test.js pins that). const cap = run( ['run', '--lock=add', '--bundle=add', '--bundle-file=snapshot.br', '--child-process', 'src/entry.js'], - { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', FORCE_EXIT_DELAY: '50', EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } + { cwd: tmp, env: { ...cleanEnv, STASIS_TEST_HOLD_HANDLE: '1', STASIS_TEST_FORCE_EXIT_DELAY: '50', EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /ENTRY result=worked on input-1/, 'the forked worker ran') @@ -2516,8 +2517,7 @@ test('run --child-process: a force-killed worker still contributes its shard und // The load half of the regression, with sources ON DISK (Metro's model -- and the shape // of the original report): resolution goes to disk, but the forked child's entry is // SERVED from the bundle, which threw "file not attested in bundle: .../processChild.js" - // when the kill lost the shard. Disk-present so it runs on every Node line; the pruned - // (off-disk require.resolve) variant is the next test, gated on the Node behavior probe. + // when the kill lost the shard. Disk-present so it runs on every Node line. const r = run( ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], { cwd: tmp } @@ -2525,6 +2525,23 @@ test('run --child-process: a force-killed worker still contributes its shard und t.assert.equal(r.status, 0, `load stderr: ${r.stderr}`) t.assert.match(r.stdout, /ENTRY result=worked on input-1 with types-shared-by-parent-and-child/) t.assert.match(r.stdout, /PARENT worker-exit code=0 signal=null/) + + // Self-containment of the FLUSH-produced shard specifically: with node_modules removed, + // the bundle is the only place the fork target and the worker-only dep can come from -- + // a flush shard subtly thinner than an exit-hook shard would surface here and nowhere + // else (the disk-present load above masks missing edges via native resolution). Needs + // the off-disk require.resolve, so probe-gated like the graceful test's pruned half. + if (resolveResolveFromBundle()) { + rmSync(join(tmp, 'node_modules'), { recursive: true }) + const pruned = run( + ['run', '--lock=frozen', '--bundle=load', '--bundle-file=snapshot.br', 'src/entry.js'], + { cwd: tmp } + ) + t.assert.equal(pruned.status, 0, `pruned load stderr: ${pruned.stderr}`) + t.assert.match(pruned.stdout, /ENTRY result=worked on input-1 with types-shared-by-parent-and-child/) + } else { + t.diagnostic(`skipping bundle-only require.resolve load on ${process.version} (Node loader limitation)`) + } })) test('run --child-process: WITHOUT the signal-flush opt-in a force-killed worker still loses its shard (no global default)', withTmp((t, tmp) => { @@ -2534,7 +2551,7 @@ test('run --child-process: WITHOUT the signal-flush opt-in a force-killed worker // documented best-effort behavior -- kill loses the shard, capture completes without it. const cap = run( ['run', '--lock=add', '--child-process', 'src/entry.js'], - { cwd: tmp, env: { ...cleanEnv, HOLD_HANDLE: '1', FORCE_EXIT_DELAY: '50' } } + { cwd: tmp, env: { ...cleanEnv, STASIS_TEST_HOLD_HANDLE: '1', STASIS_TEST_FORCE_EXIT_DELAY: '50' } } ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /PARENT worker-exit code=null signal=SIGTERM/, 'the worker was force-killed') @@ -2547,11 +2564,14 @@ test('run --child-process: WITHOUT the signal-flush opt-in a force-killed worker test('run --bundle=load: a jest-worker-style forked child runs from the bundle with node_modules removed', withTmp((t, tmp) => { cpSync(forkResolveFixture, tmp, { recursive: true }) - // Graceful path (no kill): the worker's exit-hook shard contributes the fork target and - // its worker-only loads, exactly as before -- the signal flush must not perturb it. + // Graceful path WITH the flush opt-in engaged -- the dominant production combination + // (StasisMetro always sets the flag for capturing builds; most workers drain cleanly). + // The registered SIGTERM listener must not perturb the drain (signal listeners don't + // ref the event loop): the worker still exits 0 and its exit-hook shard contributes + // the fork target and its worker-only loads exactly as without the flag. const cap = run( ['run', '--lock=add', '--bundle=add', '--bundle-file=snapshot.br', '--child-process', 'src/entry.js'], - { cwd: tmp } + { cwd: tmp, env: { ...cleanEnv, EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' } } ) t.assert.equal(cap.status, 0, `capture stderr: ${cap.stderr}`) t.assert.match(cap.stdout, /PARENT worker-exit code=0 signal=null/, 'the worker exited gracefully (exit-hook shard)') diff --git a/tests/config.test.js b/tests/config.test.js index 5f34177e..e9bf7e6f 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -13,6 +13,7 @@ const ENV_KEYS = [ 'EXODUS_STASIS_RESOURCES', 'EXODUS_STASIS_DEBUG', 'EXODUS_STASIS_CHILD_PROCESS', + 'EXODUS_STASIS_SHARD_SIGNAL_FLUSH', 'EXODUS_STASIS_FS', 'EXODUS_STASIS_BROTLI_QUALITY', ] @@ -717,6 +718,43 @@ test('Config childProcess=true option conflicts with env childProcess=0', withEn } )) +// shardSignalFlush is env-ONLY plumbing (no option, no stasis.config.json key), set by +// StasisMetro for its build's children -- but its parse is the same strict envBool as every +// boolean toggle, and the SIGTERM flush handler keys off it, so '0'/'false' MUST disable +// (a truthiness parse would flip a signal-disposition change ON) and garbage must fail loudly. +test('Config shardSignalFlush env "1" is true', withEnv( + { EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '1' }, + (t) => { t.assert.equal(new Config().shardSignalFlush, true) } +)) + +test('Config shardSignalFlush env "0"/"false" disable (not truthy-parsed)', withEnv( + { EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '0' }, + (t) => { t.assert.equal(new Config().shardSignalFlush, false) } +)) + +test('Config shardSignalFlush env "" is unset and keeps the default (false)', withEnv( + { EXODUS_STASIS_SHARD_SIGNAL_FLUSH: '' }, + (t) => { t.assert.equal(new Config().shardSignalFlush, false) } +)) + +test('Config shardSignalFlush defaults to false', withEnv({}, (t) => { + t.assert.equal(new Config().shardSignalFlush, false) +})) + +test('Config shardSignalFlush env rejects unrecognized values', withEnv( + { EXODUS_STASIS_SHARD_SIGNAL_FLUSH: 'yes' }, + (t) => { + t.assert.throws(() => new Config(), { name: 'RangeError', message: /EXODUS_STASIS_SHARD_SIGNAL_FLUSH must be/ }) + } +)) + +// Env-only: neither an option nor a stasis.config.json key admits it. +test('Config rejects a shardSignalFlush option and loadConfig rejects the key', (t) => { + t.assert.throws(() => new Config({ shardSignalFlush: true }), /Unknown Config options: shardSignalFlush/) + const c = new Config() + t.assert.throws(() => c.loadConfig(json({ shardSignalFlush: true }))) +}) + // fs mirrors lock/bundle: a 'sync'|'async' enum read from --fs / EXODUS_STASIS_FS / "fs" in // stasis.config.json, requiring an active read/write bundle mode (add|replace|load), and -- like // debug/childProcess -- NOT serialized. The runtime loader installs the fs reader patches from diff --git a/tests/extract-cmd.test.js b/tests/extract-cmd.test.js index ff4fe570..5df10944 100644 --- a/tests/extract-cmd.test.js +++ b/tests/extract-cmd.test.js @@ -36,6 +36,7 @@ const cleanEnv = (() => { EXODUS_STASIS_BUNDLE: _b, EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_DEBUG: _d, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...rest } = process.env return rest diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js index 3a5601c7..3aa03e05 100644 --- a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js @@ -4,11 +4,13 @@ const { fork } = require('child_process'); const { hello } = require('../types'); // Mirrors jest-worker's BaseWorkerPool.end(): the worker gets an END message and this -// long to exit gracefully before it is force-killed by SIGTERM (losing its exit hooks). +// long to exit gracefully before it is force-killed by SIGTERM (losing its exit hooks), +// with SIGKILL following the same delay later (jest-worker's forceExit escalation). // jest-worker hardcodes 500ms; env-overridable so kill-path tests (which wait out the // full delay in real time) can shorten it -- the kill semantics are delay-independent -// once the call round-trip has completed. -const FORCE_EXIT_DELAY = Number(process.env.FORCE_EXIT_DELAY ?? 500); +// once the call round-trip has completed. STASIS_TEST_-prefixed (the repo's test-plumbing +// convention) and `||` (not `??`) so an ambient empty/unset value can't zero the delay. +const FORCE_EXIT_DELAY = Number(process.env.STASIS_TEST_FORCE_EXIT_DELAY || 500); // Mirrors jest-worker's ChildProcessWorker: the parent process NEVER require()s // ./processChild -- it only require.resolve()s it and forks the resolved path, so the @@ -33,11 +35,21 @@ class ChildProcessWorker { end() { return new Promise((resolve) => { + // SIGTERM after FORCE_EXIT_DELAY, SIGKILL after another FORCE_EXIT_DELAY -- the same + // escalation real jest-worker's forceExit performs. The SIGKILL mirror matters for the + // tests: if the loader's flush handler ever stops re-delivering SIGTERM, the worker + // would otherwise survive forever (HOLD_HANDLE pins its loop) and the suite would HANG + // instead of failing the signal=SIGTERM assertion with an observable signal=SIGKILL. + let sigkillTimeout; const forceExitTimeout = setTimeout(() => { this._child.kill('SIGTERM'); + sigkillTimeout = setTimeout(() => { + this._child.kill('SIGKILL'); + }, FORCE_EXIT_DELAY); }, FORCE_EXIT_DELAY); this._child.once('exit', (code, signal) => { clearTimeout(forceExitTimeout); + clearTimeout(sigkillTimeout); // `hello` keeps the PARENT-side require('../types') load -- real jest-worker's // ChildProcessWorker requires ../types too, which is what puts the shared types.js // in the main process's capture alongside the child's. diff --git a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js index b672e786..ef065cbf 100644 --- a/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/processChild.js @@ -4,12 +4,12 @@ // the parent resolves this path via require.resolve but never loads it in-process. const { hello } = require('../types'); -// HOLD_HANDLE simulates a worker whose event loop never drains after the END message -// (a ref'd interval, a watcher some transformer left behind): the parent then +// STASIS_TEST_HOLD_HANDLE simulates a worker whose event loop never drains after the END +// message (a ref'd interval, a watcher some transformer left behind): the parent then // force-kills it by SIGTERM after FORCE_EXIT_DELAY, exactly like jest-worker's // forceExit. beforeExit/exit hooks never fire on a signal death -- the loader's // SIGTERM shard flush is the only thing that saves this worker's capture. -if (process.env.HOLD_HANDLE) setInterval(() => {}, 1 << 30); +if (process.env.STASIS_TEST_HOLD_HANDLE) setInterval(() => {}, 1 << 30); const messageListener = (msg) => { if (msg[0] === 'call') { diff --git a/tests/popular-npm-modules.test.js b/tests/popular-npm-modules.test.js index 2ac755a5..4a728244 100644 --- a/tests/popular-npm-modules.test.js +++ b/tests/popular-npm-modules.test.js @@ -46,6 +46,7 @@ const cleanEnv = (() => { EXODUS_STASIS_BUNDLE: _b, EXODUS_STASIS_BUNDLE_FILE: _bf, EXODUS_STASIS_DEBUG: _d, + EXODUS_STASIS_SHARD_SIGNAL_FLUSH: _ssf, ...rest } = process.env // q11 (the default) is ~3.4s on the 10-package payload; q5 is ~50ms for the