feat: supervise stelloauth and cloakbrowser

This commit is contained in:
Dennis Juhler Aagaard
2026-09-24 17:24:14 +02:00
parent 8a7e9895c8
commit 9410d83053
2 changed files with 1074 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
from __future__ import annotations
import dataclasses
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Callable, NamedTuple
OPTIONS_PATH = Path("/data/options.json")
PROFILE_PATH = Path("/tmp/cloakserve")
CLOAK_ROOT = "http://127.0.0.1:9222/"
CLOAK_VERSION = "http://127.0.0.1:9222/json/version?fingerprint=addon-readiness"
CLOAK_CLOSE = "http://127.0.0.1:9222/fingerprint/addon-readiness/close"
STELLOAUTH_ROOT = "http://127.0.0.1:8080/"
DURATION = re.compile(r"^[1-9][0-9]*(?:ms|s|m|h)$")
CLOAK_COMMAND = [
"/usr/local/bin/cloakserve",
"--headless=true",
"--idle-timeout=30",
"--data-dir=/tmp/cloakserve",
]
STELLOAUTH_COMMAND = ["/usr/local/bin/stelloauth"]
class ConfigError(RuntimeError):
pass
@dataclasses.dataclass(frozen=True)
class Options:
queue_timeout: str
rate_limit_count: int
rate_limit_duration: str
class HttpResponse(NamedTuple):
status: int
body: bytes
class ReadinessError(RuntimeError):
pass
def load_options(path: Path) -> Options:
try:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or set(value) != {
"queue_timeout",
"rate_limit_count",
"rate_limit_duration",
}:
raise ValueError
queue_timeout = value["queue_timeout"]
rate_limit_count = value["rate_limit_count"]
rate_limit_duration = value["rate_limit_duration"]
if not isinstance(queue_timeout, str) or DURATION.fullmatch(queue_timeout) is None:
raise ValueError
if (
isinstance(rate_limit_count, bool)
or not isinstance(rate_limit_count, int)
or not 1 <= rate_limit_count <= 20
):
raise ValueError
if (
not isinstance(rate_limit_duration, str)
or DURATION.fullmatch(rate_limit_duration) is None
):
raise ValueError
return Options(queue_timeout, rate_limit_count, rate_limit_duration)
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError):
raise ConfigError("Invalid add-on configuration") from None
def build_environment(options: Options) -> dict[str, str]:
environment = os.environ.copy()
environment.update(
{
"CLOAK_CDP_URL": "http://127.0.0.1:9222",
"CLOAK_MAX_SESSIONS": "1",
"CLOAK_QUEUE_TIMEOUT": options.queue_timeout,
"RATE_LIMIT_COUNT": str(options.rate_limit_count),
"RATE_LIMIT_DURATION": options.rate_limit_duration,
"HTTP_ADDRESS": "0.0.0.0",
"PORT": "8080",
"METRICS_ADDRESS": "127.0.0.1",
"METRICS_PORT": "9090",
}
)
return environment
def http_request(method: str, url: str, timeout: float) -> HttpResponse:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
data = b"" if method == "POST" else None
request = urllib.request.Request(url, data=data, method=method)
with opener.open(request, timeout=timeout) as response:
return HttpResponse(response.status, response.read())
def probe_cloak(
request: Callable[[str, str, float], HttpResponse] = http_request,
) -> None:
if request("GET", CLOAK_ROOT, 2.0).status != 200:
raise ReadinessError("CloakBrowser root probe failed")
version = request("GET", CLOAK_VERSION, 2.0)
if version.status != 200:
raise ReadinessError("CloakBrowser CDP probe failed")
try:
document = json.loads(version.body)
if not isinstance(document, dict):
raise ValueError
websocket_url = document["webSocketDebuggerUrl"]
if (
not isinstance(websocket_url, str)
or not websocket_url.startswith("ws://127.0.0.1:")
):
raise ValueError
parsed = urllib.parse.urlsplit(websocket_url)
if (
parsed.scheme != "ws"
or parsed.hostname != "127.0.0.1"
or parsed.username is not None
or parsed.password is not None
or parsed.port is None
):
raise ValueError
except (json.JSONDecodeError, KeyError, TypeError, UnicodeError, ValueError):
raise ReadinessError("CloakBrowser CDP response invalid") from None
if request("POST", CLOAK_CLOSE, 2.0).status != 200:
raise ReadinessError("CloakBrowser readiness profile close failed")
def probe_stelloauth(
request: Callable[[str, str, float], HttpResponse] = http_request,
) -> None:
if request("GET", STELLOAUTH_ROOT, 2.0).status != 200:
raise ReadinessError("Stelloauth root probe failed")
def wait_until_ready(
name: str,
probe: Callable[[], None],
timeout: float,
stopping: Callable[[], bool],
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
deadline = monotonic() + timeout
delay = 0.25
while monotonic() < deadline and not stopping():
try:
probe()
return
except (OSError, ValueError, ReadinessError):
sleep(delay)
delay = min(delay * 2, 2.0)
raise ReadinessError(f"{name} did not become ready")
class ProcessManager:
def __init__(
self,
environment: dict[str, str],
*,
popen: Callable[..., subprocess.Popen] = subprocess.Popen,
killpg: Callable[[int, int], None] = os.killpg,
cloak_probe: Callable[[], None] = probe_cloak,
stelloauth_probe: Callable[[], None] = probe_stelloauth,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
profile_path: Path = PROFILE_PATH,
) -> None:
self.environment = environment
self._popen = popen
self._killpg = killpg
self._cloak_probe = cloak_probe
self._stelloauth_probe = stelloauth_probe
self._monotonic = monotonic
self._sleep = sleep
self._profile_path = profile_path
self._children: list[tuple[str, subprocess.Popen]] = []
self._stopping = False
self._term_sent = False
self._shutdown_deadline: float | None = None
def _handle_signal(self, _signum: int, _frame: object) -> None:
if self._stopping:
return
logging.info("Shutdown requested")
self._stopping = True
self._begin_shutdown()
def _clean_profiles(self) -> None:
logging.info("Cleaning CloakBrowser profiles")
if self._profile_path.is_symlink():
self._profile_path.unlink()
elif self._profile_path.exists():
shutil.rmtree(self._profile_path)
self._profile_path.mkdir(parents=True, mode=0o700)
self._profile_path.chmod(0o700)
def _spawn(self, name: str, command: list[str]) -> subprocess.Popen:
process = self._popen(
command,
env=self.environment,
start_new_session=True,
)
self._children.append((name, process))
if self._stopping:
try:
self._killpg(process.pid, signal.SIGTERM)
except OSError:
pass
return process
def _first_exited_child(self) -> tuple[str, int] | None:
for name, process in self._children:
returncode = process.poll()
if returncode is not None:
return name, returncode
return None
def _startup_stopping(self) -> bool:
return self._stopping or self._first_exited_child() is not None
def _signal_running(self, sent_signal: int) -> None:
for _name, process in self._children:
if process.poll() is not None:
continue
try:
self._killpg(process.pid, sent_signal)
except OSError:
pass
def _begin_shutdown(self) -> None:
if self._shutdown_deadline is None:
self._shutdown_deadline = self._monotonic() + 10.0
if not self._term_sent:
self._signal_running(signal.SIGTERM)
self._term_sent = True
def _reap_all(self) -> None:
for _name, process in self._children:
try:
process.wait(timeout=None)
except OSError:
logging.error("Child process reap failed")
def _shutdown(self) -> None:
if not self._children:
return
logging.info("Stopping child processes")
self._begin_shutdown()
assert self._shutdown_deadline is not None
while (
any(process.poll() is None for _name, process in self._children)
and self._monotonic() < self._shutdown_deadline
):
remaining = self._shutdown_deadline - self._monotonic()
self._sleep(min(0.25, max(0.0, remaining)))
if any(process.poll() is None for _name, process in self._children):
logging.info("Forcing child processes to stop")
self._signal_running(signal.SIGKILL)
self._reap_all()
logging.info("Child processes stopped")
@staticmethod
def _failure_status(returncode: int) -> int:
return returncode if returncode != 0 else 1
def _startup_failure(self, service: str) -> int:
exited = self._first_exited_child()
if exited is not None:
name, returncode = exited
logging.error(f"{name} exited unexpectedly")
status = self._failure_status(returncode)
else:
logging.error(f"{service} readiness failed")
status = 1
self._shutdown()
return status
def _run(self) -> int:
try:
self._clean_profiles()
if self._stopping:
self._shutdown()
return 0
logging.info("Starting CloakBrowser")
self._spawn("CloakBrowser", CLOAK_COMMAND)
wait_until_ready(
"CloakBrowser",
self._cloak_probe,
60.0,
self._startup_stopping,
self._monotonic,
self._sleep,
)
if self._stopping:
self._shutdown()
return 0
if self._first_exited_child() is not None:
return self._startup_failure("CloakBrowser")
logging.info("CloakBrowser ready")
logging.info("Starting Stelloauth")
self._spawn("Stelloauth", STELLOAUTH_COMMAND)
wait_until_ready(
"Stelloauth",
self._stelloauth_probe,
30.0,
self._startup_stopping,
self._monotonic,
self._sleep,
)
if self._stopping:
self._shutdown()
return 0
if self._first_exited_child() is not None:
return self._startup_failure("Stelloauth")
logging.info("Stelloauth listening on 0.0.0.0:8080")
except ReadinessError:
if self._stopping:
self._shutdown()
return 0
service = "Stelloauth" if len(self._children) > 1 else "CloakBrowser"
return self._startup_failure(service)
except OSError:
logging.error("Process startup failed")
self._shutdown()
return 1
while not self._stopping:
exited = self._first_exited_child()
if exited is not None:
name, returncode = exited
logging.error(f"{name} exited unexpectedly")
status = self._failure_status(returncode)
self._shutdown()
return status
self._sleep(0.25)
self._shutdown()
return 0
def run(self) -> int:
previous_handlers = {
signal.SIGTERM: signal.signal(signal.SIGTERM, self._handle_signal),
signal.SIGINT: signal.signal(signal.SIGINT, self._handle_signal),
}
try:
return self._run()
finally:
for handled_signal, previous_handler in previous_handlers.items():
signal.signal(handled_signal, previous_handler)
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
try:
options = load_options(OPTIONS_PATH)
except ConfigError:
logging.error("Invalid add-on configuration")
return 2
return ProcessManager(build_environment(options)).run()
if __name__ == "__main__":
sys.exit(main())
+688
View File
@@ -0,0 +1,688 @@
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
import logging
import signal
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).parents[1]
SUPERVISOR_PATH = ROOT / "stelloauth/rootfs/usr/local/bin/addon-supervisor"
def load_supervisor():
loader = importlib.machinery.SourceFileLoader("addon_supervisor", str(SUPERVISOR_PATH))
spec = importlib.util.spec_from_loader(loader.name, loader)
assert spec is not None
module = importlib.util.module_from_spec(spec)
sys.modules[loader.name] = module
loader.exec_module(module)
return module
@pytest.fixture
def supervisor():
return load_supervisor()
def test_options_load_valid_defaults(supervisor, tmp_path: Path) -> None:
path = tmp_path / "options.json"
path.write_text(
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
}
),
encoding="utf-8",
)
assert supervisor.load_options(path) == supervisor.Options("60s", 5, "1h")
def test_environment_maps_options_and_preserves_parent(supervisor, monkeypatch) -> None:
monkeypatch.setenv("PARENT_SENTINEL", "preserved")
environment = supervisor.build_environment(supervisor.Options("60s", 5, "1h"))
assert environment["PARENT_SENTINEL"] == "preserved"
assert {key: environment[key] for key in (
"CLOAK_CDP_URL",
"CLOAK_MAX_SESSIONS",
"CLOAK_QUEUE_TIMEOUT",
"RATE_LIMIT_COUNT",
"RATE_LIMIT_DURATION",
"HTTP_ADDRESS",
"PORT",
"METRICS_ADDRESS",
"METRICS_PORT",
)} == {
"CLOAK_CDP_URL": "http://127.0.0.1:9222",
"CLOAK_MAX_SESSIONS": "1",
"CLOAK_QUEUE_TIMEOUT": "60s",
"RATE_LIMIT_COUNT": "5",
"RATE_LIMIT_DURATION": "1h",
"HTTP_ADDRESS": "0.0.0.0",
"PORT": "8080",
"METRICS_ADDRESS": "127.0.0.1",
"METRICS_PORT": "9090",
}
@pytest.mark.parametrize(
"content",
[
"{}",
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
"unknown-SENTINEL": "secret-SENTINEL",
}
),
json.dumps({"queue_timeout": "60s", "rate_limit_count": 5}),
json.dumps(
{
"queue_timeout": "0s-SENTINEL",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
}
),
json.dumps(
{
"queue_timeout": "60-SENTINEL",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
}
),
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": True,
"rate_limit_duration": "1h",
}
),
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": 0,
"rate_limit_duration": "1h",
}
),
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": 21,
"rate_limit_duration": "1h",
}
),
json.dumps(
{
"queue_timeout": "60s",
"rate_limit_count": 5,
"rate_limit_duration": "1-SENTINEL",
}
),
'{"queue_timeout":"malformed-SENTINEL"',
],
)
def test_invalid_options_raise_fixed_non_secret_error(
supervisor, tmp_path: Path, caplog, content: str
) -> None:
path = tmp_path / "options.json"
path.write_text(content, encoding="utf-8")
with caplog.at_level(logging.INFO), pytest.raises(
supervisor.ConfigError, match="^Invalid add-on configuration$"
):
supervisor.load_options(path)
assert "SENTINEL" not in caplog.text
assert "SENTINEL" not in str(sys.exc_info())
def test_unreadable_options_raise_fixed_non_secret_error(
supervisor, tmp_path: Path, caplog
) -> None:
missing = tmp_path / "missing-SENTINEL.json"
with caplog.at_level(logging.INFO), pytest.raises(
supervisor.ConfigError, match="^Invalid add-on configuration$"
):
supervisor.load_options(missing)
assert "SENTINEL" not in caplog.text
def test_invalid_options_main_logs_only_fixed_error(
supervisor, tmp_path: Path, caplog, monkeypatch
) -> None:
path = tmp_path / "options.json"
path.write_text('{"credential":"secret-SENTINEL"}', encoding="utf-8")
monkeypatch.setattr(supervisor, "OPTIONS_PATH", path)
with caplog.at_level(logging.INFO):
assert supervisor.main() == 2
assert caplog.messages == ["Invalid add-on configuration"]
assert "SENTINEL" not in caplog.text
def test_probe_cloak_uses_real_cdp_websocket_and_closes_profile(supervisor) -> None:
calls: list[tuple[str, str, float]] = []
def request(method: str, url: str, timeout: float):
calls.append((method, url, timeout))
if url == supervisor.CLOAK_VERSION:
return supervisor.HttpResponse(
200,
json.dumps(
{
"webSocketDebuggerUrl": (
"ws://127.0.0.1:9222/devtools/browser/readiness"
)
}
).encode(),
)
return supervisor.HttpResponse(200, b"ok")
supervisor.probe_cloak(request)
assert [(method, url) for method, url, _ in calls] == [
("GET", supervisor.CLOAK_ROOT),
("GET", supervisor.CLOAK_VERSION),
("POST", supervisor.CLOAK_CLOSE),
]
assert all(timeout == 2.0 for _, _, timeout in calls)
@pytest.mark.parametrize(
"root_status,version_status,version_body,close_status",
[
(200, 200, b"{}", 200),
(200, 200, b'{"webSocketDebuggerUrl":""}', 200),
(200, 200, b"not-json-SENTINEL", 200),
(
200,
200,
b'{"webSocketDebuggerUrl":"ws://192.0.2.1:9222/devtools/browser/x"}',
200,
),
(
200,
200,
b'{"webSocketDebuggerUrl":"WS://127.0.0.1:9222/devtools/browser/x"}',
200,
),
(
503,
200,
b'{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/x"}',
200,
),
(
200,
503,
b'{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/x"}',
200,
),
(
200,
200,
b'{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/x"}',
503,
),
],
)
def test_probe_cloak_rejects_invalid_readiness(
supervisor, root_status, version_status, version_body, close_status
) -> None:
responses = {
supervisor.CLOAK_ROOT: supervisor.HttpResponse(root_status, b"root"),
supervisor.CLOAK_VERSION: supervisor.HttpResponse(version_status, version_body),
supervisor.CLOAK_CLOSE: supervisor.HttpResponse(close_status, b"close"),
}
with pytest.raises(supervisor.ReadinessError):
supervisor.probe_cloak(lambda _method, url, _timeout: responses[url])
def test_probe_stelloauth_requires_http_200(supervisor) -> None:
calls = []
def request(method: str, url: str, timeout: float):
calls.append((method, url, timeout))
return supervisor.HttpResponse(200, b"SENTINEL-body-is-ignored")
supervisor.probe_stelloauth(request)
assert calls == [("GET", supervisor.STELLOAUTH_ROOT, 2.0)]
with pytest.raises(supervisor.ReadinessError):
supervisor.probe_stelloauth(
lambda _method, _url, _timeout: supervisor.HttpResponse(503, b"")
)
class FakeClock:
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.now
def sleep(self, delay: float) -> None:
self.sleeps.append(delay)
self.now += delay
def test_readiness_backoff_starts_at_quarter_second_and_caps_at_two(supervisor) -> None:
clock = FakeClock()
attempts = 0
def probe() -> None:
nonlocal attempts
attempts += 1
if attempts <= 5:
raise OSError("transient-SENTINEL")
supervisor.wait_until_ready(
"Service", probe, 60.0, lambda: False, clock.monotonic, clock.sleep
)
assert clock.sleeps == [0.25, 0.5, 1.0, 2.0, 2.0]
@pytest.mark.parametrize("name,timeout", [("CloakBrowser", 60.0), ("Stelloauth", 30.0)])
def test_readiness_expires_at_service_timeout(supervisor, name: str, timeout: float) -> None:
clock = FakeClock()
with pytest.raises(
supervisor.ReadinessError, match=f"^{name} did not become ready$"
):
supervisor.wait_until_ready(
name,
lambda: (_ for _ in ()).throw(ValueError("transient-SENTINEL")),
timeout,
lambda: False,
clock.monotonic,
clock.sleep,
)
assert timeout <= clock.now <= timeout + 2.0
assert max(clock.sleeps) == 2.0
def test_readiness_stop_flag_aborts_immediately(supervisor) -> None:
clock = FakeClock()
called = False
def probe() -> None:
nonlocal called
called = True
with pytest.raises(
supervisor.ReadinessError, match="^CloakBrowser did not become ready$"
):
supervisor.wait_until_ready(
"CloakBrowser", probe, 60.0, lambda: True, clock.monotonic, clock.sleep
)
assert called is False
assert clock.sleeps == []
def test_probe_http_request_disables_proxies(supervisor, monkeypatch) -> None:
observed = {}
class Response:
status = 200
def read(self) -> bytes:
return b"response"
def __enter__(self):
return self
def __exit__(self, *_args):
return None
class Opener:
def open(self, request, timeout):
observed["request"] = request
observed["timeout"] = timeout
return Response()
def build_opener(handler):
observed["handler"] = handler
return Opener()
monkeypatch.setattr(supervisor.urllib.request, "build_opener", build_opener)
assert supervisor.http_request("POST", "http://127.0.0.1/", 3.0) == (
200,
b"response",
)
assert observed["handler"].proxies == {}
assert observed["request"].get_method() == "POST"
assert observed["timeout"] == 3.0
class FakeProcess:
def __init__(self, pid: int, *, ignores_term: bool = False) -> None:
self.pid = pid
self.returncode: int | None = None
self.ignores_term = ignores_term
self.wait_calls: list[float | None] = []
def poll(self) -> int | None:
return self.returncode
def wait(self, timeout: float | None) -> int:
self.wait_calls.append(timeout)
if self.returncode is None:
self.returncode = -9
return self.returncode
def manager_harness(
supervisor,
tmp_path: Path,
*,
cloak_probe=None,
stelloauth_probe=None,
ignores_term: tuple[bool, bool] = (False, False),
):
clock = FakeClock()
events: list[object] = []
processes = [
FakeProcess(1001, ignores_term=ignores_term[0]),
FakeProcess(1002, ignores_term=ignores_term[1]),
]
spawned: list[FakeProcess] = []
def popen(command, *, env, start_new_session):
process = processes[len(spawned)]
spawned.append(process)
events.append(("start", list(command), env, start_new_session))
return process
def killpg(pid: int, sent_signal: int) -> None:
events.append(("signal", pid, sent_signal))
process = next(item for item in processes if item.pid == pid)
if sent_signal == signal.SIGKILL or not process.ignores_term:
process.returncode = -sent_signal
def default_cloak_probe() -> None:
events.append("probe cloak")
def default_stelloauth_probe() -> None:
events.append("probe stelloauth")
profile_path = tmp_path / "cloakserve"
environment = {"SENSITIVE_SENTINEL": "credential-code-cookie-token-SENTINEL"}
manager = supervisor.ProcessManager(
environment,
popen=popen,
killpg=killpg,
cloak_probe=cloak_probe or default_cloak_probe,
stelloauth_probe=stelloauth_probe or default_stelloauth_probe,
monotonic=clock.monotonic,
sleep=clock.sleep,
profile_path=profile_path,
)
return SimpleNamespace(
manager=manager,
clock=clock,
events=events,
processes=processes,
spawned=spawned,
profile_path=profile_path,
environment=environment,
)
def test_lifecycle_startup_order_and_profiles(supervisor, tmp_path: Path, monkeypatch) -> None:
harness = manager_harness(supervisor, tmp_path)
harness.profile_path.mkdir()
stale = harness.profile_path / "stale-profile"
stale.write_text("stale", encoding="utf-8")
original_cloak = harness.manager._cloak_probe
original_stelloauth = harness.manager._stelloauth_probe
def cloak_probe() -> None:
assert not stale.exists()
assert harness.profile_path.stat().st_mode & 0o777 == 0o700
original_cloak()
def stelloauth_probe() -> None:
original_stelloauth()
harness.manager._handle_signal(signal.SIGTERM, None)
harness.manager._cloak_probe = cloak_probe
harness.manager._stelloauth_probe = stelloauth_probe
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
starts_and_probes = [event for event in harness.events if event == "probe cloak" or event == "probe stelloauth" or (isinstance(event, tuple) and event[0] == "start")]
assert [(event[0], event[1]) if isinstance(event, tuple) else event for event in starts_and_probes] == [
("start", supervisor.CLOAK_COMMAND),
"probe cloak",
("start", supervisor.STELLOAUTH_COMMAND),
"probe stelloauth",
]
for event in starts_and_probes:
if isinstance(event, tuple):
assert event[2] is harness.environment
assert event[3] is True
def test_cloak_readiness_failure_never_starts_stelloauth(
supervisor, tmp_path: Path, monkeypatch
) -> None:
def failing_probe() -> None:
raise supervisor.ReadinessError("secret-SENTINEL")
harness = manager_harness(supervisor, tmp_path, cloak_probe=failing_probe)
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 1
assert len(harness.spawned) == 1
assert harness.clock.now >= 60.0
assert harness.processes[0].wait_calls == [None]
def test_stelloauth_readiness_failure_stops_both_children(
supervisor, tmp_path: Path, monkeypatch
) -> None:
def failing_probe() -> None:
raise OSError("credential-SENTINEL")
harness = manager_harness(supervisor, tmp_path, stelloauth_probe=failing_probe)
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 1
assert len(harness.spawned) == 2
assert {(event[1], event[2]) for event in harness.events if event[0] == "signal"} == {
(1001, signal.SIGTERM),
(1002, signal.SIGTERM),
}
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
@pytest.mark.parametrize(
"child_index,child_status,expected_status",
[(0, 0, 1), (0, 7, 7), (1, 0, 1), (1, 9, 9)],
)
def test_child_exit_stops_sibling_and_returns_failure(
supervisor,
tmp_path: Path,
monkeypatch,
child_index: int,
child_status: int,
expected_status: int,
) -> None:
harness = manager_harness(supervisor, tmp_path)
slept = False
def sleep(delay: float) -> None:
nonlocal slept
harness.clock.sleep(delay)
if not slept:
slept = True
harness.processes[child_index].returncode = child_status
harness.manager._sleep = sleep
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == expected_status
sibling = harness.processes[1 - child_index]
assert ("signal", sibling.pid, signal.SIGTERM) in harness.events
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
@pytest.mark.parametrize("incoming_signal", [signal.SIGTERM, signal.SIGINT])
def test_signal_shutdown_forwards_sigterm_and_returns_zero(
supervisor, tmp_path: Path, monkeypatch, incoming_signal: int
) -> None:
harness = manager_harness(supervisor, tmp_path)
triggered = False
def sleep(delay: float) -> None:
nonlocal triggered
if not triggered:
triggered = True
harness.manager._handle_signal(incoming_signal, None)
harness.clock.sleep(delay)
harness.manager._sleep = sleep
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
assert {(event[1], event[2]) for event in harness.events if event[0] == "signal"} == {
(1001, signal.SIGTERM),
(1002, signal.SIGTERM),
}
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_signal_during_cloak_readiness_never_starts_stelloauth(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(supervisor, tmp_path)
def cloak_probe() -> None:
harness.manager._handle_signal(signal.SIGTERM, None)
harness.manager._cloak_probe = cloak_probe
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
assert len(harness.spawned) == 1
assert ("signal", 1001, signal.SIGTERM) in harness.events
assert harness.processes[0].wait_calls == [None]
def test_child_exit_during_cloak_readiness_never_starts_stelloauth(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(supervisor, tmp_path)
def cloak_probe() -> None:
harness.processes[0].returncode = 7
harness.manager._cloak_probe = cloak_probe
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 7
assert len(harness.spawned) == 1
assert harness.processes[0].wait_calls == [None]
def test_signal_while_starting_child_still_terminates_new_process_group(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(supervisor, tmp_path)
original_popen = harness.manager._popen
starts = 0
def popen(command, *, env, start_new_session):
nonlocal starts
starts += 1
if starts == 2:
harness.manager._handle_signal(signal.SIGTERM, None)
return original_popen(command, env=env, start_new_session=start_new_session)
harness.manager._popen = popen
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
assert ("signal", 1002, signal.SIGTERM) in harness.events
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_shutdown_uses_one_ten_second_deadline_then_sigkills_remaining_groups(
supervisor, tmp_path: Path, monkeypatch
) -> None:
harness = manager_harness(supervisor, tmp_path, ignores_term=(True, True))
triggered = False
def sleep(delay: float) -> None:
nonlocal triggered
if not triggered:
triggered = True
harness.manager._handle_signal(signal.SIGTERM, None)
harness.clock.sleep(delay)
harness.manager._sleep = sleep
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
assert harness.manager.run() == 0
assert harness.clock.now == pytest.approx(10.0)
assert [
(event[1], event[2]) for event in harness.events if event[0] == "signal"
] == [
(1001, signal.SIGTERM),
(1002, signal.SIGTERM),
(1001, signal.SIGKILL),
(1002, signal.SIGKILL),
]
assert [process.wait_calls for process in harness.processes] == [[None], [None]]
def test_lifecycle_logs_are_fixed_and_contain_no_sensitive_values(
supervisor, tmp_path: Path, monkeypatch, caplog
) -> None:
harness = manager_harness(supervisor, tmp_path)
def stelloauth_probe() -> None:
harness.manager._handle_signal(signal.SIGTERM, None)
harness.manager._stelloauth_probe = stelloauth_probe
monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None)
with caplog.at_level(logging.INFO):
assert harness.manager.run() == 0
assert caplog.messages == [
"Cleaning CloakBrowser profiles",
"Starting CloakBrowser",
"CloakBrowser ready",
"Starting Stelloauth",
"Shutdown requested",
"Stopping child processes",
"Child processes stopped",
]
lowered = caplog.text.lower()
for sensitive in ("sentinel", "credential", "code", "cookie", "token"):
assert sensitive not in lowered