Files
unshackle/tests/core/test_services_reload.py
T
imSp4rky bd64f3a8c4 fix(api): stop a service's sys.exit from killing the serve process
Catch SystemExit at service import, in the heartbeat task, the 5xx middleware, search, samples and chapters, and answer it as SERVICE_ERROR.
2026-10-29 11:23:53 -06:00

132 lines
5.3 KiB
Python

"""Pins hot-reload of services: re-import on change, drop on removal, and staged swaps for busy services."""
from __future__ import annotations
import os
import shutil
import time
from pathlib import Path
import pytest
from unshackle.core import services
from unshackle.core.config import config
pytestmark = pytest.mark.unit
def write_service(root: Path, version: int) -> None:
pkg = root / "FOO"
pkg.mkdir(exist_ok=True)
init = pkg / "__init__.py"
init.write_text(f"class FOO:\n VERSION = {version}\n ALIASES = ('foo{version}',)\n")
# the bytecode cache keys on mtime and size; same-second rewrites of equal length would reuse the stale .pyc
os.utime(init, (time.time() + version, time.time() + version))
@pytest.fixture
def service_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
root = tmp_path / "svc"
root.mkdir()
write_service(root, 1)
monkeypatch.setattr(config.directories, "services", [str(root)])
monkeypatch.setattr(services, "SERVICES", [])
monkeypatch.setattr(services, "MODULES", {})
monkeypatch.setattr(services, "ALIASES", {})
monkeypatch.setattr(services, "LOAD_ERRORS", [])
monkeypatch.setattr(services, "PENDING", set())
assert services.reload_services(["FOO"]) == []
assert services.MODULES["FOO"].VERSION == 1
yield root
services.reload_services(["FOO"])
def test_reload_picks_up_new_code_and_removal(service_dir: Path):
write_service(service_dir, 2)
assert services.reload_services(["FOO"]) == []
assert services.MODULES["FOO"].VERSION == 2
assert services.ALIASES["FOO"] == ("foo2",)
assert services.Services.get_tag("foo2") == "FOO"
shutil.rmtree(service_dir / "FOO")
assert services.reload_services(["FOO"]) == []
assert "FOO" not in services.MODULES
assert "FOO" not in services.Services.get_tags()
def test_busy_service_is_staged_then_applied(service_dir: Path, monkeypatch: pytest.MonkeyPatch):
write_service(service_dir, 2)
monkeypatch.setattr(services, "repo_specs", lambda: ["example/repo"])
monkeypatch.setattr(services, "refresh_repo", lambda spec: (service_dir, ["~FOO"]))
repos = services.refresh_and_reload(busy={"FOO"})
assert repos == [
{"spec": "example/repo", "updated": True, "changes": ["~FOO"], "deferred": ["FOO"], "load_errors": []}
]
assert services.MODULES["FOO"].VERSION == 1
assert "FOO" in services.PENDING
assert services.apply_pending(busy={"FOO"}) == []
assert services.MODULES["FOO"].VERSION == 1
assert services.apply_pending(busy=set()) == ["FOO"]
assert services.MODULES["FOO"].VERSION == 2
assert services.PENDING == set()
def write_broken_service(root: Path, version: int) -> None:
init = root / "FOO" / "__init__.py"
init.write_text(f"raise ImportError('boom {version}')\n")
os.utime(init, (time.time() + version, time.time() + version))
def test_apply_pending_never_reports_a_failed_reimport_as_applied(
service_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failed re-import keeps the old module serving, so the tag is not applied."""
monkeypatch.setattr(services, "PENDING_SINCE", {})
monkeypatch.setattr(services, "LOADED_COMMITS", {"FOO": "old-head"})
monkeypatch.setattr(services, "head", lambda dest: "new-head")
(service_dir / ".git").mkdir()
write_broken_service(service_dir, 2)
monkeypatch.setattr(services, "repo_specs", lambda: ["example/repo"])
monkeypatch.setattr(services, "refresh_repo", lambda spec: (service_dir, ["~FOO"]))
services.refresh_and_reload(busy={"FOO"})
assert services.PENDING == {"FOO"}
assert services.apply_pending(busy=set()) == []
assert services.MODULES["FOO"].VERSION == 1
assert services.LOAD_ERRORS and services.LOAD_ERRORS[0].startswith("FOO: ")
# The commit must still name the code that is running, not the one that failed to import.
assert services.LOADED_COMMITS["FOO"] == "old-head"
write_service(service_dir, 3)
services.PENDING.add("FOO")
assert services.apply_pending(busy=set()) == ["FOO"]
assert services.LOADED_COMMITS["FOO"] == "new-head"
def test_overdue_staged_service_applies_while_busy(service_dir: Path, monkeypatch: pytest.MonkeyPatch):
write_service(service_dir, 2)
monkeypatch.setattr(services, "repo_specs", lambda: ["example/repo"])
monkeypatch.setattr(services, "refresh_repo", lambda spec: (service_dir, ["~FOO"]))
monkeypatch.setattr(services, "staged_max_age", lambda: 60.0)
services.refresh_and_reload(busy={"FOO"})
assert services.apply_pending(busy={"FOO"}) == []
assert services.MODULES["FOO"].VERSION == 1
services.PENDING_SINCE["FOO"] -= 61
assert services.apply_pending(busy={"FOO"}) == ["FOO"]
assert services.MODULES["FOO"].VERSION == 2
assert services.PENDING == set() and "FOO" not in services.PENDING_SINCE
def test_service_that_exits_on_import_fails_its_reload_only(service_dir: Path):
"""A service calling sys.exit at import time is a load error, not a stop of the process."""
(service_dir / "FOO" / "__init__.py").write_text("import sys\nsys.exit(1)\n")
os.utime(service_dir / "FOO" / "__init__.py", (time.time() + 9, time.time() + 9))
services.reload_services(["FOO"])
assert services.LOAD_ERRORS and services.LOAD_ERRORS[-1].startswith("FOO: failed to import - SystemExit")