Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions stasis-core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class Config {
#resources
#debug
#childProcess
#shardSignalFlush
#fs
#brotliQuality

Expand All @@ -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,
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
63 changes: 62 additions & 1 deletion stasis-core/src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion stasis-core/src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 35 additions & 6 deletions stasis-plugins/src/metro.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions stasis-plugins/src/webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/build-cmd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/bundle-cmd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/cli-fs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading