fix: reap leaders during group shutdown

This commit is contained in:
Dennis Juhler Aagaard
2026-09-24 17:40:13 +02:00
parent 75ff25d80b
commit 31aeeb5ed3
2 changed files with 93 additions and 2 deletions
@@ -211,6 +211,7 @@ class ProcessManager:
self._sleep = sleep self._sleep = sleep
self._profile_path = profile_path self._profile_path = profile_path
self._children: list[tuple[str, subprocess.Popen]] = [] self._children: list[tuple[str, subprocess.Popen]] = []
self._reaped_children: set[int] = set()
self._groups: list[int] = [] self._groups: list[int] = []
self._dead_groups: set[int] = set() self._dead_groups: set[int] = set()
self._stopping = False self._stopping = False
@@ -255,7 +256,19 @@ class ProcessManager:
def _startup_stopping(self) -> bool: def _startup_stopping(self) -> bool:
return self._stopping or self._first_exited_child() is not None return self._stopping or self._first_exited_child() is not None
def _reap_exited_children(self) -> None:
for _name, process in self._children:
if process.pid in self._reaped_children or process.poll() is None:
continue
try:
process.wait(timeout=None)
except OSError:
logging.error("Child process reap failed")
else:
self._reaped_children.add(process.pid)
def _live_groups(self) -> list[int]: def _live_groups(self) -> list[int]:
self._reap_exited_children()
live_groups = [] live_groups = []
for pgid in self._groups: for pgid in self._groups:
if pgid in self._dead_groups: if pgid in self._dead_groups:
@@ -291,10 +304,14 @@ class ProcessManager:
def _reap_all(self) -> None: def _reap_all(self) -> None:
for _name, process in self._children: for _name, process in self._children:
if process.pid in self._reaped_children:
continue
try: try:
process.wait(timeout=None) process.wait(timeout=None)
except OSError: except OSError:
logging.error("Child process reap failed") logging.error("Child process reap failed")
else:
self._reaped_children.add(process.pid)
def _shutdown(self) -> None: def _shutdown(self) -> None:
if not self._children: if not self._children:
+76 -2
View File
@@ -382,6 +382,7 @@ class FakeProcess:
self.returncode: int | None = None self.returncode: int | None = None
self.ignores_term = ignores_term self.ignores_term = ignores_term
self.wait_calls: list[float | None] = [] self.wait_calls: list[float | None] = []
self.on_wait = None
def poll(self) -> int | None: def poll(self) -> int | None:
return self.returncode return self.returncode
@@ -390,6 +391,8 @@ class FakeProcess:
self.wait_calls.append(timeout) self.wait_calls.append(timeout)
if self.returncode is None: if self.returncode is None:
self.returncode = -9 self.returncode = -9
if self.on_wait is not None:
self.on_wait()
return self.returncode return self.returncode
@@ -400,6 +403,7 @@ def manager_harness(
cloak_probe=None, cloak_probe=None,
stelloauth_probe=None, stelloauth_probe=None,
ignores_term: tuple[bool, bool] = (False, False), ignores_term: tuple[bool, bool] = (False, False),
live_descendants: tuple[bool, bool] = (False, False),
): ):
clock = FakeClock() clock = FakeClock()
events: list[object] = [] events: list[object] = []
@@ -408,6 +412,17 @@ def manager_harness(
FakeProcess(1002, ignores_term=ignores_term[1]), FakeProcess(1002, ignores_term=ignores_term[1]),
] ]
groups = {process.pid: True for process in processes} groups = {process.pid: True for process in processes}
leaders_reaped = {process.pid: False for process in processes}
term_delivered = {process.pid: False for process in processes}
for index, process in enumerate(processes):
def on_wait(index=index, process=process) -> None:
leaders_reaped[process.pid] = True
if not live_descendants[index] or (
term_delivered[process.pid] and not process.ignores_term
):
groups[process.pid] = False
process.on_wait = on_wait
group_checks: list[int] = [] group_checks: list[int] = []
spawned: list[FakeProcess] = [] spawned: list[FakeProcess] = []
@@ -420,10 +435,17 @@ def manager_harness(
def killpg(pid: int, sent_signal: int) -> None: def killpg(pid: int, sent_signal: int) -> None:
events.append(("signal", pid, sent_signal)) events.append(("signal", pid, sent_signal))
process = next(item for item in processes if item.pid == pid) process = next(item for item in processes if item.pid == pid)
if sent_signal == signal.SIGKILL or not process.ignores_term: if sent_signal == signal.SIGKILL:
groups[pid] = False groups[pid] = False
if process.returncode is None: if process.returncode is None:
process.returncode = -sent_signal process.returncode = -sent_signal
else:
term_delivered[pid] = True
if not process.ignores_term:
if process.returncode is None:
process.returncode = -sent_signal
if leaders_reaped[pid]:
groups[pid] = False
def group_alive(pid: int) -> bool: def group_alive(pid: int) -> bool:
group_checks.append(pid) group_checks.append(pid)
@@ -583,7 +605,9 @@ def test_child_exit_stops_sibling_and_returns_failure(
def test_exited_leader_with_live_descendants_still_gets_group_sigterm( def test_exited_leader_with_live_descendants_still_gets_group_sigterm(
supervisor, tmp_path: Path, monkeypatch supervisor, tmp_path: Path, monkeypatch
) -> None: ) -> None:
harness = manager_harness(supervisor, tmp_path) harness = manager_harness(
supervisor, tmp_path, live_descendants=(True, False)
)
triggered = False triggered = False
def sleep(delay: float) -> None: def sleep(delay: float) -> None:
@@ -603,6 +627,56 @@ def test_exited_leader_with_live_descendants_still_gets_group_sigterm(
assert [process.wait_calls for process in harness.processes] == [[None], [None]] assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_leader_exit_on_sigterm_without_descendants_finishes_without_waiting(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(supervisor, tmp_path)
def stelloauth_probe() -> None:
harness.manager._handle_signal(signal.SIGTERM, None)
harness.manager._stelloauth_probe = stelloauth_probe
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
assert harness.clock.now == 0.0
assert not any(
event[0] == "signal" and event[2] == signal.SIGKILL
for event in harness.events
)
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_exited_leader_with_live_descendants_still_uses_deadline_and_sigkill(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(
supervisor,
tmp_path,
ignores_term=(True, False),
live_descendants=(True, False),
)
triggered = False
def sleep(delay: float) -> None:
nonlocal triggered
harness.clock.sleep(delay)
if not triggered:
triggered = True
harness.processes[0].returncode = 7
harness.manager._sleep = sleep
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 7
assert harness.clock.now == pytest.approx(10.25)
assert ("signal", 1001, signal.SIGTERM) in harness.events
assert ("signal", 1001, signal.SIGKILL) in harness.events
assert ("signal", 1002, signal.SIGTERM) in harness.events
assert ("signal", 1002, signal.SIGKILL) not in harness.events
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_group_disappearance_ends_shared_wait_without_sigkill( def test_group_disappearance_ends_shared_wait_without_sigkill(
supervisor, tmp_path: Path, monkeypatch supervisor, tmp_path: Path, monkeypatch
) -> None: ) -> None: