Flush child shard on SIGTERM for force-killed workers#70
Merged
ChALkeR merged 4 commits intoJul 18, 2026
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i
…ave() 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i
…s 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i
…ivery; 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements SIGTERM signal handling in the loader to flush a capturing child process's shard before the process is terminated. This ensures that force-killed workers (e.g., jest-worker's transform workers killed by
forceExit) still contribute their captured modules to the lockfile, rather than silently losing them.Key Changes
hooks.js: Added optional SIGTERM handler that flushes the child shard and re-delivers the signal when
EXODUS_STASIS_SHARD_SIGNAL_FLUSHis set. The handler:metro.js: Sets
EXODUS_STASIS_SHARD_SIGNAL_FLUSH=1onprocess.envduring plugin construction when capturing (writing lockfile or bundle). This environment variable is inherited by forked workers, enabling the signal flush behavior for Metro's transform workers.state.js: Updated documentation to clarify that fork targets (like jest-worker's processChild.js) are only attested by the child process that loads them, and that the Metro plugin's signal flush ensures force-killed workers still report their modules.
Test fixtures: Added
cli-run-fork-resolvefixture that reproduces the jest-worker pattern:require.resolve()to locate a child entry point but never loads itHOLD_HANDLEenvironment variableTests: Added three new test cases:
Implementation Details
The signal flush is strictly opt-in via
EXODUS_STASIS_SHARD_SIGNAL_FLUSHto avoid changing signal disposition in arbitrary user child processes. Only the Metro plugin sets this flag for its known transform workers, where the flush-then-redeliver behavior is appropriate. The handler preserves process semantics by re-delivering the signal when no user handler remains, allowing the parent to observe the correct exit code and signal.https://claude.ai/code/session_01RAbA9uVRB9T4LoXrEcXv3i