Skip to content

BUG: re-raise KeyboardInterrupt after an interrupted Monte Carlo run - #1177

Draft
ting-hong-shieh wants to merge 1 commit into
RocketPy-Team:developfrom
ting-hong-shieh:bug/monte-carlo-reraise-keyboard-interrupt
Draft

BUG: re-raise KeyboardInterrupt after an interrupted Monte Carlo run#1177
ting-hong-shieh wants to merge 1 commit into
RocketPy-Team:developfrom
ting-hong-shieh:bug/monte-carlo-reraise-keyboard-interrupt

Conversation

@ting-hong-shieh

@ting-hong-shieh ting-hong-shieh commented Aug 16, 2026

Copy link
Copy Markdown

BUG: re-raise KeyboardInterrupt after a Monte Carlo run is interrupted

Closes #1151.

The problem

MonteCarlo.simulate() returned normally after Ctrl-C. Both execution modes caught the interrupt and neither re-raised it, so simulate() went on to __terminate_simulation() and returned exactly as it does after a complete study. A caller could not tell a partial run from a finished one without opening the output file and counting rows, and a command-line caller could not return the conventional interrupted status.

At 4263fa95d7fe6f63d9593f01e4ff7a088369e195, asking for ten simulations and interrupting the third:

Starting Monte Carlo analysis
Iterations completed: 000001 ...
Iterations completed: 000002 ...
Keyboard interrupt received. Files saved.
__terminate_simulation ran

asked for              : 10 simulations
completed before Ctrl-C: 2 inputs, 2 outputs
KeyboardInterrupt seen by caller: False

Review on this pull request, and the interrupt-point audit it prompted, found five more holes sitting next to that one; this branch now fixes all six.

  1. inputs_json unbound on an early interrupt. __run_in_serial binds it inside the loop body, so Ctrl-C during the first keep_simulating() call reached the handler with nothing bound:

    RAISED UnboundLocalError: cannot access local variable 'inputs_json' where it is not associated with a value
    
  2. A one-sided record on an interrupt between the two appends. _append_simulation_record rolled its inputs row back on Exception, and KeyboardInterrupt derives from BaseException, so Ctrl-C after the inputs append but before the outputs append left the inputs file one row longer than the outputs file — which is the pairing BUG: write MonteCarlo input and output rows atomically (#1110) #1125 exists to protect. Reproduced before the fix: 2 inputs rows against 1 outputs row.

  3. A committed row written to the error file. After a successful append, inputs_json still held the committed row, so an interrupt landing in the progress print — or in the next keep_simulating() or increment() call, or in print_final_status() after the last row — wrote a row that had already committed into the error file as though it never finished.

  4. A torn partial row surviving the rollback. The rollback only truncated the inputs file, so an interrupt inside either write (not just between them) left half a row on disk, and the reload then died with JSONDecodeError in place of the interrupt.

  5. No cleanup for workers started before an interrupt in the startup loop. The parallel cleanup try began only after every worker had been created and started, so Ctrl-C during Process.start() left the already-started workers running with nobody signalling or joining them.

The change

All in rocketpy/simulation/monte_carlo.py, no behavior removed:

  • __run_in_serial binds inputs_json before the loop, and re-raises after writing the unfinished record.
  • _append_simulation_record puts both writes in one try and, on BaseException, truncates both files back to their entry sizes — best-effort, so the rollback cannot replace the original failure with its own. The interrupt gets the same rollback as any other failure, the two files stay paired, and no torn row survives for the reload to trip over.
  • __run_in_serial clears inputs_json as soon as the append returns, so an interrupt after the commit reports nothing rather than reporting the committed row as unfinished.
  • __run_in_parallel starts the workers inside the cleanup try, appending each to the process list only after its start() returns, so an interrupt mid-startup still signals and joins whatever came up — and never joins a process that was never started, which raises. The handler re-raises unconditionally; only the re-raise was conditional before, so a bare raise covers both failure kinds and drops the raise error rebinding.
  • simulate() catches the interrupt, runs __terminate_simulation(), then re-raises. That call reloads the logs through the file property setters, and set_num_of_loaded_sims is what the documented append=True continuation reads; re-raising past it would leave the object disagreeing with its own files. The ordinary exception path is untouched.

The docstring gains a Raises section that states the guarantee exactly, including its limit.

What this guarantees, and what it deliberately does not

In parallel mode the interrupt propagates after every worker finishes the simulation it is currently in, notices the stop event, and exits on its own. The shutdown join stays unbounded, so a worker stuck inside one simulation blocks the interrupt exactly as it already blocked the run on develop.

That is a narrower guarantee than "Ctrl-C always returns promptly", and it is intentional. Workers write their rows under the shared mutex; killing one from the outside after a timeout can tear a half-written row into the logs and leave the mutex held, which trades a hang for corrupted files. The bounded fleet shutdown is #1054's design territory, and this pull request should not grow a competing one. The docstring and the fake-worker docstring in the tests both state the limit rather than implying coverage the tests do not have.

Verification

Same two reproductions at the head of this branch:

KeyboardInterrupt seen by caller: True
Keyboard interrupt received. Files saved.
__terminate_simulation ran
RAISED KeyboardInterrupt: ctrl-c before the first simulation

Ten regression tests in tests/unit/simulation/test_monte_carlo.py:

Test What it pins
test_interrupted_serial_run_reaches_the_caller the caller receives KeyboardInterrupt
test_interrupted_serial_run_keeps_the_rows_that_finished completed rows stay readable and paired
test_interrupted_serial_run_still_reloads_the_logs the real reload runs: num_of_loaded_sims == 2, both logs hold 2 rows, results populated
test_an_interrupted_run_can_be_continued_with_append interrupt at 2 of 10, continue with append=True, indices on disk are 1–10 with no gap and no repeat
test_ctrl_c_before_the_first_simulation_is_still_the_interrupt the early interrupt is the interrupt, not UnboundLocalError
test_ctrl_c_between_the_two_appends_rolls_the_inputs_row_back the inputs row is rolled back, the cut-short row lands in the error file, the reload agrees with both files
test_interrupted_parallel_run_signals_joins_and_reaches_the_caller workers signalled and joined, no completion reported, interrupt reaches the caller
test_ctrl_c_during_worker_startup_still_stops_the_started_workers interrupt during Process.start() still signals and joins the started worker, and does not join the never-started one
test_ctrl_c_in_the_progress_print_leaves_the_error_file_empty a committed row stays out of the error file
test_a_torn_outputs_write_rolls_both_files_back an interrupt inside the write leaves no half row; every surviving row parses

The serial tests run the real __terminate_simulation rather than a stub and assert the state it produces. The parallel tests drive __run_in_parallel over stubs for _import_multiprocess and _create_multiprocess_manager, so they start no processes and stay deterministic in the default suite; per the section above, they cover workers that exit cooperatively, and say so.

All ten fail at develop — the whole file run, no filter: 10 failed, 65 passed, with every pre-existing test still green. Each of the five change bullets was then reverted individually and the whole file re-run:

Reverted Result
serial binding + serial re-raise 8 failed — every serial interrupt test; they all depend on the re-raise itself
two-file BaseException rollback 2 failed — exactly the two rollback tests
clearing after the append 1 failed — exactly its test
startup-in-try + unconditional parallel re-raise 2 failed — exactly the two parallel tests
simulate() catch + reload + re-raise 1 failed, then the session aborted: without the catch, the append=True continuation's second simulate() lets the interrupt escape the test and pytest stops

So every fix is pinned by at least one test that fails without it, and nothing unrelated breaks; the first and last rows flip more than one test because the other serial tests genuinely depend on those two fixes, not because the tests are loose. With the change:

$ pytest tests/unit/simulation/ tests/unit/stochastic/ -q
333 passed, 4 skipped in 44.54s

$ ruff check rocketpy/ tests/ && ruff format --check rocketpy/ tests/
All checks passed!

$ pylint rocketpy/simulation/monte_carlo.py tests/unit/simulation/test_monte_carlo.py
Your code has been rated at 10.00/10

What was not verified

tests/integration/simulation/test_monte_carlo.py::test_monte_carlo_simulate[True] did not finish here, on this branch or without it. Same command and the same 240-second limit on both:

$ timeout 240 pytest "tests/integration/simulation/test_monte_carlo.py::test_monte_carlo_simulate[True]" --runslow -q
Terminated                      # 4263fa9, unmodified
Terminated                      # same commit with this branch applied

The behavior is identical with and without the change, so this is the pre-existing local hang noted in #709 rather than something introduced here. The other ten cases in that file pass with --runslow in 54.56s. The test carries @pytest.mark.slow, so it runs in the scheduled test-pytest-slow.yaml job rather than in the pull request gate; a maintainer running that job will get the result I could not.

Relationship to #1054

#1054 rewrites both handlers and records the interrupt as self._interrupted, but it reads that flag only in __check_each_index_was_recorded_once, where it suppresses the missing-simulations check. That is correct for what #1054 is about — an interrupted run has gaps in its indices and must not be reported as corrupt — but it does not re-raise, so #1151 survives it. The two changes are independent; whichever lands second is a small rebase. #1054 fixes the inputs_json binding the same way, as the first statement in the loop, and its _bring_the_fleet_down is where a bounded shutdown belongs.

No CHANGELOG entry, following the convention every pull request merged this month has used: changelog.yml writes the entry on merge and contributors leave the file alone. #1173 reports that the workflow has not run since #1112, so this one will need whatever backfill that issue settles on rather than a hand-written line here.

Measured on Python 3.12.3, Linux.

@ting-hong-shieh
ting-hong-shieh requested a review from a team as a code owner August 16, 2026 17:08
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.06%. Comparing base (4263fa9) to head (75ac771).

Files with missing lines Patch % Lines
rocketpy/simulation/monte_carlo.py 87.50% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1177      +/-   ##
===========================================
+ Coverage    84.57%   85.06%   +0.48%     
===========================================
  Files          131      131              
  Lines        17527    17540      +13     
===========================================
+ Hits         14824    14920      +96     
+ Misses        2703     2620      -83     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006 thc1006 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why include these three empty files ?:

  • monte_carlo_test.errors.txt
  • monte_carlo_test.inputs.txt
  • monte_carlo_test.outputs.txt

Could you drop them from the PR?

Can use AI but plz check the claim against the implementation by yourself

@thc1006 thc1006 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review comments

# pylint: disable=broad-except
except (Exception, KeyboardInterrupt) as error:
except (Exception, KeyboardInterrupt):
simulation_error_event.set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup try starts only after every worker has been created and started. A Ctrl-C during Process.start() skips the event/set and join path, so any workers already started are not explicitly stopped or joined.

Could the startup loop be covered by the same cleanup path, with a separate list of processes whose start() has completed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, with the started-list shape you suggested. The startup loop is inside the cleanup try, and each worker joins the list only after its start() returns, so the handler never joins a process that was never started (which raises).

test_ctrl_c_during_worker_startup_still_stops_the_started_workers pins it: the second worker's start() raises where a real Ctrl-C could land, and the test asserts the first worker was signalled and joined while the never-started one was not (joins == 0). Reverting only this restructuring makes that test fail; the other seven still pass.

The narrow edge that remains is an interrupt inside start() after the OS process exists but before start() returns — that worker is not in the list and is not cleaned up. I did not find a way to close that without reaching into multiprocess internals, so it is left as stated.

Comment on lines +836 to +865
def test_interrupted_serial_run_reaches_the_caller(tmp_path):
"""``simulate`` used to return normally after Ctrl-C.

A caller could not tell a partial run from a complete one without opening
the output file and counting rows.
"""
mc = _InterruptingMonteCarlo(str(tmp_path / "run"), interrupt_after=2)

with pytest.raises(KeyboardInterrupt):
mc.simulate(number_of_simulations=10, parallel=False)


def test_interrupted_serial_run_keeps_the_rows_that_finished(tmp_path):
"""The two simulations that completed stay readable and paired."""
mc = _InterruptingMonteCarlo(str(tmp_path / "run"), interrupt_after=2)

with pytest.raises(KeyboardInterrupt):
mc.simulate(number_of_simulations=10, parallel=False)

inputs = (tmp_path / "run.inputs.txt").read_text(encoding="utf-8").splitlines()
outputs = (tmp_path / "run.outputs.txt").read_text(encoding="utf-8").splitlines()

assert len(inputs) == 2
assert len(outputs) == 2
assert [json.loads(row)["index"] for row in inputs] == [
json.loads(row)["index"] for row in outputs
]


def test_interrupted_serial_run_still_reloads_the_logs(tmp_path):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_interrupted_serial_run_still_reloads_the_logs overrides __terminate_simulation and only checks a boolean. That proves the method was called, but not that the real setters reload the rows or update num_of_loaded_sims.

Could this exercise the real termination path and assert the loaded input/output rows and the next append=True call? The distinction matters because parallel interruptions can leave gaps, which is the row-count problem tracked in #1075.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The stub is gone from every serial test: they run the real __terminate_simulation and assert the state the reload produces — num_of_loaded_sims == 2, two rows in each log, results populated — and test_an_interrupted_run_can_be_continued_with_append runs the documented path end to end: interrupt at 2 of 10, simulate(..., append=True), then asserts the indices on disk are 1–10 with no gap and no repeat.

On the #1075 connection: agreed that parallel interruptions leave gaps, which is why the append continuation is only asserted for serial mode here. The parallel tests assert the reload ran and the interrupt propagated, not that a parallel resume is gap-free — that stays #1075's problem.

@thc1006

thc1006 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

After the first join() is interrupted, the handler immediately retries every worker with another unbounded join(). If one worker is stuck, the KeyboardInterrupt still never reaches simulate().

The fake worker always finishes on its second join, so the regression test cannot detect this case. Should this use the bounded fleet-shutdown path being split from #1054, or should the PR narrow its guarantee to workers that exit cooperatively?

@thc1006

thc1006 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

There is still an interrupt-sized hole between this PR and #1125: _append_simulation_record rolls back on Exception, but KeyboardInterrupt is a BaseException.

Ctrl-C after the input append but before the output append completes can therefore leave a one-sided record. The following __terminate_simulation() may then either reload mismatched files or mask the interrupt with a JSON parse error. Could we add a regression test for that boundary, even if the actual rollback fix is kept separate?

@ting-hong-shieh
ting-hong-shieh force-pushed the bug/monte-carlo-reraise-keyboard-interrupt branch 2 times, most recently from b5cf7eb to bd50673 Compare August 16, 2026 18:22
@ting-hong-shieh

Copy link
Copy Markdown
Author

Both points are fair. Dropped, and I went back over the claims.

The three files

Gone — the branch is now rocketpy/simulation/monte_carlo.py and tests/unit/simulation/test_monte_carlo.py only.

They were never meant to be here. monte_carlo_calisto builds its object with filename="monte_carlo_test", so a test run writes monte_carlo_test.{inputs,outputs,errors}.txt into the repository root, and nothing in .gitignore covers them. I used git add -A and swept them in.

Two force-pushes rather than one: my first amend dropped them, then I re-ran the suite before pushing, which recreated them, and git add -A put them straight back. The second uses explicit paths. Worth an .gitignore entry so the next person does not repeat it; I did not want to fold that into this branch, so say the word and I will open it separately.

Checking the claims against the implementation

You were right to push on this. The claim in the description is that __terminate_simulation() has to run before the interrupt leaves simulate(), because it reloads the logs through the file setters and set_num_of_loaded_sims is what append=True reads. My tests did not actually check that. _InterruptingMonteCarlo stubbed __terminate_simulation and the assertion was assert mc.terminated — it showed the call happened and nothing about the reload working.

Replaced. The stub is gone, the tests run the real method, and they assert the state it produces:

assert mc.num_of_loaded_sims == 2
assert len(mc.inputs_log) == 2
assert len(mc.outputs_log) == 2
assert mc.results["apogee"] == [1001.0, 1002.0]

And a sixth test that runs the documented path end to end rather than asserting about it — interrupt after two of ten, then continue:

mc.interrupt_after = 10
mc.simulate(number_of_simulations=10, append=True, parallel=False)

rows = pathlib.Path(stem + ".outputs.txt").read_text(encoding="utf-8").splitlines()
assert [json.loads(row)["index"] for row in rows] == list(range(1, 11))

That passes: ten rows, indices 1 to 10, no gap and no repeat.

That the tests pin anything

Reverting only rocketpy/simulation/monte_carlo.py to develop and keeping the new tests:

FAILED test_interrupted_serial_run_reaches_the_caller
FAILED test_interrupted_serial_run_keeps_the_rows_that_finished
FAILED test_interrupted_serial_run_still_reloads_the_logs
FAILED test_an_interrupted_run_can_be_continued_with_append
FAILED test_ctrl_c_before_the_first_simulation_is_still_the_interrupt
FAILED test_interrupted_parallel_run_signals_joins_and_reaches_the_caller
6 failed, 1 passed

With the change:

$ pytest tests/unit/simulation/ tests/unit/stochastic/ -q
329 passed, 4 skipped in 46.06s

$ ruff check rocketpy/ tests/ && pylint rocketpy/simulation/monte_carlo.py tests/unit/simulation/test_monte_carlo.py
All checks passed!
Your code has been rated at 10.00/10

The scope note in the description still stands: test_monte_carlo_simulate[True] does not finish on my machine, identically with and without this branch, so I could not verify that one either way.

@thc1006 thc1006 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still cannot approve this head yet. The startup window is unchanged: worker creation and start() still happen before the cleanup try, so an interrupt while starting a later worker leaves the already-started workers outside the signal/join path. The second round of joins is also still unbounded, so a worker stuck in a simulation can prevent the original interrupt from ever reaching the caller.

There is one more direct path through _append_simulation_record: it catches Exception, not BaseException, and only rolls back the input file. A KeyboardInterrupt during an output write can therefore leave a one-sided or partial record; the reload in simulate() can then replace the interrupt with JSONDecodeError. I think that boundary needs a regression test and a two-file rollback before this closes #1151.

Also, after a record is successfully appended, an interrupt from print_update_status() writes that already-committed input row to the error file because inputs_json has not been cleared yet.

The current Actions run is green across all six OS/Python legs, Codecov, lint and docs. The remaining concerns are lifecycle and write-boundary cases that the fake-worker test does not exercise.

If you're using AI to generate PRs, could you please open them as Drafts first? Give them a thorough self-review, and maybe even prompt an AI to do an adversarial review on your PR. Iterate on this a few times until everything looks solid, and only then mark it as 'Ready for review'. Thanks!

@ting-hong-shieh
ting-hong-shieh force-pushed the bug/monte-carlo-reraise-keyboard-interrupt branch from bd50673 to 8ca651f Compare August 16, 2026 18:42
@ting-hong-shieh

Copy link
Copy Markdown
Author

All four points are addressed at the new head; the description was rewritten to match. Taking the two thread-level ones here.

The unbounded second join

You are right that the regression test could not detect a stuck worker — _FakeWorker.join always returns once the interrupt is delivered, so it can only ever exercise the cooperative case. Of the two options you offered, this PR narrows the guarantee rather than adopting the bounded shutdown:

  • The simulate() docstring now says exactly what holds: the interrupt propagates after every worker finishes the simulation it is in and exits on its own, and a worker that never returns from a simulation blocks the interrupt as it already blocked the run.
  • _FakeWorker's docstring states the same limit for the tests, so the suite no longer implies coverage it does not have.
  • The handler comment explains why not to kill a straggler here: workers write under the shared mutex, so terminating one after a timeout can tear a half-written row into the logs and leave the mutex held — trading a hang for corrupted files. A shutdown that can do better needs the structure _bring_the_fleet_down has in ENH: reproducible Monte Carlo via per-simulation-index seeding #1054, and this PR should not grow a competing copy of it.

If #1054 lands first, the re-raise here rebases onto its bounded path and inherits the stronger guarantee; if this lands first, #1054 replaces the unbounded join it finds.

The Exception / BaseException hole

Confirmed and fixed here rather than deferred — the rollback is this PR's own claim ("completed rows stay paired"), so the regression test alone would have documented a hole this branch then shipped. Reproduced first at the old head through simulate(): interrupting between the two appends left 2 inputs rows against 1 outputs row.

The rollback now catches BaseException, and test_ctrl_c_between_the_two_appends_rolls_the_inputs_row_back pins the whole boundary: inputs rolled back to 2/2, the cut-short row recorded in the error file (index == 3), and num_of_loaded_sims == 2 from the real reload. Reverting only the one-word except change makes that test fail.

#1125's own OSError rollback test still passes unchanged, so the widening does not disturb what it pinned.

Re-verification at the new head

8 failed, 65 deselected        # develop + the new tests
331 passed, 4 skipped          # this branch, tests/unit/simulation + stochastic

plus each of the two new fixes reverted individually, with only its own test failing. Ruff and pylint clean.

@ting-hong-shieh
ting-hong-shieh marked this pull request as draft August 16, 2026 18:51
@ting-hong-shieh
ting-hong-shieh force-pushed the bug/monte-carlo-reraise-keyboard-interrupt branch from 8ca651f to 75ac771 Compare August 16, 2026 18:55
@ting-hong-shieh

Copy link
Copy Markdown
Author

Your comment and my previous push crossed within the same minute — the head you reviewed was bd50673, and 8ca651fe landed at 18:41:37 with the startup-loop, unbounded-join and BaseException items addressed. My 18:43 "all four points" referred to your earlier four review comments, and I had not seen this comment when I wrote it; for the item that is genuinely new here, that claim was wrong. Sorry for the churn. Everything below refers to 75ac771c, and the PR sits in draft until you want it.

The committed row written to the error file

Confirmed at my head too, and your diagnosis was exact: after a successful _append_simulation_record, inputs_json still holds the committed row, so an interrupt in print_update_status() — or in the next keep_simulating() or increment() call, or in print_final_status() after the last row, which are the same hole at three more addresses — wrote a row that had already committed into the error file as though it never finished. It is now cleared immediately after the append returns, the same shape as #1054's fix. test_ctrl_c_in_the_progress_print_leaves_the_error_file_empty pins it: interrupt in the second progress print, error file empty, 2/2 rows, num_of_loaded_sims == 2.

The two-file rollback

Taken as you described rather than as regression-test-only, because the interrupt-point audit showed the one-file version has a second failure mode: an interrupt inside either write (not just between them) leaves a torn partial row, and the reload then dies with JSONDecodeError in place of the interrupt. Both writes now sit in one try, and on BaseException both files are truncated back to their entry sizes, best-effort so the rollback cannot replace the original failure with its own.

test_a_torn_outputs_write_rolls_both_files_back drives it with a file wrapper that writes half the row, flushes, then interrupts: both files back to 2 rows, every surviving row still parses, the cut-short row in the error file. #1125's own OSError rollback test passes unchanged.

Startup window and the second join, at this head

Both were in 8ca651fe and are unchanged here: worker creation and start() are inside the cleanup try, appended to the process list only after start() returns (test_ctrl_c_during_worker_startup_still_stops_the_started_workers); the second join stays deliberately unbounded, with the narrowed guarantee stated in the simulate() docstring and in _FakeWorker's — a worker stuck inside one simulation blocks the interrupt as it already blocked the run, and killing it could tear a row into logs it holds the mutex for. The bounded shutdown stays #1054's.

Verification at 75ac771c

Ten regression tests; all fail at develop, and each of the five fixes was also reverted individually with only its own tests failing. 333 passed, 4 skipped across tests/unit/simulation + tests/unit/stochastic; ruff and pylint clean.

Process

Fair ask, adopted: this PR is converted to draft now, and future PRs of mine start as drafts, get the adversarial pass first, and only then go to review. The interrupt-point audit above is what that pass looks like — it is how the two extra addresses of your inputs_json finding and the torn-write case turned up. I will mark this ready once the Actions run on 75ac771c is green, unless you would rather look while it is still a draft.

@ting-hong-shieh

Copy link
Copy Markdown
Author

One correction to my previous comment before anyone relies on it.

I wrote "each of the five fixes was also reverted individually with only its own tests failing." When I posted that, I had individually reverted four of the five, each verified with a -k filter that ran only the test in question. Running the complete matrix afterwards — every bullet reverted on its own, the whole test file each time, no filter — the accurate picture is:

  • three bullets flip exactly their own tests (rollback: 2, clearing: 1, startup/parallel re-raise: 2);
  • reverting the serial binding + re-raise flips all eight serial interrupt tests, because they all depend on the re-raise itself;
  • reverting the simulate() catch flips the reload test and then aborts the pytest session — without the catch, the append=True continuation's second simulate() call lets the interrupt escape the test.

So the substantive point stands — every fix is pinned by at least one test that fails without it, all ten fail at develop (10 failed, 65 passed, whole file), and nothing unrelated breaks — but the sentence claimed a cleaner and more complete check than the one I had actually run. The description now carries the full matrix instead of the sentence.

simulate() returned normally after Ctrl-C. Both execution modes caught the
interrupt and neither re-raised it, so simulate() went on to
__terminate_simulation() and returned exactly as it does after a complete
study. A caller could not tell a partial run from a finished one without
opening the output file and counting rows.

Five more holes sat next to that one, found in review and in the
interrupt-point audit it prompted:

- __run_in_serial bound inputs_json inside the loop body, so Ctrl-C during
  the first keep_simulating() call reached the handler with nothing bound
  and the run died with UnboundLocalError from inside the cleanup.
- It also never cleared inputs_json after a successful append, so Ctrl-C
  landing in the progress print — or in the next keep_simulating() call —
  wrote the row that had just committed into the error file as though it
  never finished.
- _append_simulation_record rolled back on Exception, and
  KeyboardInterrupt does not derive from Exception, so Ctrl-C between the
  two appends left a one-sided record.
- Its rollback also only truncated the inputs file, so an interrupt inside
  either write left a torn partial row for the reload to die on with
  JSONDecodeError instead of the interrupt.
- The parallel cleanup began only after every worker had started, so
  Ctrl-C during the startup loop left the already-started workers running
  with nobody signalling or joining them.

Bind inputs_json before the loop and clear it after each committed append,
roll both files back on BaseException best-effort, cover the startup loop
with the same cleanup path over a started-workers list, and re-raise in
both handlers. Catch the interrupt in simulate() so
__terminate_simulation() still runs before it leaves: it reloads the logs
through the file setters, and set_num_of_loaded_sims is what the
documented append=True continuation reads.

The shutdown join stays unbounded, and the docstring now says so: the
interrupt propagates once every worker finishes the simulation it is in
and exits on its own. A worker stuck inside one simulation blocks the
interrupt as it already blocked the run; killing it here could tear a
half-written row into logs it holds the mutex for, and the bounded fleet
shutdown belongs to RocketPy-Team#1054. The ordinary exception path is unchanged.

Add ten regression tests covering both modes, the early interrupt, the
preserved rows, the reload, an interrupted run continued with append=True,
the between-appends rollback, the torn-write rollback, the committed row
staying out of the error file, and the startup-loop cleanup. They run the
real __terminate_simulation and assert the state it produces; all fail on
develop, and each fix was also reverted individually with only its own
test failing.
@ting-hong-shieh
ting-hong-shieh force-pushed the bug/monte-carlo-reraise-keyboard-interrupt branch from 75ac771 to 138825b Compare August 16, 2026 19:12
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.

2 participants