Once a model overflowed VRAM and Studio later died in a way that bypasses the graceful path (SIGHUP from a closed terminal, SIGKILL/OOM, or a direct-uvicorn launch), the llama-server child was orphaned and kept holding GPU memory. GPU placement is recomputed every load from a live free-VRAM probe, so the leftover process made every subsequent load (even a tiny model, even after a restart) spill to system RAM until it was killed by hand. The main llama-server spawn now records its PID to a pidfile under the active studio root, removed on _kill_process. The startup reaper kills that exact PID first (path-independent, so it catches an orphan the install-root match misses), verifying it is still a llama-server to guard against PID reuse; the pidfile only ever names a Studio-spawned server, so unrelated user processes (vllm, games) are never touched. The existing root-gated enumeration stays as a fallback. A belt-and-suspenders kill is also wired into the FastAPI lifespan shutdown (before hardware/cache teardown) to cover the direct-uvicorn path that run.py's signal handler does not. PR_SET_PDEATHSIG is intentionally not used on the main spawn: llama-server is launched on a pooled asyncio.to_thread worker, and a thread-scoped death signal could prematurely kill a healthy server. The reaper runs before any model loads on the next start, so it fully covers the user-visible problem. Adds tests for the pidfile reap (kills a recorded live server, skips a reused non-llama PID, cleans a stale/missing pidfile, clears on kill) and for the lifespan kill (runs first, errors swallowed).
171 lines
5.4 KiB
Python
171 lines
5.4 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
|
|
|
|
"""Regression tests for run_lifespan_shutdown: a dead default executor (the
|
|
abrupt-shutdown teardown race) must not abort the remaining cleanup. The helper
|
|
is dependency-injected, so these need only structlog."""
|
|
|
|
import asyncio
|
|
import contextvars
|
|
import types
|
|
|
|
from utils.lifespan_shutdown import run_lifespan_shutdown
|
|
|
|
|
|
def _counter():
|
|
box = {"n": 0}
|
|
|
|
def _fn():
|
|
box["n"] += 1
|
|
|
|
return box, _fn
|
|
|
|
|
|
def test_run_lifespan_shutdown_survives_dead_default_executor():
|
|
term_box, terminate = _counter()
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
async def _drive():
|
|
loop = asyncio.get_running_loop()
|
|
# Kill the default executor to mimic the teardown race.
|
|
await asyncio.to_thread(lambda: None)
|
|
loop._default_executor.shutdown(wait = True)
|
|
await run_lifespan_shutdown(terminate, clear, hw)
|
|
|
|
asyncio.run(_drive())
|
|
|
|
assert term_box["n"] == 1, "terminate must run via inline fallback"
|
|
assert clear_box["n"] == 1, "clear must still run after the to_thread failure"
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_survives_shutdown_default_executor():
|
|
"""Production path: loop.shutdown_default_executor() makes run_in_executor raise
|
|
'Executor shutdown has been called'; the helper must still recover inline."""
|
|
term_box, terminate = _counter()
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
async def _drive():
|
|
await asyncio.get_running_loop().shutdown_default_executor()
|
|
await run_lifespan_shutdown(terminate, clear, hw)
|
|
|
|
asyncio.run(_drive())
|
|
|
|
assert term_box["n"] == 1, "terminate must run via inline fallback"
|
|
assert clear_box["n"] == 1
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_normal_path():
|
|
"""Healthy executor: each step runs exactly once."""
|
|
term_box, terminate = _counter()
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
asyncio.run(run_lifespan_shutdown(terminate, clear, hw))
|
|
|
|
assert term_box["n"] == 1
|
|
assert clear_box["n"] == 1
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_swallows_terminate_errors():
|
|
"""A terminate failure must not block later cleanup."""
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
def _boom():
|
|
raise ValueError("boom")
|
|
|
|
asyncio.run(run_lifespan_shutdown(_boom, clear, hw))
|
|
|
|
assert clear_box["n"] == 1, "later cleanup must run even when terminate raises"
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_swallows_clear_errors():
|
|
"""A clear failure must not raise out of shutdown."""
|
|
term_box, terminate = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
def _boom():
|
|
raise ValueError("boom")
|
|
|
|
asyncio.run(run_lifespan_shutdown(terminate, _boom, hw))
|
|
|
|
assert term_box["n"] == 1
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_does_not_retry_body_runtime_error():
|
|
"""A body-side RuntimeError (healthy executor) must run terminate once, not retry inline."""
|
|
term_box, _ = _counter()
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
def _boom():
|
|
term_box["n"] += 1
|
|
raise RuntimeError("body failed")
|
|
|
|
asyncio.run(run_lifespan_shutdown(_boom, clear, hw))
|
|
|
|
assert term_box["n"] == 1, "body RuntimeError must not be retried inline"
|
|
assert clear_box["n"] == 1, "later cleanup must still run"
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_preserves_contextvars():
|
|
"""terminate runs in a copy of the caller's context (parity with asyncio.to_thread)."""
|
|
cv = contextvars.ContextVar("unsloth_test_cv")
|
|
cv.set("bound-value")
|
|
seen = []
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
def terminate():
|
|
seen.append(cv.get("UNSET"))
|
|
|
|
asyncio.run(run_lifespan_shutdown(terminate, clear, hw))
|
|
|
|
assert seen == ["bound-value"], "terminate must run with the caller's contextvars"
|
|
assert clear_box["n"] == 1
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_kills_llama_server_first():
|
|
"""The injected kill_llama_server runs once, before hardware/cache teardown,
|
|
so a direct-uvicorn shutdown cannot orphan the GPU child."""
|
|
order = []
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
asyncio.run(
|
|
run_lifespan_shutdown(
|
|
lambda: order.append("terminate"),
|
|
lambda: order.append("clear"),
|
|
hw,
|
|
kill_llama_server = lambda: order.append("kill"),
|
|
)
|
|
)
|
|
|
|
assert order and order[0] == "kill", "llama-server must be killed before other teardown"
|
|
assert order.count("kill") == 1
|
|
assert "terminate" in order and "clear" in order
|
|
assert hw.DEVICE is None
|
|
|
|
|
|
def test_run_lifespan_shutdown_swallows_kill_llama_server_errors():
|
|
"""A kill_llama_server failure must not block the remaining cleanup."""
|
|
term_box, terminate = _counter()
|
|
clear_box, clear = _counter()
|
|
hw = types.SimpleNamespace(DEVICE = "cuda:0")
|
|
|
|
def _boom():
|
|
raise RuntimeError("kill failed")
|
|
|
|
asyncio.run(run_lifespan_shutdown(terminate, clear, hw, kill_llama_server = _boom))
|
|
|
|
assert term_box["n"] == 1
|
|
assert clear_box["n"] == 1
|
|
assert hw.DEVICE is None
|