Skip to content

Flush child shard on SIGTERM for force-killed workers#70

Merged
ChALkeR merged 4 commits into
mainfrom
claude/jest-worker-processchild-attestation-s2wjl7
Jul 18, 2026
Merged

Flush child shard on SIGTERM for force-killed workers#70
ChALkeR merged 4 commits into
mainfrom
claude/jest-worker-processchild-attestation-s2wjl7

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

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_FLUSH is set. The handler:

    • Only runs in child processes that are capturing into an inherited shard channel
    • Flushes the shard before re-delivering SIGTERM to preserve signal semantics
    • Re-kills the process only if no user signal handler remains
    • Leaves signal disposition untouched for non-capturing processes
  • metro.js: Sets EXODUS_STASIS_SHARD_SIGNAL_FLUSH=1 on process.env during 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-resolve fixture that reproduces the jest-worker pattern:

    • Parent process uses require.resolve() to locate a child entry point but never loads it
    • Forked child loads the entry and additional worker-only dependencies
    • Simulates jest-worker's force-kill behavior via HOLD_HANDLE environment variable
  • Tests: Added three new test cases:

    • Verify force-killed worker contributes its shard with signal flush enabled
    • Verify force-killed worker loses its shard without the opt-in (best-effort behavior)
    • Verify graceful worker exit still works correctly with signal flush enabled
    • Verify the plugin sets the signal flush flag for capturing runs but not for frozen/load runs

Implementation Details

The signal flush is strictly opt-in via EXODUS_STASIS_SHARD_SIGNAL_FLUSH to 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

claude added 4 commits July 18, 2026 01:30
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
@ChALkeR
ChALkeR merged commit ec5111b into main Jul 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants