Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

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