unsloth/tests/test_python39_compatibility.py
oobabooga abfa6c781d
Studio: prune dead startup records and preserve process identity (#14301)
Studio leaves startup markers and PID files behind after a backend
crashes or is killed. This PR cleans up records for dead backends on
later startups, removing existing leftovers and preventing buildup
across restarts.

It also fixes the clock-sensitive identity check that can make `unsloth
studio stop` report success while leaving the server running.

## What changes

**1. Remove records left behind by crashes**

A killed backend cannot delete its startup marker and PID file. A later
startup now removes those leftovers after confirming that the PID is
gone. On this machine, the Studio directory already contains **446
`.marker` files and 42 `.pid` files**.

Cleanup holds the existing cache lock and rechecks the file and PID
before deleting. Live or uncertain records are preserved, and cleanup is
skipped if the lock cannot be acquired. Sibling discovery remains
read-only.

**2. Keep track of live backends across clock adjustments**

On Linux, psutil's process creation timestamp depends on the system's
reported boot time. When that changes, even a one-second discrepancy
makes the old Python check reject a live backend. This can break
stopping, discovery and duplicate-launch detection, and let another
backend clear the shared compiled cache while the first is still
running.

New Linux records include the **boot ID and raw process start ticks**,
which survive clock adjustments. The backend, CLI and desktop use the
same identity. The existing fields and bare legacy `studio.pid` remain
compatible with older versions; timestamp-only records use an inclusive
one-second tolerance.

When a live PID's recorded identity does not match, `stop` now preserves
its files and reports an error.

## Validation

**Native process A/B on Linux, macOS and
[Windows](https://github.com/oobabooga/unsloth/actions/runs/39339775585):**
eight killed workers leave **16 stale files before, zero after**, while
preserving the live sibling's records. Concurrent startup sweeps also
preserve every live record and remove every dead one. These tests use
the production record writers and CLI with a small HTTP server.

**Full Studio A/B on Linux:**

| Scenario | Before | After |
|---|---|---|
| SIGKILL, then restart | Both old records remain | Both removed;
replacement starts normally |
| Stop after a one-second timestamp shift | Reports success; server
keeps answering | Server stops and its records disappear |
| Launch again on an occupied port after a clock shift | Starts another
server on a fallback port | Refuses the duplicate |
| Start a sibling after a clock shift | Deletes a shared-cache sentinel
while the first server is healthy | Preserves the sentinel |
| Discover and stop both backends after a clock shift | Misses and
leaves the first backend running | Finds and stops both |

Clock offsets were injected into psutil without changing the host clock.
The last three cases use a one-hour offset and remove the optional
legacy pointer, so detection depends on the per-port records and
markers.

**Regression checks:**

- Linux: 359 Python and 70 focused Rust tests passed. Seven behavioral
cases fail on main and pass with this change.
- The focused Python and Rust suites also passed natively on macOS and
Windows.
- Python 3.9: 66 compatibility, helper and CLI tests passed, plus direct
imports and real CLI help and stop commands against an empty Studio
home.
- Ruff, formatting and diff checks passed.

The desktop UI and model training were not exercised.

---------

Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
2026-10-20 09:37:42 -07:00

420 lines
16 KiB
Python

"""Package must import on the requires-python floor; a dataclass annotation with | broke 3.9."""
import ast
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_ROOT = REPO_ROOT / "unsloth"
CLI_ROOT = REPO_ROOT / "unsloth_cli"
# studio/ union debt is ratcheted: only shrink this set; fix new files with the future import.
STUDIO_UNION_DEBT_FILES = frozenset(
{
"studio/backend/auth/hashing.py",
"studio/backend/core/inference/external_provider.py",
"studio/backend/core/inference/key_exchange.py",
"studio/backend/core/inference/providers.py",
"studio/backend/core/inference/tools.py",
"studio/backend/core/training/trainer.py",
"studio/backend/hub/tests/test_model_services.py",
"studio/backend/main.py",
"studio/backend/routes/auth.py",
"studio/backend/routes/inference.py",
"studio/backend/routes/mcp_servers.py",
"studio/backend/routes/models.py",
"studio/backend/routes/providers.py",
"studio/backend/routes/settings.py",
"studio/backend/storage/rag_db.py",
"studio/backend/storage/studio_db.py",
"studio/backend/tests/test_anthropic_citations_edge.py",
"studio/backend/tests/test_cached_gguf_routes.py",
"studio/backend/tests/test_chat_attachments.py",
"studio/backend/tests/test_chat_history_storage.py",
"studio/backend/tests/test_export_absolute_paths.py",
"studio/backend/tests/test_index_bootstrap_origin.py",
"studio/backend/tests/test_kv_cache_estimation.py",
"studio/backend/tests/test_middleware.py",
"studio/backend/tests/test_openai_citation_markers.py",
"studio/backend/tests/test_openai_citation_markers_edge.py",
"studio/backend/tests/test_setup_llama_cpp_backend.py",
"studio/backend/tests/test_training_history_update.py",
"studio/backend/tests/test_transformers_version.py",
"studio/backend/utils/datasets/dataset_utils.py",
"studio/backend/utils/datasets/format_conversion.py",
"studio/backend/utils/datasets/format_detection.py",
"studio/backend/utils/models/model_config.py",
"studio/backend/utils/transformers_latest.py",
"studio/backend/utils/transformers_version.py",
}
)
def declared_floor():
"""The ``>=X.Y`` in requires-python, as a tuple for ast.parse(feature_version=)."""
text = (REPO_ROOT / "pyproject.toml").read_text(encoding = "utf-8")
match = re.search(r"^requires-python\s*=\s*[\"']([^\"']+)[\"']", text, re.MULTILINE)
assert match, "no requires-python in pyproject.toml"
floor = re.search(r">=\s*(\d+)\.(\d+)", match.group(1))
assert floor, f"no >= lower bound in requires-python = {match.group(1)!r}"
return int(floor.group(1)), int(floor.group(2))
def guarded_floor(init_path):
"""Reads the floor from an 'if sys.version_info < (x, y): raise ImportError' guard; None if absent."""
try:
tree = ast.parse(init_path.read_text(encoding = "utf-8"), filename = str(init_path))
except (OSError, SyntaxError):
return None
for node in tree.body:
if not isinstance(node, ast.If):
continue
test = node.test
if not (
isinstance(test, ast.Compare)
and len(test.ops) == 1
and isinstance(test.ops[0], ast.Lt)
and "version_info" in ast.dump(test.left)
and isinstance(test.comparators[0], ast.Tuple)
):
continue
if not any(isinstance(stmt, ast.Raise) for stmt in node.body):
continue
parts = [e.value for e in test.comparators[0].elts if isinstance(e, ast.Constant)]
if len(parts) >= 2 and all(isinstance(v, int) for v in parts[:2]):
return (parts[0], parts[1])
return None
def floor_guarded_dirs(root):
"""Skip packages whose __init__ guards a higher floor, keyed on the guard, not the vendor path."""
guarded = []
for init in root.rglob("__init__.py"):
if "__pycache__" in init.parts:
continue
floor = guarded_floor(init)
if floor and floor > declared_floor():
guarded.append(init.parent)
return guarded
def package_files(root = PACKAGE_ROOT, minimum = 50):
skip = floor_guarded_dirs(root)
files = sorted(
p
for p in root.rglob("*.py")
if "__pycache__" not in p.parts
and "node_modules" not in p.parts
and not any(d in p.parents for d in skip)
)
assert len(files) >= minimum, f"only found {len(files)} files under {root}, glob is wrong"
return files
def packaged_roots():
"""Read packaged roots from pyproject's include list, so no new package escapes the floor check."""
text = (REPO_ROOT / "pyproject.toml").read_text(encoding = "utf-8")
block = re.search(r"^include\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
assert block, "no packages.find include list in pyproject.toml"
roots = []
for pattern in re.findall(r"[\"']([^\"']+)[\"']", block.group(1)):
top = pattern.split(".")[0].rstrip("*")
candidate = REPO_ROOT / top
if candidate.is_dir() and candidate not in roots:
roots.append(candidate)
assert roots, "no packaged directories resolved from pyproject"
return roots
def evaluated_union_files(root):
"""Files under `root` that would raise on the floor, i.e. need the future import."""
offenders = set()
for path in package_files(root, minimum = 1):
tree = ast.parse(path.read_text(encoding = "utf-8"), filename = str(path))
if has_future_annotations(tree):
continue
for expression in evaluated_annotations(tree):
for inner in ast.walk(expression):
if isinstance(inner, ast.BinOp) and isinstance(inner.op, ast.BitOr):
offenders.add(path)
return offenders
def signature_annotations(node):
"""Parameter and return annotations, evaluated when the ``def`` executes."""
args = node.args
out = [
a.annotation
for a in [*args.args, *args.kwonlyargs, *args.posonlyargs, args.vararg, args.kwarg]
if a is not None and a.annotation is not None
]
if node.returns:
out.append(node.returns)
return out
def evaluated_annotations(tree):
"""Variable annotations inside a function body are never evaluated, so they must not be flagged."""
out = []
def walk(node, in_function):
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.AnnAssign) and child.annotation and not in_function:
out.append(child.annotation)
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.extend(signature_annotations(child))
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
walk(child, True)
elif isinstance(child, ast.ClassDef):
walk(child, False)
else:
walk(child, in_function)
walk(tree, False)
return out
# A `|` between these is a union, not arithmetic; None and typing imports are handled below.
TYPE_ANCHORS = frozenset(
{
"str",
"int",
"float",
"bool",
"bytes",
"bytearray",
"complex",
"object",
"type",
"list",
"dict",
"tuple",
"set",
"frozenset",
}
)
TYPING_MODULES = frozenset({"typing", "typing_extensions", "collections.abc"})
def typing_names(tree):
"""Names this module bound with ``from typing import X`` and friends."""
return {
alias.asname or alias.name
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module in TYPING_MODULES
for alias in node.names
}
def union_operands(node):
"""Operands of an ``A | B | C`` chain, or None if any part is not name-shaped."""
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
left, right = union_operands(node.left), union_operands(node.right)
return None if left is None or right is None else left + right
if isinstance(node, ast.Subscript):
return union_operands(node.value)
if isinstance(node, (ast.Name, ast.Attribute)):
return [node]
if isinstance(node, ast.Constant) and (node.value is None or isinstance(node.value, str)):
return [node]
return None
def looks_like_a_type_alias(node, known_typing_names):
"""A bare | between names is often a dict merge or flag union, so require a recognisable type
operand."""
operands = union_operands(node)
if not operands:
return False
for operand in operands:
if isinstance(operand, ast.Constant):
if operand.value is None:
return True
continue
name = operand.id if isinstance(operand, ast.Name) else operand.attr
if name in TYPE_ANCHORS or name in known_typing_names:
return True
return False
def evaluated_values(tree):
"""Type aliases run at import and the future import does not defer them, so they are checked."""
out = []
scoped = (
ast.If,
ast.Try,
ast.With,
ast.AsyncWith,
ast.For,
ast.AsyncFor,
ast.While,
ast.ExceptHandler,
ast.ClassDef,
)
def walk(node):
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.extend(child.decorator_list)
out.extend(d for d in child.args.defaults if d is not None)
out.extend(d for d in child.args.kw_defaults if d is not None)
continue
if isinstance(child, ast.ClassDef):
out.extend(child.decorator_list)
if isinstance(child, (ast.Assign, ast.AnnAssign)) and child.value is not None:
out.append(child.value)
if isinstance(child, scoped):
walk(child)
walk(tree)
return out
def has_future_annotations(tree):
return any(
isinstance(n, ast.ImportFrom)
and n.module == "__future__"
and any(alias.name == "annotations" for alias in n.names)
for n in tree.body
)
def test_every_packaged_module_parses_on_the_declared_floor():
"""Every directory pyproject ships, not just unsloth/ - studio/ is packaged too."""
floor = declared_floor()
broken = []
for root in packaged_roots():
for path in package_files(root, minimum = 1):
try:
ast.parse(
path.read_text(encoding = "utf-8"),
filename = str(path),
feature_version = floor,
)
except SyntaxError as error:
broken.append(f"{path.relative_to(REPO_ROOT)}:{error.lineno}: {error.msg}")
assert not broken, (
"syntax newer than the declared floor "
f"{floor[0]}.{floor[1]} in a packaged directory; either rewrite it or raise "
"requires-python:\n " + "\n ".join(broken)
)
def test_every_packaged_module_compiles():
"""ast.parse accepts a misplaced __future__ import that compile rejects, so compile every module."""
broken = []
for root in packaged_roots():
for path in package_files(root, minimum = 1):
try:
compile(path.read_text(encoding = "utf-8"), str(path), "exec", dont_inherit = True)
except SyntaxError as error:
broken.append(f"{path.relative_to(REPO_ROOT)}:{error.lineno}: {error.msg}")
assert not broken, "these modules do not compile:\n " + "\n ".join(broken)
def test_studio_evaluated_unions_do_not_grow():
"""Ratchet: studio/ still has unions that raise on the floor, and FastAPI resolves them per endpoint."""
if declared_floor() >= (3, 10):
pytest.skip("floor is 3.10+, PEP 604 evaluates fine")
studio = REPO_ROOT / "studio"
if not studio.is_dir():
pytest.skip("no studio/ directory in this checkout")
offenders = sorted(str(p.relative_to(REPO_ROOT)) for p in evaluated_union_files(studio))
# Check membership and count: a swap keeps the count equal; net growth may add no new file.
added = [p for p in offenders if p not in STUDIO_UNION_DEBT_FILES]
removed = [p for p in sorted(STUDIO_UNION_DEBT_FILES) if p not in offenders]
assert not added and len(offenders) <= len(STUDIO_UNION_DEBT_FILES), (
f"{len(offenders)} studio files now evaluate PEP 604 unions on the floor, against "
f"{len(STUDIO_UNION_DEBT_FILES)} recorded. Add `from __future__ import annotations` to "
"these, do not raise the ratchet:\n "
+ "\n ".join(added or offenders)
+ (
"\n\nalso no longer offending (drop them from STUDIO_UNION_DEBT_FILES):\n "
+ "\n ".join(removed)
if removed
else ""
)
)
def test_no_pep604_unions_are_evaluated_on_the_declared_floor():
"""``X | Y`` is a TypeError below 3.10 unless the module defers it."""
if declared_floor() >= (3, 10):
pytest.skip("floor is 3.10+, PEP 604 evaluates fine")
offenders = []
scanned = [p for root in packaged_roots() for p in package_files(root, minimum = 1)]
for path in scanned:
tree = ast.parse(path.read_text(encoding = "utf-8"), filename = str(path))
where = path.relative_to(REPO_ROOT)
known_typing_names = typing_names(tree)
# The future import defers annotations only; an assigned value still runs.
deferred = has_future_annotations(tree)
in_floor_package = PACKAGE_ROOT in path.parents or CLI_ROOT in path.parents
annotation_sources = (
[] if (deferred or not in_floor_package) else evaluated_annotations(tree)
)
for expression in annotation_sources:
for inner in ast.walk(expression):
if isinstance(inner, ast.BinOp) and isinstance(inner.op, ast.BitOr):
offenders.append(f"{where}:{inner.lineno}: {ast.unparse(inner)} (annotation)")
for expression in evaluated_values(tree):
for inner in ast.walk(expression):
if (
isinstance(inner, ast.BinOp)
and isinstance(inner.op, ast.BitOr)
and looks_like_a_type_alias(inner, known_typing_names)
):
offenders.append(f"{where}:{inner.lineno}: {ast.unparse(inner)} (type alias)")
assert not offenders, (
"PEP 604 unions evaluated below 3.10. Annotations are deferred by `from __future__ "
"import annotations`; type aliases need typing.Union, which that import does NOT "
"defer:\n " + "\n ".join(sorted(set(offenders)))
)
def test_the_truststore_guard_is_what_exempts_it():
"""Not the path. If upstream drops the version guard, the files come back
into the scan and this gate goes red again -- which is correct, because at
that point `import truststore` really can reach `_api.py` on 3.9."""
init = REPO_ROOT / "studio/backend/vendor/truststore/__init__.py"
if not init.exists():
pytest.skip("truststore is not vendored in this checkout")
assert guarded_floor(init) == (3, 10)
assert init.parent in floor_guarded_dirs(REPO_ROOT / "studio")
def test_unguarded_code_in_the_same_vendor_directory_is_still_scanned(tmp_path):
vendor = tmp_path / "vendor"
(vendor / "guarded").mkdir(parents = True)
(vendor / "plain").mkdir()
(vendor / "guarded" / "__init__.py").write_text(
"import sys\nif sys.version_info < (3, 10):\n raise ImportError('nope')\n"
)
(vendor / "plain" / "__init__.py").write_text("")
skipped = floor_guarded_dirs(tmp_path)
assert vendor / "guarded" in skipped
assert vendor / "plain" not in skipped
def test_a_guard_at_or_below_our_floor_does_not_exempt(tmp_path):
"""A package that merely requires 3.9 is a package we ship on. Exempting it
would hide real breakage."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text(
"import sys\nif sys.version_info < (3, 9):\n raise ImportError('nope')\n"
)
assert floor_guarded_dirs(tmp_path) == []
def test_a_guard_that_does_not_raise_does_not_exempt(tmp_path):
"""`if sys.version_info < (3, 10): warnings.warn(...)` still imports."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text(
"import sys, warnings\nif sys.version_info < (3, 10):\n warnings.warn('old')\n"
)
assert floor_guarded_dirs(tmp_path) == []