From 20fc9706b599f7a7c4c5ce7cf4cf85ae3952c4fa Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:13:29 +0800 Subject: [PATCH] fix(runtime): reap children exiting during shutdown observation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/effect_runtime.py | 4 ++ .../test_effect_runtime_restart.py | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index cd209d651a..b5c78ed674 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -782,6 +782,10 @@ def _stop_effect_runtime( except (OSError, subprocess.TimeoutExpired, UnicodeError, RuntimeError): stopped = False if stopped: + # The child may have exited during the group snapshot, + # after the first nonblocking reap. Retire its wait status + # before returning successful namespace cleanup. + _reap_exited_runtime_child(info) break time.sleep(.05) continue diff --git a/tests/control_plane/test_effect_runtime_restart.py b/tests/control_plane/test_effect_runtime_restart.py index eeb95e6614..ce45daa315 100644 --- a/tests/control_plane/test_effect_runtime_restart.py +++ b/tests/control_plane/test_effect_runtime_restart.py @@ -6,6 +6,7 @@ import os import socket import subprocess +import sys from pathlib import Path from types import SimpleNamespace import time @@ -350,3 +351,48 @@ def unavailable(pid, timeout): assert result["stopped"] is False # Ordinary serving retirement retains its existing, non-destructive contract. assert effect_runtime.restart_effect_runtime()["status"] == "stopped" + + +@pytest.mark.skipif(os.name != "posix" or not hasattr(os, "WNOWAIT"), reason="POSIX child exit observation") +def test_namespace_shutdown_reaps_child_exiting_during_group_observation(tmp_path, monkeypatch): + from loopx.extensions import process_runtime + + child = subprocess.Popen( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=subprocess.PIPE, start_new_session=True, + ) + info = {"pid": child.pid, "token": "serving"} + observe_group = process_runtime._posix_owned_group_has_exited + requests, observations = [], [] + + def shutdown(_info, **kwargs): + requests.append(kwargs["method"]) + # Keep the child live through the first nonblocking reap. It exits + # during the following real process-group observation instead. + return {} + + def exited_during_observation(pid, timeout): + assert pid == child.pid and effect_runtime._pid_is_alive(pid) + child.stdin.close() + # Observe the real exit without reaping it. kill(pid, 0) still sees + # this zombie, while the group observer correctly sees no writer. + os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT) + exited = observe_group(pid, timeout) + observations.append(exited) + assert exited and effect_runtime._pid_is_alive(pid) + return exited + + monkeypatch.setattr(effect_runtime, "_request_with_info", shutdown) + monkeypatch.setattr(process_runtime, "_posix_owned_group_has_exited", exited_during_observation) + try: + result = effect_runtime._stop_effect_runtime( + tmp_path / "runtime.json", info, timeout=5, wait_for_process_exit=True, + ) + assert result["stopped"] and result["status"] == "stopped" + assert requests == ["runtime.shutdown"] and observations == [True] + assert not effect_runtime._pid_is_alive(child.pid), "Exited child must be reaped before cleanup returns" + with pytest.raises(ChildProcessError): + os.waitpid(child.pid, os.WNOHANG) + finally: + child.stdin.close() + child.wait(timeout=5)