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
320 lines
11 KiB
Python
320 lines
11 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
|
|
|
|
"""Tests for the parent-lifetime reaper (utils/process_lifetime).
|
|
|
|
The Linux PDEATHSIG cases spawn real processes and assert actual liveness; the
|
|
Windows Job Object path is exercised with a mocked kernel32 so it runs on CI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND = Path(__file__).resolve().parent.parent
|
|
if str(_BACKEND) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND))
|
|
|
|
import utils.process_lifetime as pl # noqa: E402
|
|
|
|
IS_POSIX = os.name == "posix"
|
|
IS_LINUX = sys.platform.startswith("linux")
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _reset_module_state():
|
|
pl._tracked_pids.clear()
|
|
pl._win_job_handle = None
|
|
pl._initialized = False
|
|
yield
|
|
pl._tracked_pids.clear()
|
|
pl._win_job_handle = None
|
|
pl._initialized = False
|
|
|
|
|
|
def _alive(pid: int) -> bool:
|
|
if sys.platform == "win32":
|
|
return _win_alive(pid)
|
|
try:
|
|
os.kill(pid, 0) # POSIX existence probe (on Windows this would terminate it)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _win_alive(pid: int) -> bool:
|
|
import ctypes
|
|
|
|
PROCESS_QUERY_LIMITED_INFORMATION, STILL_ACTIVE = 0x1000, 259
|
|
kernel32 = ctypes.windll.kernel32
|
|
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
if not handle:
|
|
return False
|
|
code = ctypes.c_ulong()
|
|
kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
kernel32.CloseHandle(handle)
|
|
return code.value == STILL_ACTIVE
|
|
|
|
|
|
def _wait_dead(pid: int, timeout: float) -> bool:
|
|
end = time.time() + timeout
|
|
while time.time() < end:
|
|
if not _alive(pid):
|
|
return True
|
|
time.sleep(0.05)
|
|
return not _alive(pid)
|
|
|
|
|
|
# ── No-op safety / composition ──
|
|
|
|
|
|
def test_initialize_idempotent_and_noop_on_posix():
|
|
pl.initialize_parent_lifetime()
|
|
pl.initialize_parent_lifetime() # second call short-circuits
|
|
if IS_POSIX:
|
|
assert pl._win_job_handle is None # POSIX installs no job
|
|
|
|
|
|
def test_adopt_pid_tolerates_none_and_dead_pid():
|
|
pl.adopt_pid(None) # ignored
|
|
pl.adopt_pid(2**31 - 1) # almost-certainly-dead pid: recorded, never raises
|
|
assert None not in pl._tracked_pids
|
|
|
|
|
|
def test_child_popen_kwargs_linux_vs_other(monkeypatch):
|
|
monkeypatch.setattr(pl, "_is_linux", lambda: True)
|
|
assert "preexec_fn" in pl.child_popen_kwargs()
|
|
monkeypatch.setattr(pl, "_is_linux", lambda: False)
|
|
assert pl.child_popen_kwargs() == {} # Windows/macOS add nothing here
|
|
|
|
|
|
def test_compose_preexec_runs_pdeathsig_then_existing(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(pl, "_is_linux", lambda: True)
|
|
monkeypatch.setattr(pl, "_pdeathsig_preexec", lambda: calls.append("death"))
|
|
pl.compose_preexec(lambda: calls.append("existing"))()
|
|
assert calls == ["death", "existing"] # ordering matters for sandbox hooks
|
|
|
|
|
|
def test_compose_preexec_passthrough_off_linux(monkeypatch):
|
|
monkeypatch.setattr(pl, "_is_linux", lambda: False)
|
|
sentinel = lambda: None # noqa: E731
|
|
assert pl.compose_preexec(sentinel) is sentinel
|
|
assert pl.compose_preexec(None) is None
|
|
|
|
|
|
# ── Real Linux PDEATHSIG: child dies when the parent dies abnormally ──
|
|
|
|
|
|
@pytest.mark.skipif(not IS_LINUX, reason = "PR_SET_PDEATHSIG is Linux-only")
|
|
def test_pdeathsig_child_dies_when_parent_sigkilled(tmp_path):
|
|
mid = tmp_path / "mid.py"
|
|
mid.write_text(
|
|
"import sys, subprocess, time\n"
|
|
f"sys.path.insert(0, {str(_BACKEND)!r})\n"
|
|
"from utils.process_lifetime import child_popen_kwargs\n"
|
|
"p = subprocess.Popen(['sleep', '300'], **child_popen_kwargs())\n"
|
|
"print(p.pid, flush = True)\n"
|
|
"time.sleep(300)\n"
|
|
)
|
|
proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
|
|
try:
|
|
sleeper_pid = int(proc.stdout.readline().strip())
|
|
assert _alive(sleeper_pid)
|
|
proc.kill() # hard-kill the parent (no graceful shutdown runs)
|
|
proc.wait(timeout = 5)
|
|
assert _wait_dead(sleeper_pid, 5.0), "child orphaned after parent SIGKILL"
|
|
finally:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason = "Windows Job Object")
|
|
def test_windows_job_kills_child_when_parent_dies(tmp_path):
|
|
# Real kill-on-job-close: the parent installs the job and assigns itself, a
|
|
# child inherits it automatically, and terminating the parent must reap the
|
|
# child (the orphaned-cloudflared.exe scenario).
|
|
mid = tmp_path / "mid.py"
|
|
mid.write_text(
|
|
"import sys, subprocess, time\n"
|
|
f"sys.path.insert(0, {str(_BACKEND)!r})\n"
|
|
"import utils.process_lifetime as pl\n"
|
|
"pl.initialize_parent_lifetime()\n"
|
|
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(300)'])\n"
|
|
"print(p.pid, int(pl._win_job_handle is not None), flush = True)\n"
|
|
"time.sleep(300)\n"
|
|
)
|
|
proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
|
|
try:
|
|
first = proc.stdout.readline().split()
|
|
child_pid, installed = int(first[0]), first[1] == "1"
|
|
assert installed, "Windows Job Object was not installed"
|
|
assert _alive(child_pid)
|
|
proc.kill() # TerminateProcess the parent -> last job handle closes
|
|
proc.wait(timeout = 5)
|
|
assert _wait_dead(child_pid, 5.0), "child orphaned after parent killed"
|
|
finally:
|
|
proc.kill()
|
|
|
|
|
|
# ── terminate_all backstop sweep ──
|
|
|
|
|
|
@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
|
|
def test_terminate_all_signals_tracked_and_is_idempotent():
|
|
p = subprocess.Popen(["sleep", "300"])
|
|
pl.adopt_pid(p.pid)
|
|
pl.terminate_all()
|
|
assert p.wait(timeout = 5) is not None # reap + confirm it died
|
|
pl.terminate_all() # registry now empty; must not raise
|
|
|
|
|
|
@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
|
|
def test_terminate_all_escalates_to_sigkill():
|
|
# A child that ignores SIGTERM must still be reaped via SIGKILL after timeout.
|
|
p = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(300)",
|
|
]
|
|
)
|
|
time.sleep(0.5) # let the handler install
|
|
pl.adopt_pid(p.pid)
|
|
pl.terminate_all(timeout = 0.3)
|
|
assert p.wait(timeout = 5) == -signal.SIGKILL # SIGTERM ignored, SIGKILL wins
|
|
|
|
|
|
@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
|
|
def test_terminate_all_lets_cooperative_child_exit_cleanly(tmp_path):
|
|
# A child that handles SIGTERM gets `timeout` to exit cleanly (not -SIGKILL).
|
|
marker = tmp_path / "clean.txt"
|
|
p = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import signal, sys, time\n"
|
|
f"def h(*a): open({str(marker)!r}, 'w').write('clean'); sys.exit(0)\n"
|
|
"signal.signal(signal.SIGTERM, h)\n"
|
|
"time.sleep(300)\n",
|
|
]
|
|
)
|
|
time.sleep(0.5)
|
|
pl.adopt_pid(p.pid)
|
|
pl.terminate_all(timeout = 3.0)
|
|
assert p.wait(timeout = 3) == 0 # exited via its own handler, not SIGKILL
|
|
assert marker.read_text() == "clean"
|
|
|
|
|
|
def test_forget_pid_unregisters():
|
|
pl.adopt_pid(4242)
|
|
assert 4242 in pl._tracked_pids
|
|
pl.forget_pid(4242)
|
|
assert 4242 not in pl._tracked_pids
|
|
|
|
|
|
@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
|
|
def test_terminate_all_skips_recycled_pid(monkeypatch):
|
|
# A tracked pid whose identity changed (recycled) must not be signalled.
|
|
p = subprocess.Popen(["sleep", "300"])
|
|
pl.adopt_pid(p.pid) # records the real identity
|
|
monkeypatch.setattr(pl, "_pid_identity", lambda _pid: "DIFFERENT")
|
|
pl.terminate_all()
|
|
assert _alive(p.pid) # left untouched: identity mismatch
|
|
p.kill()
|
|
p.wait(timeout = 5)
|
|
|
|
|
|
@pytest.mark.skipif(not IS_LINUX, reason = "PR_SET_PDEATHSIG is Linux-only")
|
|
def test_bind_kills_multiprocessing_child_on_parent_death(tmp_path):
|
|
# multiprocessing workers can't take a preexec_fn, so the child binds itself
|
|
# via bind_current_process_to_parent_lifetime(). Killing the parent must reap
|
|
# it (the gap reviewers found in adopt_pid alone).
|
|
mid = tmp_path / "mid_mp.py"
|
|
mid.write_text(
|
|
"import sys, time, multiprocessing as mp\n"
|
|
f"sys.path.insert(0, {str(_BACKEND)!r})\n"
|
|
"from utils.process_lifetime import bind_current_process_to_parent_lifetime\n"
|
|
"def _child():\n"
|
|
" bind_current_process_to_parent_lifetime()\n"
|
|
" time.sleep(300)\n"
|
|
"if __name__ == '__main__':\n"
|
|
" p = mp.get_context('spawn').Process(target = _child, daemon = True)\n"
|
|
" p.start()\n"
|
|
" print(p.pid, flush = True)\n"
|
|
" time.sleep(300)\n"
|
|
)
|
|
proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
|
|
try:
|
|
child_pid = int(proc.stdout.readline().strip())
|
|
assert _alive(child_pid)
|
|
proc.kill()
|
|
proc.wait(timeout = 5)
|
|
assert _wait_dead(child_pid, 5.0), "mp child orphaned after parent SIGKILL"
|
|
finally:
|
|
proc.kill()
|
|
|
|
|
|
# ── Windows Job Object path (mocked kernel32, runs on Linux CI) ──
|
|
|
|
|
|
class _Call:
|
|
def __init__(self, name, log, ret):
|
|
self.name, self.log, self.ret = name, log, ret
|
|
self.restype = self.argtypes = None
|
|
|
|
def __call__(self, *a, **k):
|
|
self.log.append(self.name)
|
|
return self.ret
|
|
|
|
|
|
class _FakeKernel32:
|
|
def __init__(
|
|
self,
|
|
log,
|
|
create_ret = 4321,
|
|
set_ret = 1,
|
|
assign_ret = 1,
|
|
):
|
|
self.CreateJobObjectW = _Call("create", log, create_ret)
|
|
self.SetInformationJobObject = _Call("set", log, set_ret)
|
|
self.AssignProcessToJobObject = _Call("assign", log, assign_ret)
|
|
self.GetCurrentProcess = _Call("getcur", log, -1)
|
|
self.CloseHandle = _Call("close", log, 1)
|
|
|
|
|
|
def _patch_windows(monkeypatch, fake):
|
|
import ctypes
|
|
monkeypatch.setattr(pl, "_is_windows", lambda: True)
|
|
monkeypatch.setattr(ctypes, "WinDLL", lambda *a, **k: fake, raising = False)
|
|
|
|
|
|
def test_windows_job_install_order(monkeypatch):
|
|
log: list[str] = []
|
|
_patch_windows(monkeypatch, _FakeKernel32(log))
|
|
pl._install_windows_job()
|
|
assert log.index("create") < log.index("set") < log.index("assign")
|
|
assert pl._win_job_handle == 4321 # handle retained
|
|
|
|
|
|
def test_windows_job_install_degrades_on_create_failure(monkeypatch):
|
|
log: list[str] = []
|
|
_patch_windows(monkeypatch, _FakeKernel32(log, create_ret = 0))
|
|
pl._install_windows_job() # must not raise
|
|
assert pl._win_job_handle is None
|
|
assert "set" not in log # short-circuited after the failed create
|
|
|
|
|
|
def test_windows_job_install_degrades_on_assign_failure(monkeypatch):
|
|
log: list[str] = []
|
|
_patch_windows(monkeypatch, _FakeKernel32(log, assign_ret = 0))
|
|
pl._install_windows_job()
|
|
assert pl._win_job_handle is None # not retained when assignment fails
|
|
assert "close" in log # the orphaned job handle is closed
|