diff --git a/stasis-core/src/config.js b/stasis-core/src/config.js index 199018e4..57085df0 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,21 @@ 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 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 + // 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 b5979701..13a18abd 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,64 @@ function initState(root) { process.on('beforeExit', save) process.on('exit', save) + + // 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); + // 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, + // 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) 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). 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', () => { + try { + save() + } finally { + if (!userOwnedSigterm && 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..e466ef50 100644 --- a/stasis-plugins/src/metro.js +++ b/stasis-plugins/src/metro.js @@ -123,12 +123,21 @@ 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 +// - 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 -- +// 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 @@ -171,6 +180,26 @@ export class StasisMetro { 'is never attested. Enable it: `stasis run --child-process ...`, EXODUS_STASIS_CHILD_PROCESS=1, ' + 'or "childProcess": true in stasis.config.json.' ) + // 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 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 492a5c4e..e6ea824d 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,179 @@ 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 +} + +// 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, 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 = () => { + 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 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 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 }) + // 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, 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') + // 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 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. + 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/) + + // 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) => { + 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, 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') + 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') +})) + +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 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, 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)') + 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'], + { 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/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/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..3aa03e05 --- /dev/null +++ b/tests/fixtures/cli-run-fork-resolve/node_modules/fake-jest-worker/build/workers/ChildProcessWorker.js @@ -0,0 +1,64 @@ +'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), +// 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. 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 +// file's bytes are loaded exclusively in the forked child, as that child's entry. +class ChildProcessWorker { + constructor() { + this._child = fork(require.resolve('./processChild'), [], { + // 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) => { + // 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. + console.log(`PARENT worker-exit code=${code} signal=${signal} types=${hello}`); + 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..ef065cbf --- /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'); + +// 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.STASIS_TEST_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. 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