mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
strata runtime start/status/stop/verify/restart now work on Windows. The owner transport (loopback-only TCP, 256-bit token, state file in a private directory) was already there; the supervisor around it was fcntl/getuid/killpg only. - strata_control/winproc.py: LockFileEx byte-range leases (shared and exclusive, on a byte beyond the data so the file stays readable) and process identity (OpenProcess + GetProcessTimes). Liveness never uses os.kill(pid, 0): on Windows that is TerminateProcess. - runtime.py: state directory %LOCALAPPDATA%\Strata\runtime with a protected one-entry ACL (existing ones must be unshared); owner-only checks through private_files.is_unshared; the state file's address must be a loopback port; the owner host is detached with CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW; the server runs in a kill-on-close job object (serve/winjob.py, terminate() added), so the whole tree ends with the host however the host ends; a graceful stop closes the server's stdin pipe (--owner-fd 0, serve/owner_guard.py turns the EOF into the Ctrl+C drain path); the deadline kill ends the job; the ownership marker records the child's creation time, so a PID that was reused by another process is not mistaken for the recorded child. A PID from a state file is only compared, never signalled. - A connect failure to a lost owner is a typed ProfileError (exit 3) instead of a raw OSError. - `strata serve` and calibration take the activity lease on Windows too. - tests/terminal/test_runtime.py runs on Windows (32 tests were skipped): a .cmd stands in for the fake engine, the signal tests have Windows variants; new tests: a second client attaches from another process, wrong/stale tokens cannot stop the owner, a recorded PID of an unrelated live process is never touched, a reused PID with another creation time is not the recorded child, the liveness check does not end its target, the directory ACL, shared and exclusive leases.
107 lines
5.2 KiB
Python
107 lines
5.2 KiB
Python
"""serve/winjob.py - on Windows, tie the processes Strata starts to the server's own lifetime.
|
|
|
|
Closing the console window, Task Manager or a crash end the server without running its cleanup, and the engine,
|
|
the vision encoder and the MCP servers kept running on their own. `contain(proc)` puts a child in a job object that
|
|
is set to kill everything in it when its last handle closes - and the only handle is this process's, which the OS
|
|
closes however the server ends. Processes the child starts later join the same job. The server itself stays out of
|
|
the job, so a browser that `--open` starts is not tied to it. Each child (and the server) is also opted out of Windows
|
|
power throttling, see `no_throttle` (#691). Elsewhere, and if the job cannot be made, a no-op.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import threading
|
|
|
|
_job = None
|
|
_lock = threading.Lock()
|
|
|
|
if os.name == "nt":
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
|
|
class _IoCounters(ctypes.Structure):
|
|
_fields_ = [(n, ctypes.c_ulonglong) for n in ("ReadOperationCount", "WriteOperationCount",
|
|
"OtherOperationCount", "ReadTransferCount",
|
|
"WriteTransferCount", "OtherTransferCount")]
|
|
|
|
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 _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)]
|
|
|
|
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
_JobObjectExtendedLimitInformation = 9
|
|
|
|
_k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
_k32.CreateJobObjectW.argtypes = (wintypes.LPVOID, wintypes.LPCWSTR)
|
|
_k32.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD)
|
|
_k32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
_k32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
|
|
def _make_job():
|
|
job = _k32.CreateJobObjectW(None, None) # no security attributes: the handle is not inherited
|
|
if not job:
|
|
return None
|
|
info = _ExtendedLimits()
|
|
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
if not _k32.SetInformationJobObject(job, _JobObjectExtendedLimitInformation, ctypes.byref(info),
|
|
ctypes.sizeof(info)):
|
|
_k32.CloseHandle(job)
|
|
return None
|
|
return job
|
|
|
|
class _PowerThrottling(ctypes.Structure):
|
|
_fields_ = [("Version", wintypes.ULONG), ("ControlMask", wintypes.ULONG), ("StateMask", wintypes.ULONG)]
|
|
|
|
_k32.SetProcessInformation.argtypes = (wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD)
|
|
_k32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
|
|
def no_throttle(handle) -> bool:
|
|
"""Opt a process out of Windows power throttling (EcoQoS). Without it Windows 11 treats a process whose console
|
|
window is minimized as background work and, on a hybrid CPU, moves its threads to the E-cores: generation
|
|
slowed by about 20% while the server window was minimized (#691, i9-13980HX: 36.6 -> 43.2 tok/s)."""
|
|
for mask in (0x1 | 0x4, 0x1): # EXECUTION_SPEED and IGNORE_TIMER_RESOLUTION, both off (Windows 11);
|
|
info = _PowerThrottling(1, mask, 0) # Windows 10 knows the first only: ERROR_INVALID_PARAMETER for both
|
|
if _k32.SetProcessInformation(handle, 4, ctypes.byref(info), ctypes.sizeof(info)): # ProcessPowerThrottling
|
|
return True
|
|
return False
|
|
|
|
no_throttle(_k32.GetCurrentProcess()) # the server itself (tokenizing, streaming) too
|
|
else:
|
|
def no_throttle(handle) -> bool:
|
|
return False
|
|
|
|
|
|
def terminate() -> bool:
|
|
"""End every process in the job now (the deadline kill of an owner that must not leave a tree behind). False when
|
|
there is no job (nothing was contained, or not Windows)."""
|
|
if os.name != "nt" or not _job:
|
|
return False
|
|
return bool(_k32.TerminateJobObject(_job, 1))
|
|
|
|
|
|
def contain(proc) -> bool:
|
|
"""Put a started subprocess.Popen in the kill-on-close job. True if it is in; False (harmlessly) otherwise."""
|
|
global _job
|
|
if os.name != "nt" or proc is None:
|
|
return False
|
|
with _lock:
|
|
if _job is None:
|
|
_job = _make_job() or 0
|
|
if not _job:
|
|
return False
|
|
try:
|
|
no_throttle(int(proc._handle))
|
|
return bool(_k32.AssignProcessToJobObject(_job, int(proc._handle)))
|
|
except (AttributeError, OSError, ValueError):
|
|
return False
|