fix(execute_code): POSIX kernels exit when their host dies mid-cell (death pipe)
Widens the Windows parent-death fix to the class: the kernel inherits the read end of a pipe whose only write end lives in the host, so host death by any means (SIGKILL, OOM, crash) is EOF and the kernel exits. Stdin EOF alone only reaches the runner between cells, so a kernel SIGKILLed mid-cell used to outlive its host indefinitely. The integration test now runs on every platform and is trimmed to the contract (kill host mid-cell, kernel gone), not the handle plumbing.
This commit is contained in:
@@ -86,6 +86,39 @@ _CAPTURE_LIMIT = {capture_limit}
|
||||
_SPILL_DIR = os.environ.get("HERMES_KERNEL_SPILL_DIR", "")
|
||||
_SPILL_CAP = {spill_cap}
|
||||
_PARENT_PROCESS_HANDLE = os.environ.pop("HERMES_KERNEL_PARENT_PROCESS_HANDLE", "")
|
||||
_PARENT_DEATH_FD = os.environ.pop("HERMES_KERNEL_PARENT_DEATH_FD", "")
|
||||
|
||||
|
||||
def _start_parent_death_pipe_watchdog():
|
||||
"""POSIX twin of the Windows handle watchdog: exit when the parent dies.
|
||||
|
||||
The host holds the only write end of an inherited pipe; a blocking read
|
||||
returns EOF the instant the host exits by ANY means (SIGKILL, OOM, crash),
|
||||
exactly like the MCP death supervisor. Stdin EOF alone is not enough: the
|
||||
main loop only sees it between cells, so a kernel SIGKILLed mid-cell
|
||||
outlived its host. Not PR_SET_PDEATHSIG — that is bound to the spawning
|
||||
THREAD, and kernels are spawned from per-cell threads that exit.
|
||||
"""
|
||||
global _PARENT_DEATH_FD
|
||||
raw_fd = _PARENT_DEATH_FD
|
||||
_PARENT_DEATH_FD = ""
|
||||
if sys.platform == "win32" or not raw_fd:
|
||||
return
|
||||
try:
|
||||
fd = int(raw_fd)
|
||||
os.set_inheritable(fd, False)
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
def _wait():
|
||||
try:
|
||||
while os.read(fd, 1):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_wait, name="hermes-parent-watchdog", daemon=True).start()
|
||||
|
||||
|
||||
def _start_parent_process_watchdog():
|
||||
@@ -139,6 +172,7 @@ def _start_parent_process_watchdog():
|
||||
|
||||
|
||||
_start_parent_process_watchdog()
|
||||
_start_parent_death_pipe_watchdog()
|
||||
|
||||
# The persistent cell namespace. `__name__` is `__main__` so scripts behave
|
||||
# like the per-call path; builtins resolve normally through exec.
|
||||
@@ -314,6 +348,7 @@ class SessionKernel:
|
||||
self.stop_event = threading.Event()
|
||||
self.rpc_token: str = ""
|
||||
self.sentinel: str = ""
|
||||
self.death_pipe_w: Optional[int] = None
|
||||
self.tool_call_log: List = []
|
||||
self.tool_call_counter: List[int] = [0]
|
||||
# Cells currently attached to this kernel (bumped under _KERNELS_LOCK
|
||||
@@ -475,6 +510,12 @@ atexit.register(shutdown_all_kernels)
|
||||
|
||||
def _teardown(kernel: SessionKernel) -> None:
|
||||
kernel.stop_event.set()
|
||||
if kernel.death_pipe_w is not None:
|
||||
try:
|
||||
os.close(kernel.death_pipe_w)
|
||||
except OSError:
|
||||
pass
|
||||
kernel.death_pipe_w = None
|
||||
if kernel.proc is not None and kernel.proc.poll() is None:
|
||||
from tools.code_execution_tool import _kill_process_group
|
||||
|
||||
@@ -706,6 +747,13 @@ def _spawn(kernel: SessionKernel, *, task_id: str, child_python: str,
|
||||
close_parent_process_handle = None
|
||||
startupinfo = None
|
||||
|
||||
death_r: Optional[int] = None
|
||||
pass_fds: Tuple[int, ...] = ()
|
||||
if not _IS_WINDOWS:
|
||||
death_r, kernel.death_pipe_w = os.pipe()
|
||||
child_env["HERMES_KERNEL_PARENT_DEATH_FD"] = str(death_r)
|
||||
pass_fds = (death_r,)
|
||||
|
||||
try:
|
||||
kernel.proc = subprocess.Popen(
|
||||
[child_python, runner_path],
|
||||
@@ -719,11 +767,14 @@ def _spawn(kernel: SessionKernel, *, task_id: str, child_python: str,
|
||||
start_new_session=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
|
||||
close_fds=True,
|
||||
pass_fds=pass_fds,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
finally:
|
||||
if parent_process_handle and close_parent_process_handle is not None:
|
||||
close_parent_process_handle(parent_process_handle)
|
||||
if death_r is not None:
|
||||
os.close(death_r)
|
||||
|
||||
# Deliberately NOT propagate_context_to_thread: that would freeze the
|
||||
# spawning cell's context/callbacks into the server thread for the
|
||||
|
||||
Reference in New Issue
Block a user