Skip to content

fix: flush Python inject shards from fork workers - #289

Merged
TrevorBasinger merged 4 commits into
rc/0.4.4from
cg/fork-worker-inject-shards
Aug 19, 2026
Merged

fix: flush Python inject shards from fork workers#289
TrevorBasinger merged 4 commits into
rc/0.4.4from
cg/fork-worker-inject-shards

Conversation

@christophergeyer

Copy link
Copy Markdown
Member

Problem

PR #265 made Python inject reports per-PID and merged those shards after tracing, but a plain Linux multiprocessing fork worker never produced a shard: multiprocessing terminates workers through os._exit, bypassing Python atexit, where RuntimeInjectionTracker.write_log is registered.

Consequently a package imported only inside a fork worker—and native libraries loaded only by that import—was absent from lineage. Parent imports and native file tracing could mask the gap in typical framework workloads.

Fix

Register an after-fork callback with multiprocessing.util. Inside each fork child, that callback creates a child-local multiprocessing.util.Finalize which writes the existing PID-specific inject shard. Multiprocessing executes these finalizers during orderly worker shutdown before os._exit; PR #265's existing merger then consumes the report without changes.

Best-effort registration preserves tracing if multiprocessing hooks are unavailable.

Scope boundary

This repairs orderly multiprocessing fork shutdown. It does not claim crash safety for direct os._exit, SIGKILL, fatal signals, or host loss. A regression test explicitly documents that os._exit still produces no shard; closing that class requires incremental import/native-load journaling plus parent-side completeness detection.

Tests

  • Real fork worker, not a fabricated shard:
    • worker writes inject-log.<actual-child-pid>;
    • shard carries the worker PID and worker-only import;
    • parent shard is not fabricated by the test.
  • Direct os._exit documents the remaining boundary.
  • Standalone runtime suite: 125 passed; Ruff clean.

Composition with #287

Validated locally with this PR composed with #287 using isolated uv tool Roar (Python 3.14) and a separate workload venv (Python 3.12):

  • Fork-only import numpy -> exactly numpy==2.5.2; blake3 absent; 4 dpkg native dependencies retained; no unmanaged .so warning.
  • Fork-only import blake3 -> exactly blake3==1.0.9; 2 dpkg native dependencies retained; no unmanaged .so warning.
  • Combined runtime suite: 127 passed.

This PR intentionally remains independent of #287: it guarantees report production; #287 attributes package/native contents within reports.

@christophergeyer

Copy link
Copy Markdown
Member Author

CI note: the Linux build-rust-binaries failure occurred before tests because unpinned cargo install bpf-linker resolved incompatible 0.11.0. Standalone infrastructure PR #288 pins validated 0.10.4 and is passing the Linux build and tests. Rerun this PR after #288 merges.

@christophergeyer

Copy link
Copy Markdown
Member Author

CI note: the Linux failure occurred before tests because unpinned resolved incompatible 0.11.0. Standalone infrastructure PR #288 pins validated 0.10.4 and is passing Linux build/tests. Rerun this PR after #288 merges.

@christophergeyer
christophergeyer force-pushed the cg/fork-worker-inject-shards branch 2 times, most recently from 463082a to da24a3c Compare August 18, 2026 21:43
@christophergeyer

Copy link
Copy Markdown
Member Author

Independent review + verification

An independent reviewer went over this branch, and I re-verified the load-bearing claims myself. Marking clearly which is which, since two of my own first attempts at verification used a broken harness.

1. with Pool(...) — the most common idiom — still flushes nothing ✅ verified independently

multiprocessing.pool.Pool.__exit__ is terminate(), which SIGTERMs workers. SIGTERM's default disposition kills the process outright: no util._exit_function, no Finalize, no shard.

Measured under real injection (PYTHONPATH=<inject dir>:<repo>, ROAR_LOG_FILE set), 4 workers:

with ctx.Pool(4) as p: p.map(...)     ->  1 shard  = 1 parent + 0 workers
p = ctx.Pool(4); ...; p.close(); p.join()  ->  5 shards = 1 parent + 4 workers

Process(...) + start/join also works (3/3). So the mechanism is correct for two of three teardown paths, but the headline case — with Pool(...), and the HF datasets num_proc scenario in the docstring at tracker.py:428-435 — is unfixed. Daemonic children have the same problem: util._exit_function terminate()s them, and torch.utils.data.DataLoader sets daemon=True.

Unlike os._exit(), this one is fixable — a signal.signal(SIGTERM, ...) handler in the fork child that writes the shard and re-raises.

2. The production wiring is untested ✅ verified independently

tracker.py:344 (self._install_fork_worker_finalizer() inside install()) is the only thing connecting this feature to production. Both new tests call tracker._install_fork_worker_finalizer() directly, so they never exercise the wiring.

I deleted line 344, keeping both methods intact, and ran pytest tests/execution/runtime/ -q -n0: 125 passed — unchanged.

This matters more than usual because line 344 sits inside the merge conflict with #287 (below). A conflict resolution that drops it silently removes the feature with zero test signal, and both new methods swallow everything with except Exception: pass.

3. On the forced-exit boundary commit

test_forced_os_exit_remains_outside_finalizer_guarantee is honest about os._exit() — that genuinely can't be intercepted without incremental journaling. No objection.

But it documents the unfixable limitation while omitting the fixable and more common one (SIGTERM, above). As written it reads as "we've bounded the problem" when the real boundary is tighter. SIGKILL is likewise unhandled and unmentioned; that one is genuinely unfixable and should just be named.

4. Merge conflict with #287 — textual, plus one semantic coupling

Both PRs insert immediately after install(). git merge-tree confirms a content conflict; whichever merges second needs a manual rebase. The resolution is trivial (keep both sides) and the merged tree passes all of both PRs' tests.

The semantic part: this PR's from multiprocessing import util runs inside install(), i.e. before #287's mark_workload_boundary(), so those modules land in #287's subtracted baseline (measured 117 -> 124 files). Harmless today since stdlib never reaches used_packages, but it establishes a coupling neither PR documents: every import added to roar's bootstrap silently shrinks recorded lineage.

Also raised, not independently verified by me

  • Fork workers re-serialise the parent's inherited state, so merge_inject_logs's "richest shard = the workload" invariant (tracker.py:226) is false by construction under fork. No observable corruption today, since those scalars are identical under fork and load_python_data doesn't read them.
  • Unbounded shard accumulation: write_log costs ~72ms in an empty venv (dominated by importlib.metadata.distributions()), paid on every worker exit; 800 shards ~ 165MB / 1.4s merge.
  • Locks-across-fork deadlock is a latent risk, not current: write_log imports nothing new in the child today, but nothing guards that property.

Confirmed not problems

Grandchild double-registration is safe (BaseProcess._after_fork clears the registry before running after-forkers); exitpriority=-100 ordering is fine; spawn does not double-write (after-forkers don't fire in a spawn child, so it still reports via atexit); install() is called exactly once.

Suggested bar before merge

  1. A test that goes through install().
  2. Handle SIGTERM, or document it explicitly alongside os._exit.
  3. Coordinate the baseline-ordering coupling with fix: isolate workload package and native dependency capture #287.

🤖 Generated with Claude Code

@christophergeyer

Copy link
Copy Markdown
Member Author

Update: the SIGTERM fix was wrong, and is replaced

The previous push (afdcf66) fixed the with Pool(...) gap by installing a SIGTERM handler in the fork child. That was worse than the bug it fixed, and an independent re-review caught it. Confirmed by measurement:

worker inside zlib.compress, on terminate()
  without roar          exit -15, joined in 0.02s
  with the handler      still alive after 8s, needed SIGKILL

A Python signal handler only runs when the interpreter reaches a bytecode boundary. A worker inside a long C call — BLAS, zlib, pickle — latches the signal and never dies, and both Pool._terminate_pool and util._exit_function join workers with no timeout. A missing shard is a thin record; a hung pipeline is a stopped campaign.

What replaced it

No signal handling at all. The fork child writes its shard eagerly, right after forking, and the existing Finalize rewrites the same per-PID path on an orderly exit.

  • a killed worker (SIGTERM or SIGKILL) or one calling os._exit still contributes the state it inherited at fork
  • an orderly worker upgrades that with whatever it imported while running
  • merge_inject_logs unions both, so the upgrade is free

This covers strictly more than the handler did: SIGKILL and os._exit were previously uncoverable and now leave a valid shard.

The boundary, stated honestly

Imports a worker makes after forking are lost if it is killed before exiting. Closing that needs incremental journaling, not an exit hook. test_a_forced_exit_keeps_the_fork_time_shard_but_loses_later_imports pins both halves.

Coverage for a worker killed while still bootstrapping is best-effort — I measured runs where a forked worker died before its after-fork hook ran. The pool test therefore asserts against workers that actually ran a task, rather than an exact shard count, which would be flaky.

Verification

  • hang gone: EXITCODE -15 JOIN 0.02s
  • with Pool(...), close()/join(), bare Process, daemon children — all report
  • 127 runtime tests pass; ruff check + format clean
  • mutations each fail a test: removing the install() wiring, the eager write, or the Finalize

Also from the re-review, not addressed here and worth their own consideration: worker teardown cost (write_log walks every installed distribution, ~20ms/worker on a small venv, paid per worker and multiplied by maxtasksperchild), the shard write not being atomic (open(...,"w") rather than temp + os.replace), and raw os.fork() children not being covered at all since register_after_fork only fires from BaseProcess._after_fork.

🤖 Generated with Claude Code

chrisgeyertreqs and others added 4 commits August 19, 2026 15:04
Review found the finalizer covered only half the teardown paths.
`Pool.__exit__` is `terminate()`, which SIGTERMs every worker, and
`util._exit_function` does the same to surviving daemon children --
which is what DataLoader creates. SIGTERM's default disposition kills
the process outright, so neither the Finalize callback nor atexit runs.

Measured under real injection, 4 workers:

  with ctx.Pool(4) as p: p.map(...)     1 shard  (0 of 4 workers)
  p.close(); p.join()                   5 shards (4 of 4)

So the common idiom -- and the `num_proc` case the finalizer's own
docstring cites -- reported nothing at all. It now reports 4 of 4, and a
daemon child is captured too.

The handler is installed only in a fork child, and only where nothing
else owns the signal, so a workload's own SIGTERM handling is never
displaced. It restores the default disposition and re-raises, so the
process still dies of SIGTERM and still reports exit status -15. One of
our own handlers may be superseded, so a re-install or a second tracker
does not leave a stale one writing the wrong shard.

`signal` is imported in the parent so the child's import is a
sys.modules hit; importing for the first time inside a fork child can
deadlock on the import lock.

Two tests, each verified to fail against the code without its fix:

  - the wiring test goes through `install()` rather than the private
    method. Deleting the single line that wires this into `install()`
    previously left all 125 tests passing -- and that line sits in the
    merge-conflict region with #287, so a conflict resolution could drop
    it silently.
  - the Pool test pins the terminated case.

A third asserts a workload-owned SIGTERM handler is not displaced.

The documented boundary now matches the code: termination by signal is
covered; `os._exit` and SIGKILL are not, and cannot be without
incremental import journaling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the SIGTERM handler from the previous commit, which was worse
than the bug it fixed.

A Python signal handler only runs when the interpreter reaches a bytecode
boundary. A worker inside a long C call -- BLAS, zlib, pickle -- latches
the signal and never dies, and both `Pool._terminate_pool` and
`util._exit_function` join workers with no timeout. So the handler could
hang the workload indefinitely:

  worker in zlib.compress, on terminate()
    without roar      exit -15, joined in 0.02s
    with the handler  still alive after 8s, needed SIGKILL

A missing shard is a thin record. A hung pipeline is a stopped campaign.

Instead the fork child writes its shard eagerly, right after forking, and
keeps the existing Finalize to rewrite the same per-PID path on an orderly
exit. A worker that is killed -- SIGTERM, SIGKILL -- or that calls
os._exit still contributes the state it inherited at fork, rather than
nothing. An orderly worker upgrades that with whatever it imported while
running. merge_inject_logs unions both, so the upgrade is free.

This also covers strictly more than the handler did: SIGKILL and os._exit
were previously uncoverable, and both now leave a valid shard.

The boundary, stated honestly and pinned by a test: imports a worker makes
*after* forking are lost if it is killed before exiting. Closing that
needs incremental journaling, not an exit hook. Coverage for a worker
killed while still bootstrapping is best-effort, so the pool test asserts
against workers that actually ran a task rather than an exact shard count.

Verified: hang gone; `with Pool(...)`, close()/join(), bare Process and
daemon children all report; 127 runtime tests pass; removing the install()
wiring, the eager write, or the Finalize each fails a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@christophergeyer
christophergeyer force-pushed the cg/fork-worker-inject-shards branch from 3febfd2 to c6c9d14 Compare August 19, 2026 15:05
@TrevorBasinger
TrevorBasinger merged commit cb1d8c2 into rc/0.4.4 Aug 19, 2026
15 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