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:
Teknium
2026-09-02 23:39:43 -07:00
parent 32d5e9d357
commit 365e2835d4
2 changed files with 90 additions and 154 deletions
+51
View File
@@ -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