unsloth/studio/backend/utils/process_lifetime.py
Daniel Han 8e0d082c92
Reap Studio child processes when the parent dies abnormally (#6425)
* Reap Studio child processes when the parent dies abnormally

Standalone `unsloth studio` launches orphaned cloudflared and llama-server when
the parent exited without running the cooperative shutdown path (terminal-window
close, Task Manager End Task, SIGKILL): the children reparented to init and kept
running, leaving an authenticated Cloudflare tunnel up for days.

Add utils/process_lifetime.py: a parent-owned Windows Job Object
(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, children auto-inherit) plus Linux
PR_SET_PDEATHSIG, behind a best-effort helper that mirrors the desktop app's
windows_job.rs. initialize_parent_lifetime() runs at the top of run_server;
long-lived spawns (cloudflared, llama-server, RAG embedder, llama.cpp updater)
get the PDEATHSIG preexec, multiprocessing workers are adopted into the job, and
_graceful_shutdown plus atexit gain a terminate_all() backstop sweep. The
cooperative shutdown path is otherwise unchanged.

Verified on Linux: killing the parent now reaps cloudflared and llama-server
within ~2s instead of orphaning them.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* test: add real Windows kill-on-job-close integration test

Spawn a parent that installs the job and a child that inherits it, terminate
the parent, and assert the child is reaped. Skipped off Windows. Also make the
liveness probe Windows-safe (os.kill(pid, 0) terminates on Windows).

* Fix Win64 handle truncation in the Job Object calls

Set explicit argtypes so the 64-bit job/process handles are not marshaled as
c_int (which truncated them on Win64, failing AssignProcessToJobObject). Assert
install success in the Windows integration test.

* Bind multiprocessing workers to parent death; harden the sweep

Review follow-ups:
- Multiprocessing workers (inference/export/training/data-recipe/Xet) cannot be
  given a preexec_fn by the parent, so adopt_pid alone left them orphanable on a
  Linux SIGKILL. They now bind themselves with PR_SET_PDEATHSIG at startup via
  bind_current_process_to_parent_lifetime(), wired into the shared
  run_without_native_path_secret entrypoint and the Xet child entry.
- Wire the previously-missed data-recipe worker through adopt_pid.
- terminate_all now honors its timeout: SIGTERM, wait, then SIGKILL the
  survivors, so cooperative children can exit cleanly.
- Track adopted pids with a /proc starttime identity and add forget_pid, so the
  shutdown sweep never signals a recycled pid.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Michael Han <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 05:51:22 -07:00

293 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