BUG: make a failing Monte Carlo worker say so - #1182
Open
thc1006 wants to merge 5 commits into
Open
Conversation
thc1006
force-pushed
the
bug/report-a-worker-that-fails-before-its-first-simulation
branch
from
August 17, 2026 19:17
fa81b7f to
65a2da2
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1182 +/- ##
===========================================
+ Coverage 84.57% 85.32% +0.74%
===========================================
Files 131 131
Lines 17527 17571 +44
===========================================
+ Hits 14824 14992 +168
+ Misses 2703 2579 -124 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
thc1006
force-pushed
the
bug/report-a-worker-that-fails-before-its-first-simulation
branch
2 times, most recently
from
August 17, 2026 20:13
abb8e92 to
ce74db8
Compare
A parallel run spawns a SeedSequence per worker and passes it to environment, rocket and flight. _sampler_seed then fed it to SeedSequence(entropy=...), which takes an int or a sequence of ints, so the first worker raised TypeError before drawing anything. The call was reached only from the custom sampler reset until RocketPy-Team#1117 added the list-choice generator, which every model goes through. A real two-worker run passes at d21abde^ in 2.32s and does not finish on develop: the worker's own error path raises UnboundLocalError on inputs_json, so the parent never learns it died and the run hangs. The children of one root share their entropy and differ by spawn_key, so the value is folded through generate_state rather than read off entropy, which would put every worker on one sampler stream. Nothing is consumed, and an int or None seed keeps the stream it had. The fold lives in rocketpy.tools, since the component streams and the per-index seeding both need the same one and three copies would drift on width and word order. _sampler_seed does its own final fold through it as well rather than repeating the four lines. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
__sim_producer binds sim_idx and inputs_json inside the loop, and its handler reads both. A worker that fails in the seeding above the loop, or in the claim that opens it, reached the handler with neither name assigned and died with UnboundLocalError instead of recording anything. The parent learns a worker failed from error_event, which the handler sets on its last line, so it was never reached either and the run waited rather than stopping. Both names are bound before the try now, and the message says worker startup when no index was claimed rather than naming one that does not exist. Binding only the name in the traceback is not enough: the message then raises on the other one, which the tests cover. Scoped to the parallel producer. The serial handler has the same unbound inputs_json and is RocketPy-Team#1177's. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The workers say they failed by setting an event, and the parent joins them and reads it. A worker that is killed runs no handler, so the event stays clear, the join returns because the process is gone, and the run reports the simulations it never wrote as done. Measured with a worker leaving in the second simulation of six, two workers: simulate() returned normally with two rows on disk. Its exit code is what is left of a worker that ends this way, so the parent reads that too. Anything other than zero is refused, None included, since that is a worker that has not finished at all. The handler around it holds the manager mutex while it reports, so a failure in the reporting left the lock held by a process that had already gone and the next worker waited on it. The event is set first and outside the lock, the lock is released from a finally, and each reporting step is separate so an unwritable log cannot replace the failure being reported. A startup failure writes a row of its own now rather than nothing, since the caller is told to read that file. The test leaves through the data collector rather than a patched method, since a spawn platform re-imports the module in the child and never sees the patch, and through os._exit rather than a signal, since SIGKILL is POSIX-only. Checked on both start methods. The Monte Carlo objects are built on tmp_path rather than retargeted, because filename is a plain attribute and the three log paths are set in __init__. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The lock the workers share belongs to the manager and is not released when the process holding it is killed. A sibling then blocks on a lock nobody owns, and the parent, joining without a timeout, waits with it. The exit code check the previous commit added is never reached, so the one case it exists for is the one it cannot see. The join polls now, and acts only when a worker has actually ended badly. A run that is merely slow is never bounded: an exit code, not a duration, is what says a worker is gone. The survivors are asked through the event first, since one between simulations leaves with its logs intact, and only the ones still running after that are ended. Undoing this leaves every test in the new file red. Deciding on how long a worker has taken instead of on how it ended leaves exactly one red, which is the test that says a slow run must be left alone. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006
force-pushed
the
bug/report-a-worker-that-fails-before-its-first-simulation
branch
from
August 17, 2026 21:54
6b260bb to
2adc9d4
Compare
Lifted out of __sim_producer unchanged. The producer was over pylint's statement limit once the per-index seeding shortens it elsewhere, and the handler is one thing rather than part of the loop around it. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Gui-FernandesBR
approved these changes
Aug 19, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes parallel Monte Carlo seed handling and ensures failed workers cannot silently hang or produce incomplete successful runs.
Changes:
- Normalizes
SeedSequencevalues without collapsing worker streams. - Adds worker failure reporting, exit-code validation, and bounded shutdown polling.
- Adds regression coverage for parallel execution and worker failure modes.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
rocketpy/tools.py |
Adds deterministic SeedSequence conversion. |
rocketpy/stochastic/stochastic_model.py |
Accepts worker seed sequences. |
rocketpy/simulation/monte_carlo.py |
Improves worker reporting and lifecycle handling. |
tests/unit/stochastic/test_seed_types.py |
Tests seed compatibility and stream independence. |
tests/unit/simulation/test_monte_carlo_parallel_runs.py |
Exercises real serial and parallel runs. |
tests/unit/simulation/test_monte_carlo_worker_reporting.py |
Tests worker failure diagnostics. |
tests/unit/simulation/test_monte_carlo_worker_join.py |
Tests polling and shutdown behavior. |
tests/unit/simulation/test_monte_carlo_worker_exit.py |
Tests abnormal worker exit detection. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Bound before the try: the handler below reads both, and a failure in | ||
| # the seeding, or in the claim that opens the loop, reaches it with | ||
| # neither of them assigned. | ||
| sim_idx, inputs_json = None, "" |
Comment on lines
+594
to
595
| with suppress(Exception): | ||
| error_event.set() |
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.
Three ways a failing Monte Carlo worker gets away without saying so. One hangs the run, one reports success with results it never wrote, and one hangs it again from a level down.
Stacked on #1181, which is the parallel run being unable to start at all. This branch carries that commit, so the diff here shows it too; the work of this pull request is the two commits on top. Happy to rebase once the other one lands.
Pull request type
Checklist
ruff check/ruff format --check,pylint) has passed locallyCHANGELOG.md— no action needed; an LLM workflow auto-updates it after merge, though see BUG: the changelog workflow stopped running, and CHANGELOG.md is 37 merged pull requests behind #1173 for why it currently does not.Current behavior
A worker that fails early dies inside its own handler.
__sim_producerbindssim_idxandinputs_jsoninside the simulation loop:and its
exceptblock reads both. A worker that fails in the seeding above the loop, or in the claim that opens it, arrives there with neither name assigned:That happens after
mutex.acquire()and beforemutex.release(), so a manager lock is left held by a process that no longer exists. The next worker blocks onacquire()and the run waits forever.error_event.set()is the handler's last line, so the parent is never told either.A worker that is killed is not noticed at all. The parent joins each worker and reads the event. A signal runs no handler, so the event stays clear and the join returns because the process is gone. With a
SIGKILLin the second of six simulations across two workers:I would rather have the hang than that one. A hang is at least visible.
New behavior
Both names are bound before the
try, and the message saysworker startupwhen no index was claimed instead of naming one that does not exist. The handler now finishes, releases the mutex and sets the event, so a worker failure surfaces as aRuntimeErrornaming the error file rather than as a wait.After the join,
_refuse_a_worker_that_did_not_finishreads the exit codes. Anything other than zero is refused. It sits before the event check because the two are disjoint: a worker that reports through its handler exits cleanly, and one that was killed only leaves its exit code behind.The handler holds the shared mutex while it reports, so a failure in the reporting used to leave the lock held by a process that had already gone. The event is set first and from outside the lock, the lock is released from a
finally, and each reporting step is separate so an unwritable log cannot replace the failure being reported. A startup failure writes a row of its own now, since the caller is told to read that file and a traceback a worker printed is not there once its output is redirected.The lock belongs to the manager and is not released when its holder is killed, so a sibling can block on a lock nobody owns while an unbounded join waits with it. That is the case the exit-code check exists for and the one it could not see.
_join_the_workerspolls instead, and acts only once a worker has actually ended badly. A run that is merely slow is never bounded: an exit code, not a duration, decides. This is deliberately not a general worker-lifecycle change; it is the smallest thing that makes the check above reachable.Scope is the parallel producer.
__run_in_serialhas the same unboundinputs_jsonand belongs to #1177, whose tests cover it already; I have deliberately not touched it.Breaking change
A run that used to hang now raises, and a run that used to return with missing rows now raises. Both were already failures.
Additional information
Verification, on a clean tree:
The four
tests/unit/test_sensitivity.pyfailures on my machine are a missingstatsmodelsand fail the same way on an untoucheddevelop.Mutations, each leaving a control standing:
tryinputs_jsonis bound, which is what the traceback points atif process.exitcodein place of!= 0Nonecasetry/finallyTwo rows there are worth pointing at. Fixing only the name in the traceback looks complete and leaves the message raising on the other one. And deciding the join on elapsed time rather than on an exit code passes everything except the test that says a slow run must be left alone, which is the failure mode that would hurt real users most.
Still not covered, and I would rather say so than imply otherwise: a worker killed between
mutex.acquire()and the write it is guarding leaves a torn record behind. The run is stopped and reported either way now, but the rows it was midway through are not repaired._refuse_a_worker_that_did_not_finishis a module-level function rather than a method because the run paths are driven by stub objects in the tests, whereself.__helper()would not resolve.The Monte Carlo objects in these tests are built on
tmp_pathrather than retargeted after construction.filenameis a plain attribute and the three log paths are settled in__init__, so assigning it leaves a test writing the fixture's own files into the working directory, which is what these were doing.Merged with #1169, #1170 and #1171's follow-up into a throwaway tree on
developand run there too. The fixed-seed baseline is unchanged across all of it:stochastic_calistounder seed 42 readsmass=14.906007947 radius=0.063501935.