Skip to content

Commit 6b260bb

Browse files
committed
BUG: stop waiting on workers once one of them has died
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>
1 parent ce74db8 commit 6b260bb

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

‎rocketpy/simulation/monte_carlo.py‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,7 @@ def __run_in_parallel(self, n_workers=None):
486486
sim_producer.start()
487487

488488
try:
489-
for sim_producer in processes:
490-
sim_producer.join()
489+
_join_the_workers(processes, simulation_error_event)
491490

492491
# Before the event: a worker that was killed, or that died
493492
# before its own handler could set it, leaves it clear, and the
@@ -1777,6 +1776,51 @@ def export_errors_to_json(self, filename):
17771776
self._write_log_to_json(self.errors_log, filename)
17781777

17791778

1779+
# Short enough that a dead worker is noticed promptly, long enough that the
1780+
# polling costs nothing over a run that takes hours.
1781+
_JOIN_POLL_SECONDS = 0.2
1782+
_SHUTDOWN_GRACE_SECONDS = 5.0
1783+
1784+
1785+
def _ended_badly(worker):
1786+
"""Whether a worker has stopped, and stopped for the wrong reason."""
1787+
return worker.exitcode not in (None, 0)
1788+
1789+
1790+
def _stop_the_workers_still_running(processes, error_event, grace_period):
1791+
"""Ask the rest to stop, then end the ones that cannot.
1792+
1793+
Asked first because a worker between simulations reads the event and leaves
1794+
with its logs intact. One blocked on a lock its dead sibling was holding
1795+
never reaches that check, and only ending it frees the run.
1796+
"""
1797+
with suppress(Exception):
1798+
error_event.set()
1799+
deadline = time() + grace_period
1800+
for worker in processes:
1801+
worker.join(timeout=max(0.0, deadline - time()))
1802+
for worker in processes:
1803+
if worker.is_alive():
1804+
worker.terminate()
1805+
worker.join(timeout=grace_period)
1806+
1807+
1808+
def _join_the_workers(processes, error_event, grace_period=_SHUTDOWN_GRACE_SECONDS):
1809+
"""Wait for the workers, and stop waiting once one of them has died badly.
1810+
1811+
The lock the workers share belongs to the manager and is not released when
1812+
its holder is killed, so a sibling can block on a lock nobody owns while an
1813+
unbounded join waits with it. Nothing here bounds a run that is merely
1814+
slow: only an exit code says a worker has died.
1815+
"""
1816+
while any(worker.is_alive() for worker in processes):
1817+
for worker in processes:
1818+
worker.join(timeout=_JOIN_POLL_SECONDS)
1819+
if any(_ended_badly(worker) for worker in processes):
1820+
_stop_the_workers_still_running(processes, error_event, grace_period)
1821+
return
1822+
1823+
17801824
def _worker_failure_record(where, details):
17811825
"""A row for a worker that failed before it drew anything.
17821826
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import pytest
2+
3+
from rocketpy.simulation.monte_carlo import _join_the_workers
4+
5+
6+
class _Worker:
7+
"""A process that stops after a set number of polls, or never.
8+
9+
``never`` stands in for one blocked on a lock its dead sibling was holding,
10+
which is the case an unbounded join waits out forever.
11+
"""
12+
13+
def __init__(self, exitcode=0, alive_for=0, never=False):
14+
self.exitcode = None
15+
self._final_exitcode = exitcode
16+
self._alive_for = alive_for
17+
self._never = never
18+
self.joins = 0
19+
self.terminated = False
20+
21+
def is_alive(self):
22+
return self.exitcode is None
23+
24+
def join(self, timeout=None): # pylint: disable=unused-argument
25+
self.joins += 1
26+
if self._never or self.joins <= self._alive_for:
27+
return
28+
self.exitcode = self._final_exitcode
29+
30+
def terminate(self):
31+
self.terminated = True
32+
self.exitcode = -15
33+
34+
35+
class _Event:
36+
def __init__(self):
37+
self.was_set = False
38+
39+
def set(self):
40+
self.was_set = True
41+
42+
43+
def test_a_run_where_every_worker_finishes_is_left_alone():
44+
workers = [_Worker(alive_for=3), _Worker(alive_for=5)]
45+
46+
_join_the_workers(workers, _Event(), grace_period=0)
47+
48+
assert [worker.exitcode for worker in workers] == [0, 0]
49+
assert not any(worker.terminated for worker in workers)
50+
51+
52+
def test_a_worker_blocked_behind_a_dead_one_does_not_wait_forever():
53+
# The one that mattered. Without a bound this call never returns, so the
54+
# parent never reaches the check that would have reported the failure.
55+
died = _Worker(exitcode=-9, alive_for=1)
56+
blocked = _Worker(never=True)
57+
58+
_join_the_workers([died, blocked], _Event(), grace_period=0)
59+
60+
assert blocked.terminated
61+
62+
63+
def test_the_survivors_are_asked_before_they_are_ended():
64+
died = _Worker(exitcode=1, alive_for=1)
65+
blocked = _Worker(never=True)
66+
event = _Event()
67+
68+
_join_the_workers([died, blocked], event, grace_period=0)
69+
70+
assert event.was_set
71+
72+
73+
def test_a_survivor_that_stops_on_its_own_is_not_terminated():
74+
died = _Worker(exitcode=1, alive_for=1)
75+
cooperative = _Worker(alive_for=2)
76+
77+
_join_the_workers([died, cooperative], _Event(), grace_period=0)
78+
79+
assert not cooperative.terminated
80+
assert cooperative.exitcode == 0
81+
82+
83+
@pytest.mark.parametrize("exitcode", [-9, 1, 2])
84+
def test_any_bad_exit_starts_the_shutdown(exitcode):
85+
died = _Worker(exitcode=exitcode, alive_for=1)
86+
blocked = _Worker(never=True)
87+
88+
_join_the_workers([died, blocked], _Event(), grace_period=0)
89+
90+
assert blocked.terminated
91+
92+
93+
def test_a_slow_run_is_never_bounded():
94+
# Nothing here may act on how long a worker takes, only on it having died.
95+
slow = _Worker(alive_for=50)
96+
slower = _Worker(alive_for=80)
97+
98+
_join_the_workers([slow, slower], _Event(), grace_period=0)
99+
100+
assert not any(worker.terminated for worker in (slow, slower))
101+
assert slower.joins > 50

0 commit comments

Comments
 (0)