e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
294 lines
10 KiB
Python
294 lines
10 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Bind Studio child processes to the parent's lifetime so none survive an
|
|
abnormal parent exit (terminal-window close, Task Manager "End Task", SIGKILL,
|
|
crash) -- the cooperative shutdown path only runs on graceful exits.
|
|
|
|
Windows: one parent-owned Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
|
|
The parent is assigned to it, children inherit it automatically, and the OS
|
|
reaps every process in the job when the parent's last handle closes. Mirrors the
|
|
desktop app's job in studio/src-tauri/src/windows_job.rs.
|
|
|
|
POSIX: each long-lived child sets prctl(PR_SET_PDEATHSIG) on Linux via a tiny
|
|
preexec hook (macOS has no equivalent and relies on the cooperative path +
|
|
terminate_all). Linux's signal is per-direct-child only, so multiprocessing
|
|
workers are also tracked for terminate_all.
|
|
|
|
Best-effort throughout: any failure degrades to today's behavior, never raises.
|
|
Stdlib only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
from typing import Callable, Optional
|
|
|
|
_PR_SET_PDEATHSIG = 1
|
|
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
_JobObjectExtendedLimitInformation = 9
|
|
|
|
_lock = threading.Lock()
|
|
_initialized = False
|
|
_win_job_handle: Optional[int] = None # retained for the interpreter's lifetime
|
|
_tracked_pids: "dict[int, Optional[str]]" = {} # pid -> identity, reaped by terminate_all
|
|
|
|
|
|
def _is_linux() -> bool:
|
|
return sys.platform.startswith("linux")
|
|
|
|
|
|
def _is_windows() -> bool:
|
|
return sys.platform == "win32"
|
|
|
|
|
|
# ── Parent setup ──
|
|
|
|
|
|
def initialize_parent_lifetime() -> None:
|
|
"""Install the parent-death reaper once, as early as possible at startup.
|
|
|
|
Windows builds and holds the Job Object; POSIX has nothing to install (the
|
|
guarantee is per-child via preexec). Idempotent and never raises.
|
|
"""
|
|
global _initialized
|
|
with _lock:
|
|
if _initialized:
|
|
return
|
|
_initialized = True
|
|
if _is_windows():
|
|
_install_windows_job()
|
|
|
|
|
|
def _win_signatures(kernel32) -> None:
|
|
# Explicit HANDLE-width signatures. Without argtypes, ctypes marshals the
|
|
# 64-bit job/process handles as c_int and truncates them on Win64, so the
|
|
# job calls silently operate on a bogus handle and assignment fails.
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
H, BOOL, DWORD = wintypes.HANDLE, wintypes.BOOL, wintypes.DWORD
|
|
kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p]
|
|
kernel32.CreateJobObjectW.restype = H
|
|
kernel32.SetInformationJobObject.argtypes = [H, ctypes.c_int, ctypes.c_void_p, DWORD]
|
|
kernel32.SetInformationJobObject.restype = BOOL
|
|
kernel32.AssignProcessToJobObject.argtypes = [H, H]
|
|
kernel32.AssignProcessToJobObject.restype = BOOL
|
|
kernel32.GetCurrentProcess.argtypes = []
|
|
kernel32.GetCurrentProcess.restype = H
|
|
kernel32.CloseHandle.argtypes = [H]
|
|
kernel32.CloseHandle.restype = BOOL
|
|
|
|
|
|
def _install_windows_job() -> None:
|
|
global _win_job_handle
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
|
|
_win_signatures(kernel32)
|
|
|
|
class _BASIC(ctypes.Structure):
|
|
_fields_ = [
|
|
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
("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 _IO(ctypes.Structure):
|
|
_fields_ = [
|
|
(n, ctypes.c_uint64)
|
|
for n in (
|
|
"ReadOperationCount",
|
|
"WriteOperationCount",
|
|
"OtherOperationCount",
|
|
"ReadTransferCount",
|
|
"WriteTransferCount",
|
|
"OtherTransferCount",
|
|
)
|
|
]
|
|
|
|
class _EXT(ctypes.Structure):
|
|
_fields_ = [
|
|
("BasicLimitInformation", _BASIC),
|
|
("IoInfo", _IO),
|
|
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
("JobMemoryLimit", ctypes.c_size_t),
|
|
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
]
|
|
|
|
job = kernel32.CreateJobObjectW(None, None)
|
|
if not job:
|
|
return
|
|
info = _EXT()
|
|
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
if not kernel32.SetInformationJobObject(
|
|
job, _JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info)
|
|
):
|
|
kernel32.CloseHandle(job)
|
|
return
|
|
# AssignProcessToJobObject(parent) makes children inherit the job. May
|
|
# fail if Studio already runs inside an incompatible host job (pre-Win8);
|
|
# degrade to the cooperative path rather than blocking startup.
|
|
if not kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess()):
|
|
kernel32.CloseHandle(job)
|
|
return
|
|
_win_job_handle = job # hold the handle so the job is not closed early
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Child binding ──
|
|
|
|
|
|
def _pdeathsig_preexec() -> None:
|
|
# Runs in the forked child before exec. prctl is Linux-only; the getppid
|
|
# check closes the race where the parent died before this ran.
|
|
try:
|
|
import ctypes
|
|
ctypes.CDLL("libc.so.6", use_errno = True).prctl(_PR_SET_PDEATHSIG, signal.SIGTERM)
|
|
if os.getppid() == 1:
|
|
os._exit(1)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def bind_current_process_to_parent_lifetime() -> None:
|
|
"""Bind the CURRENT process to its parent's death (Linux). For multiprocessing
|
|
children, which cannot take a preexec_fn, so the parent cannot set
|
|
PR_SET_PDEATHSIG for them -- the child must do it itself at startup."""
|
|
if _is_linux():
|
|
_pdeathsig_preexec()
|
|
|
|
|
|
def compose_preexec(existing: Optional[Callable[[], None]]) -> Optional[Callable[[], None]]:
|
|
"""Run the PDEATHSIG hook then any caller-supplied preexec (Linux only)."""
|
|
if not _is_linux():
|
|
return existing
|
|
if existing is None:
|
|
return _pdeathsig_preexec
|
|
|
|
def _composed() -> None:
|
|
_pdeathsig_preexec()
|
|
existing()
|
|
|
|
return _composed
|
|
|
|
|
|
def child_popen_kwargs(preexec_fn: Optional[Callable[[], None]] = None) -> dict:
|
|
"""Popen kwargs that bind a long-lived child to the parent's lifetime.
|
|
|
|
On Linux returns a composed ``preexec_fn`` (PDEATHSIG + any existing one);
|
|
empty elsewhere (Windows is covered by the inherited Job Object). Merge via
|
|
``**child_popen_kwargs()`` alongside the caller's existing kwargs.
|
|
"""
|
|
if _is_linux():
|
|
return {"preexec_fn": compose_preexec(preexec_fn)}
|
|
return {}
|
|
|
|
|
|
def _pid_identity(pid: int) -> Optional[str]:
|
|
# Linux /proc starttime (stat field 22); pins identity so a reused pid is not
|
|
# signalled later. None (other platforms / unreadable) disables the check.
|
|
if not _is_linux():
|
|
return None
|
|
try:
|
|
with open(f"/proc/{pid}/stat", encoding = "utf-8") as fh:
|
|
stat = fh.read()
|
|
return stat[stat.rfind(")") + 2 :].split()[19] # after comm: starttime
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def forget_pid(pid: Optional[int]) -> None:
|
|
"""Stop tracking a child the owner has reaped, so terminate_all never
|
|
signals a recycled pid."""
|
|
if pid:
|
|
_tracked_pids.pop(pid, None)
|
|
|
|
|
|
def adopt_pid(pid: Optional[int]) -> None:
|
|
"""Track a child (e.g. a multiprocessing worker started after the parent job
|
|
was set up) and, on Windows, assign it to the job as belt-and-suspenders.
|
|
Tolerates a None or already-exited pid."""
|
|
if not pid:
|
|
return
|
|
_tracked_pids[pid] = _pid_identity(pid)
|
|
if _is_windows() and _win_job_handle:
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error = True)
|
|
_win_signatures(kernel32)
|
|
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
PROCESS_SET_QUOTA, PROCESS_TERMINATE = 0x0100, 0x0001
|
|
handle = kernel32.OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, False, pid)
|
|
if handle:
|
|
kernel32.AssignProcessToJobObject(_win_job_handle, handle)
|
|
kernel32.CloseHandle(handle)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def terminate_all(timeout: float = 5.0) -> None:
|
|
"""Backstop sweep over adopted pids, after per-subsystem cleanup. SIGTERM,
|
|
then SIGKILL the survivors after `timeout`. Skips a pid whose identity no
|
|
longer matches (recycled). Idempotent and teardown-safe."""
|
|
for pid, identity in list(_tracked_pids.items()):
|
|
_tracked_pids.pop(pid, None)
|
|
if identity is not None and _pid_identity(pid) != identity:
|
|
continue # pid was reused by an unrelated process
|
|
try:
|
|
if _is_windows():
|
|
os.kill(pid, signal.SIGTERM)
|
|
continue
|
|
_posix_terminate(pid, timeout)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _posix_terminate(pid: int, timeout: float = 5.0) -> None:
|
|
# SIGTERM, give the child up to `timeout` to exit, then SIGKILL. Reaping
|
|
# belongs to the child's owner (or init for orphans). Prefer the group
|
|
# (covers grandchildren) when pid leads its own group.
|
|
import time
|
|
|
|
killer = os.kill
|
|
try:
|
|
if os.getpgid(pid) == pid:
|
|
killer = os.killpg
|
|
except Exception:
|
|
pass
|
|
try:
|
|
killer(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
return
|
|
except Exception:
|
|
return
|
|
deadline = time.monotonic() + max(0.0, timeout)
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
killer(pid, 0) # still alive?
|
|
except ProcessLookupError:
|
|
return
|
|
except Exception:
|
|
break
|
|
time.sleep(0.05)
|
|
try:
|
|
killer(pid, signal.SIGKILL)
|
|
except Exception:
|
|
pass
|