fix(execute_code): stop Windows kernels with backend parent
This commit is contained in:
+114
-12
@@ -78,12 +78,67 @@ import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
_SENTINEL = os.environ["HERMES_KERNEL_SENTINEL"]
|
||||
_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", "")
|
||||
|
||||
|
||||
def _start_parent_process_watchdog():
|
||||
"""Exit when the exact Windows parent process object is signaled.
|
||||
|
||||
The inherited SYNCHRONIZE handle names a process object, not a reusable
|
||||
PID. Missing or invalid handles fail open so watchdog setup can never kill
|
||||
an otherwise healthy kernel.
|
||||
"""
|
||||
global _PARENT_PROCESS_HANDLE
|
||||
raw_handle = _PARENT_PROCESS_HANDLE
|
||||
_PARENT_PROCESS_HANDLE = ""
|
||||
if sys.platform != "win32" or not raw_handle:
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
handle = int(raw_handle)
|
||||
if handle <= 0:
|
||||
return
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel32.SetHandleInformation.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
kernel32.SetHandleInformation.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
# This process needs the handle, but user code spawned by a cell must
|
||||
# not pass it any further. If Windows refuses to clear inheritance,
|
||||
# disable the watchdog rather than leak the handle into cell children.
|
||||
if not kernel32.SetHandleInformation(handle, 0x00000001, 0):
|
||||
kernel32.CloseHandle(handle)
|
||||
return
|
||||
except (ImportError, OSError, TypeError, ValueError):
|
||||
return
|
||||
|
||||
def _wait():
|
||||
try:
|
||||
result = kernel32.WaitForSingleObject(handle, 0xFFFFFFFF)
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
if result == 0x00000000: # WAIT_OBJECT_0: the parent exited
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_wait, name="hermes-parent-watchdog", daemon=True).start()
|
||||
|
||||
|
||||
_start_parent_process_watchdog()
|
||||
|
||||
# The persistent cell namespace. `__name__` is `__main__` so scripts behave
|
||||
# like the per-call path; builtins resolve normally through exec.
|
||||
@@ -610,18 +665,65 @@ def _spawn(kernel: SessionKernel, *, task_id: str, child_python: str,
|
||||
# timeout — a kernel outlives the 300s window between cells.
|
||||
child_env["HERMES_RPC_PERSISTENT"] = "1"
|
||||
|
||||
kernel.proc = subprocess.Popen(
|
||||
[child_python, runner_path],
|
||||
# Strict mode resolves an empty cwd: the kernel's own staging dir
|
||||
# then plays the per-call tmpdir's role.
|
||||
cwd=child_cwd or kernel.tmpdir,
|
||||
env=child_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
|
||||
)
|
||||
parent_process_handle = None
|
||||
close_parent_process_handle = None
|
||||
startupinfo = None
|
||||
if _IS_WINDOWS:
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.GetCurrentProcessId.argtypes = []
|
||||
kernel32.GetCurrentProcessId.restype = wintypes.DWORD
|
||||
kernel32.OpenProcess.argtypes = [
|
||||
wintypes.DWORD,
|
||||
wintypes.BOOL,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
close_parent_process_handle = kernel32.CloseHandle
|
||||
parent_process_handle = kernel32.OpenProcess(
|
||||
0x00100000, # SYNCHRONIZE
|
||||
True, # inherited only by the explicitly allow-listed child
|
||||
kernel32.GetCurrentProcessId(),
|
||||
)
|
||||
if parent_process_handle:
|
||||
child_env["HERMES_KERNEL_PARENT_PROCESS_HANDLE"] = str(
|
||||
int(parent_process_handle)
|
||||
)
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.lpAttributeList = {
|
||||
"handle_list": [int(parent_process_handle)]
|
||||
}
|
||||
except (AttributeError, ImportError, OSError, TypeError, ValueError):
|
||||
if parent_process_handle and close_parent_process_handle is not None:
|
||||
close_parent_process_handle(parent_process_handle)
|
||||
child_env.pop("HERMES_KERNEL_PARENT_PROCESS_HANDLE", None)
|
||||
parent_process_handle = None
|
||||
close_parent_process_handle = None
|
||||
startupinfo = None
|
||||
|
||||
try:
|
||||
kernel.proc = subprocess.Popen(
|
||||
[child_python, runner_path],
|
||||
# Strict mode resolves an empty cwd: the kernel's own staging dir
|
||||
# then plays the per-call tmpdir's role.
|
||||
cwd=child_cwd or kernel.tmpdir,
|
||||
env=child_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
|
||||
close_fds=True,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
finally:
|
||||
if parent_process_handle and close_parent_process_handle is not None:
|
||||
close_parent_process_handle(parent_process_handle)
|
||||
|
||||
# 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