spawn_server also backs _subprocess_compat.bounded_probe_run (git probes, PowerShell/tasklist scans, the update venv probe) and the scrub overrode an explicit env= too, so every probe lost *_TOKEN/*_API_KEY/PASSWORD* (GH_TOKEN for a gh credential helper, HF_TOKEN for a gated pull). Apply server_child_env in supervisor._spawn and leave spawn_server's caller environment untouched.
143 lines
5.7 KiB
Python
143 lines
5.7 KiB
Python
"""Contain each managed Windows router tree without adopting the owner process."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
|
|
import psutil
|
|
|
|
|
|
class _BasicLimits(ctypes.Structure):
|
|
_fields_ = [
|
|
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
("LimitFlags", wintypes.DWORD),
|
|
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
("ActiveProcessLimit", wintypes.DWORD),
|
|
("Affinity", ctypes.c_size_t),
|
|
("PriorityClass", wintypes.DWORD),
|
|
("SchedulingClass", wintypes.DWORD),
|
|
]
|
|
|
|
|
|
class _IoCounters(ctypes.Structure):
|
|
_fields_ = [(name, ctypes.c_ulonglong) for name in (
|
|
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
|
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount",
|
|
)]
|
|
|
|
|
|
class _ExtendedLimits(ctypes.Structure):
|
|
_fields_ = [
|
|
("BasicLimitInformation", _BasicLimits),
|
|
("IoInfo", _IoCounters),
|
|
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
("JobMemoryLimit", ctypes.c_size_t),
|
|
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
]
|
|
|
|
|
|
class _WindowsJob:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self._api = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
for name, args, result in (
|
|
("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
|
|
("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int,
|
|
ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
|
|
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
|
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
|
):
|
|
fn = getattr(self._api, name)
|
|
fn.argtypes = args
|
|
fn.restype = result
|
|
# NULL security attributes create a non-inheritable, unnamed owner handle.
|
|
self._handle = self._api.CreateJobObjectW(None, None)
|
|
if not self._handle:
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
try:
|
|
limits = _ExtendedLimits()
|
|
# Neither BREAKAWAY_OK nor SILENT_BREAKAWAY_OK: descendants stay contained.
|
|
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
|
|
if not self._api.SetInformationJobObject(
|
|
self._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def assign(self, proc: subprocess.Popen) -> None:
|
|
# Popen retains the original process handle, avoiding a PID-reuse race.
|
|
if not self._api.AssignProcessToJobObject(self._handle, int(proc._handle)):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
|
|
def close(self) -> None:
|
|
"""Terminate the contained tree; repeated closes are harmless."""
|
|
with self._lock:
|
|
if self._handle is not None:
|
|
if not self._api.CloseHandle(self._handle):
|
|
raise ctypes.WinError(ctypes.get_last_error())
|
|
self._handle = None
|
|
|
|
|
|
_CREDENTIAL_ENV_MARKERS = ("_API_KEY", "_TOKEN", "_SECRET", "PASSWORD", "_CREDENTIALS")
|
|
|
|
|
|
def server_child_env(base_env: Mapping[str, str]) -> dict[str, str]:
|
|
"""Return the environment a native inference child (llama-server) gets.
|
|
|
|
Provider and tool credentials never belong in a native child that talks to nobody but
|
|
us — and on Windows they are not merely leaked: the bundled OpenMP runtime died with
|
|
STATUS_HEAP_CORRUPTION during initialisation with one `*_API_KEY` present and loaded fine
|
|
with only that variable removed (#116109, confirmed on a model-free libomp.dll probe).
|
|
Everything else (PATH, CUDA_*, HSA_*, OMP_*, TEMP, …) passes through untouched. Applied by
|
|
the llama-server supervisor only: spawn_server is also the generic bounded-probe spawner
|
|
(git / PowerShell / update probes), whose children legitimately need GH_TOKEN, HF_TOKEN, …
|
|
"""
|
|
return {
|
|
key: value for key, value in base_env.items()
|
|
if not any(marker in key.upper() for marker in _CREDENTIAL_ENV_MARKERS)
|
|
}
|
|
|
|
|
|
def spawn_server(cmd, **kwargs) -> tuple[subprocess.Popen, _WindowsJob | None]:
|
|
"""Start a router, returning its process and an owner-held containment handle.
|
|
|
|
Keep the job until shutdown and call close() to terminate the entire tree.
|
|
Windows closes it automatically if the owner dies. Other hosts retain Popen's
|
|
ordinary behavior. Assignment happens before the child's first instruction.
|
|
"""
|
|
if sys.platform != "win32":
|
|
return subprocess.Popen(cmd, **kwargs), None
|
|
job = _WindowsJob()
|
|
proc = None
|
|
try:
|
|
kwargs["creationflags"] = kwargs.get("creationflags", 0) | 0x00000004 # CREATE_SUSPENDED
|
|
proc = subprocess.Popen(cmd, **kwargs)
|
|
job.assign(proc)
|
|
psutil.Process(proc.pid).resume()
|
|
return proc, job
|
|
except BaseException:
|
|
try:
|
|
if proc is not None:
|
|
# Assignment may have failed: closing an empty job is not enough.
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
finally:
|
|
try:
|
|
job.close()
|
|
finally:
|
|
if proc is not None:
|
|
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
if stream is not None:
|
|
stream.close()
|
|
proc._handle.Close()
|
|
raise
|