mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
A TestClient lifespan runs the server shutdown, which latches workers._EVENT_Q_SHUTDOWN for the rest of the xdist worker; the next test in that worker that publishes on the bus then fails with "supervisor event bus is shutting down" (seen on test_zombie_prevention once the sprint's new tests shifted the distribution). One autouse fixture unlatches it for every test; a regression pin. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
784 lines
36 KiB
Python
784 lines
36 KiB
Python
# tests/conftest.py — shared pytest fixtures for the Ouroboros test suite.
|
|
#
|
|
# Loaded automatically by pytest before any test module runs.
|
|
# Cross-module helpers that are not pytest fixtures (e.g. SDK mock, extension
|
|
# runtime cleanup) live in ``tests/_shared.py`` instead.
|
|
import asyncio
|
|
import functools
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
pytest.register_assert_rewrite("tests.ui_media_delivery_smoke")
|
|
|
|
|
|
_PYTEST_DATA_DIR = None
|
|
|
|
|
|
@pytest.fixture
|
|
def preflight_timeout_diagnostics(request, monkeypatch, tmp_path):
|
|
"""Observe the Windows nested-pytest timeout without changing its gate."""
|
|
import faulthandler
|
|
import json
|
|
from ouroboros import preflight_runner
|
|
from ouroboros.platform_layer import collect_descendant_pids
|
|
|
|
trace_dir = tmp_path / "preflight-diagnostics"
|
|
|
|
def install(*, dump_after=90):
|
|
trace_dir.mkdir()
|
|
original_probe = preflight_runner._install_worker_probe
|
|
original_kill = preflight_runner._terminate_preflight_tree
|
|
|
|
def probe(temp_root):
|
|
module = original_probe(temp_root)
|
|
path = preflight_runner._probe_dir(temp_root) / (module + ".py")
|
|
with path.open("a", encoding="utf-8") as stream:
|
|
stream.write(f'''
|
|
import atexit, faulthandler, json, sys, time
|
|
_trace = open({str(trace_dir)!r} + "/" + str(os.getpid()) + ".log", "a", encoding="utf-8")
|
|
def _trace_event(event, **facts):
|
|
_trace.write(json.dumps(dict(event=event, pid=os.getpid(), ppid=os.getppid(),
|
|
worker=os.environ.get("PYTEST_XDIST_WORKER", "controller"),
|
|
monotonic=time.monotonic(), **facts)) + "\\n")
|
|
_trace.flush()
|
|
_trace_event("probe_import", stdout_fd=sys.stdout.fileno(), stderr_fd=sys.stderr.fileno())
|
|
faulthandler.dump_traceback_later({dump_after!r}, repeat=True, file=_trace)
|
|
def _trace_exit():
|
|
_trace_event("atexit")
|
|
faulthandler.cancel_dump_traceback_later()
|
|
atexit.register(_trace_exit)
|
|
def pytest_sessionstart(session):
|
|
_trace_event("sessionstart")
|
|
def pytest_sessionfinish(session, exitstatus):
|
|
_trace_event("sessionfinish", exitstatus=int(exitstatus))
|
|
def pytest_unconfigure(config):
|
|
_trace_event("unconfigure")
|
|
def pytest_testnodedown(node, error):
|
|
_trace_event("worker_down", gateway=node.gateway.id, error_type=type(error).__name__)
|
|
''')
|
|
return module
|
|
|
|
def before_kill(proc, temp_root):
|
|
try:
|
|
facts = {"event": "before_kill", "pid": proc.pid,
|
|
"monotonic": time.monotonic(), "returncode": proc.poll(),
|
|
"descendant_pids": collect_descendant_pids(proc.pid)}
|
|
for name in ("stdout", "stderr"):
|
|
stream = getattr(proc, name)
|
|
reader = getattr(proc, name + "_thread", None)
|
|
facts[name] = {"closed": stream.closed if stream else None,
|
|
"fd": stream.fileno() if stream and not stream.closed else None,
|
|
"reader_alive": reader.is_alive() if reader else None}
|
|
with (trace_dir / "parent.log").open("a", encoding="utf-8") as stream:
|
|
stream.write(json.dumps(facts) + "\n")
|
|
stream.flush()
|
|
faulthandler.dump_traceback(file=stream)
|
|
except Exception as exc:
|
|
print(f"preflight diagnostic capture failed: {type(exc).__name__}")
|
|
finally:
|
|
original_kill(proc, temp_root)
|
|
|
|
monkeypatch.setattr(preflight_runner, "_install_worker_probe", probe)
|
|
monkeypatch.setattr(preflight_runner, "_terminate_preflight_tree", before_kill)
|
|
return trace_dir
|
|
|
|
if sys.platform == "win32" and request.node.name == "test_hermetic_pytest_applies_candidate_diff_and_scrubs_live_env":
|
|
install()
|
|
yield install
|
|
if trace_dir.exists():
|
|
for path in sorted(trace_dir.glob("*.log")):
|
|
print(f"\npreflight diagnostic {path.name}:\n{path.read_text(encoding='utf-8')}")
|
|
|
|
|
|
# Repo root for a live-DATA run, which has no pytest data dir to hang it off. Created lazily
|
|
# so the hermetic lane never leaves an unused temp dir behind (see pytest_sessionfinish).
|
|
_PYTEST_REPO_FALLBACK = None
|
|
if os.environ.get("OUROBOROS_ALLOW_LIVE_DATA_TESTS") != "1":
|
|
_LIVE_DATA_ROOT = (
|
|
os.environ.get("OUROBOROS_TEST_LIVE_DATA_ROOT")
|
|
or os.environ.get("OUROBOROS_DATA_DIR")
|
|
or str(pathlib.Path.home() / "Ouroboros" / "data")
|
|
)
|
|
_PYTEST_DATA_DIR = pathlib.Path(tempfile.mkdtemp(prefix="ouroboros-pytest-data-"))
|
|
os.environ["OUROBOROS_PYTEST_ACTIVE"] = "1"
|
|
os.environ["OUROBOROS_TEST_LIVE_DATA_ROOT"] = _LIVE_DATA_ROOT
|
|
os.environ["OUROBOROS_DATA_DIR"] = str(_PYTEST_DATA_DIR)
|
|
os.environ["OUROBOROS_SETTINGS_PATH"] = str(_PYTEST_DATA_DIR / "settings.json")
|
|
# Conftest-WIDE bench-runs isolation. devtools benchmark tests invoke
|
|
# run_*.main(), whose run_root() defaults to the real <repo>/../bench_runs
|
|
# when OUROBOROS_BENCH_RUNS_ROOT is unset — leaking timestamped run dirs and
|
|
# ouroboros_task_body.json stubs into the operator's bench_runs/ (the
|
|
# programbench/swe_bench_pro pollution). A file-local autouse fixture only
|
|
# covered one module; pinning it here covers every test.
|
|
os.environ["OUROBOROS_BENCH_RUNS_ROOT"] = str(_PYTEST_DATA_DIR / "bench_runs")
|
|
|
|
|
|
_ORIGINAL_POPEN_INIT = subprocess.Popen.__init__
|
|
_PYTEST_CHILD_DATA_DIR = os.environ.get("OUROBOROS_DATA_DIR", "")
|
|
_PYTEST_CHILD_LIVE_ROOT = os.environ.get("OUROBOROS_TEST_LIVE_DATA_ROOT", "")
|
|
_PYTEST_CHILD_BENCH_ROOT = os.environ.get("OUROBOROS_BENCH_RUNS_ROOT", "")
|
|
_PYTEST_POPEN_PATCHED = False
|
|
|
|
|
|
def _isolated_child_env(value) -> dict:
|
|
child_env = dict(value)
|
|
if not child_env.get("OUROBOROS_DATA_DIR"):
|
|
child_env["OUROBOROS_DATA_DIR"] = _PYTEST_CHILD_DATA_DIR
|
|
if not child_env.get("OUROBOROS_SETTINGS_PATH"):
|
|
child_env["OUROBOROS_SETTINGS_PATH"] = str(
|
|
pathlib.Path(child_env["OUROBOROS_DATA_DIR"]) / "settings.json"
|
|
)
|
|
if _PYTEST_CHILD_BENCH_ROOT and not child_env.get("OUROBOROS_BENCH_RUNS_ROOT"):
|
|
child_env["OUROBOROS_BENCH_RUNS_ROOT"] = _PYTEST_CHILD_BENCH_ROOT
|
|
child_env["OUROBOROS_PYTEST_ACTIVE"] = "1"
|
|
child_env["OUROBOROS_TEST_LIVE_DATA_ROOT"] = _PYTEST_CHILD_LIVE_ROOT
|
|
return child_env
|
|
|
|
|
|
def _install_pytest_child_isolation() -> None:
|
|
"""Keep the disposable data root when a test scrubs a child env."""
|
|
global _PYTEST_POPEN_PATCHED
|
|
if _PYTEST_DATA_DIR is None or _PYTEST_POPEN_PATCHED:
|
|
return
|
|
|
|
@functools.wraps(_ORIGINAL_POPEN_INIT)
|
|
def isolated_init(self, *args, **kwargs):
|
|
positional = list(args)
|
|
if len(positional) > 10 and positional[10] is not None:
|
|
positional[10] = _isolated_child_env(positional[10])
|
|
elif kwargs.get("env") is not None:
|
|
kwargs["env"] = _isolated_child_env(kwargs["env"])
|
|
return _ORIGINAL_POPEN_INIT(self, *positional, **kwargs)
|
|
|
|
subprocess.Popen.__init__ = isolated_init
|
|
_PYTEST_POPEN_PATCHED = True
|
|
|
|
|
|
def _restore_pytest_child_isolation() -> None:
|
|
global _PYTEST_POPEN_PATCHED
|
|
if _PYTEST_POPEN_PATCHED:
|
|
subprocess.Popen.__init__ = _ORIGINAL_POPEN_INIT
|
|
_PYTEST_POPEN_PATCHED = False
|
|
|
|
|
|
def _bind_pytest_repo_root() -> None:
|
|
"""Point git_ops.REPO_DIR away from the operator's live checkout.
|
|
|
|
Unbound, git_ops.REPO_DIR (no env fallback) sends
|
|
update_merge._update_tx_marker_path() at the LIVE repo's .git, so a staged managed merge
|
|
blocks the whole suite through the registry guard. An empty dir with no .git makes the
|
|
strict read `absent` — the honest allow. Direct assignment: init() would also rewrite
|
|
BRANCH_DEV/BRANCH_STABLE.
|
|
|
|
Keyed on the REPO opt-in (OUROBOROS_ALLOW_LIVE_REPO_TESTS, the same switch git_ops's own
|
|
destructive-git fuse reads), NOT on the DATA opt-in: they are separate switches, and a run
|
|
that opts into live DATA has not opted into reading the live repo's update transaction.
|
|
"""
|
|
if os.environ.get("OUROBOROS_ALLOW_LIVE_REPO_TESTS") == "1":
|
|
return
|
|
from supervisor import git_ops
|
|
|
|
global _PYTEST_REPO_FALLBACK
|
|
if _PYTEST_DATA_DIR is None and _PYTEST_REPO_FALLBACK is None:
|
|
_PYTEST_REPO_FALLBACK = pathlib.Path(tempfile.mkdtemp(prefix="ouroboros-pytest-repo-"))
|
|
repo_root = (_PYTEST_DATA_DIR or _PYTEST_REPO_FALLBACK) / "repo"
|
|
git_ops.REPO_DIR = repo_root.resolve(strict=False)
|
|
git_ops.REPO_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def git_ops_repo_root() -> pathlib.Path:
|
|
"""The repo root this pytest session binds git_ops (and worker children) to."""
|
|
from supervisor import git_ops
|
|
|
|
return git_ops.REPO_DIR
|
|
|
|
|
|
def _bind_pytest_runtime_roots() -> None:
|
|
"""Rebind modules that may have been imported before conftest set the env."""
|
|
_bind_pytest_repo_root()
|
|
if _PYTEST_DATA_DIR is None:
|
|
return
|
|
root = _PYTEST_DATA_DIR.resolve(strict=False)
|
|
import ouroboros.config as config
|
|
from supervisor import git_ops, queue, state, workers
|
|
|
|
config.DATA_DIR = root
|
|
config.SETTINGS_PATH = root / "settings.json"
|
|
state.init(root, state.TOTAL_BUDGET_LIMIT)
|
|
queue.init(root)
|
|
# git_ops has no env fallback: keep every rescue/log writer on the disposable
|
|
# data root without init(), which would also overwrite branch/remote authority.
|
|
git_ops.DRIVE_ROOT = root
|
|
workers.DRIVE_ROOT = root
|
|
# git_ops.DRIVE_ROOT was the one runtime root this rebind list missed
|
|
# (issue #455): _log_supervisor and the reset/rescue writers resolve
|
|
# supervisor.jsonl through it. Un-pinned it now lazily follows the env
|
|
# (git_ops.__getattr__), but the explicit session pin keeps every writer
|
|
# on ONE root even for tests that mutate OUROBOROS_DATA_DIR mid-test.
|
|
from supervisor import git_ops
|
|
|
|
git_ops.DRIVE_ROOT = root
|
|
# spawn_workers hands str(workers.REPO_DIR) to every child, and the child binds git_ops to
|
|
# it — so leaving this at the live default would send workers started BY A TEST back at the
|
|
# operator's checkout, undoing the isolation above.
|
|
workers.REPO_DIR = git_ops_repo_root()
|
|
|
|
|
|
def _mock_pollution_files(root: pathlib.Path) -> set[pathlib.Path]:
|
|
"""Mock-named pollution in the repo root.
|
|
|
|
Catches both the ``<MagicMock ...>`` repr files AND a literal ``MagicMock``
|
|
directory — the latter is what an unmocked ``ctx.drive_root / ...`` write
|
|
materialises (``MagicMock/mock.drive_root.__truediv__()...``). The earlier
|
|
file-only guard missed the directory form, which then rode a ``git add -A``
|
|
into a release.
|
|
"""
|
|
out: set[pathlib.Path] = set()
|
|
try:
|
|
for p in root.iterdir():
|
|
if p.is_file() and "<MagicMock" in p.name:
|
|
out.add(p)
|
|
elif p.is_dir() and (p.name == "MagicMock" or p.name.startswith("<MagicMock")):
|
|
out.add(p)
|
|
except OSError:
|
|
return out
|
|
return out
|
|
|
|
|
|
# Files whose tests spawn REAL OS processes / bind REAL ports / mutate process-global state.
|
|
# Under `pytest -n` (xdist) they flake — or crash a worker, which (with --max-worker-restart=0)
|
|
# fails that worker's WHOLE co-located batch, surfacing as spurious failures in unrelated files.
|
|
# So CI **and the hermetic commit gate** (ouroboros/preflight_runner.py, v6.88.0) run them in a
|
|
# SERIAL pass (`-m serial`) and exclude them from the parallel pass (`-m "not serial" -n auto`);
|
|
# in the gate a crashed worker is a named hard block, not a retry. A NEW real-process/port/
|
|
# global-state test should mark itself `@pytest.mark.serial` (preferred) or be added here.
|
|
# See docs/DEVELOPMENT.md "Pytest marker lanes".
|
|
_SERIAL_TEST_FILES = frozenset({
|
|
"test_workspace_executor.py",
|
|
# Themed siblings of test_workspace_executor.py; they spawn the same real
|
|
# processes, so the whole family stays in the serial lane.
|
|
"test_workspace_executor_services.py",
|
|
"test_workspace_executor_docker.py",
|
|
"test_workspace_executor_admission.py",
|
|
"test_workspace_executor_cleanup.py",
|
|
"test_process_custody.py",
|
|
"test_kill_process_tree_orphans.py",
|
|
"test_zombie_prevention.py",
|
|
"test_worker_crash_retry.py",
|
|
"test_process_resource_leaks.py",
|
|
"test_restart_reconnect.py",
|
|
# spawns a real pytest subprocess via run_hermetic_pytest + its reaper kills whole process
|
|
# trees / sweeps processes referencing a temp root → can collateral-damage sibling xdist
|
|
# workers under -n (their unrelated tests then fail as a crashed-worker batch).
|
|
"test_preflight_runner.py",
|
|
"test_preflight_process_containment.py",
|
|
# Imports/mutates the process-global server settings facade; when xdist
|
|
# reuses a worker after unrelated server tests, cached route/probe state can
|
|
# escape monkeypatch restoration and turn the mocked capability probe into
|
|
# a real network attempt. Keep the whole hot-reload contract in the serial
|
|
# lane, matching its process-global subject.
|
|
"test_settings_budget_hotreload.py",
|
|
# spawns real long-lived sleeper subprocesses via the legacy ouroboros.tools.services path
|
|
# AND mutates the module-global tools.services._SERVICES (NOT covered by the
|
|
# _isolate_workspace_executor_globals fixture, which isolates a different dict).
|
|
"test_services_tool_v2.py",
|
|
# Its own autouse fixture documents that the writer fence "deliberately latches PROCESS-wide
|
|
# state" (workers admission/survivor/blocker latches, update_merge/git_ops module globals);
|
|
# under -n the replace-family no-side-effect pins (replace_env["calls"] == []) intermittently
|
|
# observe git calls leaked by co-located modules. Same module-global class -> serial lane.
|
|
"test_update_apply_routing.py",
|
|
})
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True)
|
|
def pytest_collection_modifyitems(config, items): # noqa: ARG001
|
|
"""Tag whole-file serial suites with the `serial` marker BEFORE pytest's own `-m`
|
|
deselection runs (tryfirst), so `-m "not serial"` / `-m serial` partition them correctly.
|
|
Tests that carry their own `@pytest.mark.serial` decorator are honored natively too."""
|
|
for item in items:
|
|
if pathlib.Path(str(item.fspath)).name in _SERIAL_TEST_FILES:
|
|
item.add_marker(pytest.mark.serial)
|
|
|
|
|
|
def pytest_sessionstart(session): # noqa: ARG001
|
|
_bind_pytest_runtime_roots()
|
|
_install_pytest_child_isolation()
|
|
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
|
session.config._ouroboros_initial_mock_pollution = _mock_pollution_files(repo_root)
|
|
|
|
|
|
def pytest_sessionfinish(session, exitstatus): # noqa: ARG001
|
|
# Under pytest-xdist this hook fires on the controller AND every worker process against the
|
|
# SHARED repo root. Run the repo-root pollution sweep + exitstatus mutation ONLY on the
|
|
# controller (the single authority): otherwise workers race the same shutil.rmtree and each
|
|
# set their own session.exitstatus, manufacturing a non-deterministic failed-shaped run.
|
|
# Workers carry a `workerinput` config attribute; the controller (and any serial run) do not.
|
|
if not hasattr(session.config, "workerinput"):
|
|
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
|
initial = getattr(session.config, "_ouroboros_initial_mock_pollution", set())
|
|
leaked = sorted(_mock_pollution_files(repo_root) - initial)
|
|
if leaked:
|
|
paths = ", ".join(str(p.relative_to(repo_root)) for p in leaked[:5])
|
|
# Clean it so it never rides a git add -A into a commit, THEN fail so the
|
|
# offending test is fixed at its source (an unmocked drive_root/path).
|
|
for p in leaked:
|
|
try:
|
|
if p.is_dir():
|
|
shutil.rmtree(p, ignore_errors=True)
|
|
else:
|
|
p.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
# Fail the run loudly WITHOUT relying on pytest.Exit (absent in the pinned pytest
|
|
# version → it would crash the session with AttributeError instead of cleanly
|
|
# failing). Setting session.exitstatus marks the run failed; a printed banner names
|
|
# the offending paths so the unmocked drive_root/path is fixed at its source.
|
|
print(
|
|
f"\n\n❌ TEST POLLUTION: mock-named paths leaked into repo root (cleaned): {paths}\n",
|
|
file=sys.stderr,
|
|
)
|
|
session.exitstatus = 1
|
|
workeroutput = getattr(session.config, "workeroutput", None)
|
|
if workeroutput is not None: # xdist worker: hand the leak list to the controller
|
|
workeroutput["thread_leaks"] = list(_THREAD_LEAKS)
|
|
# Per-process temp data dir (unique mkdtemp per controller/worker) — clean on EVERY process.
|
|
if _PYTEST_DATA_DIR is not None:
|
|
shutil.rmtree(_PYTEST_DATA_DIR, ignore_errors=True)
|
|
if _PYTEST_REPO_FALLBACK is not None:
|
|
shutil.rmtree(_PYTEST_REPO_FALLBACK, ignore_errors=True)
|
|
|
|
|
|
def pytest_unconfigure(config): # noqa: ARG001
|
|
# Keep child isolation active through every session-finish hook; some tests
|
|
# exercise that hook directly before the real pytest session has ended.
|
|
_restore_pytest_child_isolation()
|
|
|
|
|
|
_PHASE_EVENT_LOOPS = pytest.StashKey()
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
def pytest_runtest_protocol(item, nextitem): # noqa: ARG001
|
|
"""Create both phase loops before fixtures can guard socket operations.
|
|
|
|
Windows loop construction opens a local socket pair. Network guards must
|
|
remain active throughout the test and its finalizers, so only construction
|
|
precedes fixture setup. This owner closes both loops even if a phase fails.
|
|
"""
|
|
loops = []
|
|
try:
|
|
loops.append(asyncio.new_event_loop())
|
|
loops.append(asyncio.new_event_loop())
|
|
item.stash[_PHASE_EVENT_LOOPS] = loops
|
|
yield
|
|
finally:
|
|
for loop in loops:
|
|
loop.close()
|
|
asyncio.set_event_loop(None)
|
|
if _PHASE_EVENT_LOOPS in item.stash:
|
|
del item.stash[_PHASE_EVENT_LOOPS]
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_call(item): # noqa: ARG001
|
|
"""Install a fresh asyncio event loop for the test *call* phase.
|
|
|
|
Problem: asyncio.run() closes the loop it creates, leaving no current
|
|
loop for the next test's asyncio.get_event_loop() call (RuntimeError).
|
|
|
|
This hook installs a fresh loop BEFORE the test body and closes it
|
|
AFTER, preventing cross-test contamination. The loop is set to None
|
|
after the call phase; a companion pytest_runtest_teardown hook
|
|
installs a temporary loop for fixture finalizers.
|
|
"""
|
|
test_loop = item.stash[_PHASE_EVENT_LOOPS][0]
|
|
asyncio.set_event_loop(test_loop)
|
|
# Thread-hygiene baseline, taken AFTER every fixture is set up: a thread a module- or
|
|
# session-scoped fixture starts on its first use (the E2E stub model server) belongs to
|
|
# that fixture for its whole scope and is not a leak of this test; only threads the TEST
|
|
# BODY leaves behind are named at teardown. Thread OBJECTS, not idents: CPython recycles
|
|
# an ident once a baseline thread exits, so a leaked thread could inherit one.
|
|
item.stash[_THREADS_BEFORE_ITEM] = set(threading.enumerate())
|
|
try:
|
|
yield # test body runs here
|
|
finally:
|
|
test_loop.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _rebind_runtime_roots_between_tests():
|
|
_bind_pytest_runtime_roots()
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _unlatch_supervisor_event_bus_between_tests():
|
|
"""A TestClient lifespan runs the server shutdown, whose ``workers.shutdown_event_q()``
|
|
latches ``_EVENT_Q_SHUTDOWN`` for the rest of the xdist worker; the next test in that
|
|
worker that publishes on the bus (``kill_workers_for_update``, a promote, a wake) then
|
|
raises "supervisor event bus is shutting down" against a fixture it never saw. Several
|
|
modules already unlatch it locally (test_promote_event_transport, test_inflight_indicator_seams);
|
|
this does it once for every test. A test that wants the latch sets it itself (monkeypatch)."""
|
|
from supervisor import workers
|
|
|
|
workers._EVENT_Q_SHUTDOWN = False
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_gateway_settings_bindings_between_tests():
|
|
"""``server._sync_gateway_settings_module()`` copies the server module's CURRENT
|
|
``load_settings`` / ``save_settings`` / ``_apply_settings_to_env`` /
|
|
``apply_runtime_provider_defaults`` onto ``ouroboros.gateway.settings`` on every
|
|
settings GET/POST, so a test that monkeypatches ``server.load_settings`` and then
|
|
hits the endpoint leaves the TEST-LOCAL loader bound on the gateway module after
|
|
its own monkeypatch is undone (monkeypatch never saw that assignment). The next
|
|
test of the same xdist worker that saves settings through the gateway then reads
|
|
stale "previous rows" and the one-time R12 disclosure fires twice
|
|
(``test_the_save_that_first_makes_the_triad_retrieve_discloses_once_with_numbers``
|
|
after ``test_review_cycles.py``). Snapshot the four bindings before each test and
|
|
restore them afterwards — the same shape as the autouse `_os_environ_isolation`
|
|
environment restore below."""
|
|
try:
|
|
from ouroboros.gateway import settings as _gateway_settings
|
|
except Exception: # pragma: no cover - the gateway package is always importable in CI
|
|
yield
|
|
return
|
|
names = ("load_settings", "save_settings", "_apply_settings_to_env", "apply_runtime_provider_defaults")
|
|
saved = {name: getattr(_gateway_settings, name, None) for name in names}
|
|
try:
|
|
yield
|
|
finally:
|
|
for name, value in saved.items():
|
|
if value is None:
|
|
continue
|
|
setattr(_gateway_settings, name, value)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _scrub_inherited_subagent_selection(monkeypatch):
|
|
"""Keep tests independent of the operator's saved actor list, account pin
|
|
and structured reviewer panel: a test that pins the legacy comma-list
|
|
branch must never read the shell's `OUROBOROS_REVIEWER_SLOTS`."""
|
|
monkeypatch.delenv("OUROBOROS_SUBAGENT_PROFILE", raising=False)
|
|
monkeypatch.delenv("OUROBOROS_SUBAGENTS", raising=False)
|
|
monkeypatch.delenv("OUROBOROS_REVIEWER_SLOTS", raising=False)
|
|
# The task's absolute ceiling bounds recorded acceptance durations; a shell
|
|
# export must not move the numbers the pacing tests derive from the getter.
|
|
monkeypatch.delenv("OUROBOROS_TASK_ABS_CEILING_SEC", raising=False)
|
|
|
|
|
|
def restored_os_environ():
|
|
"""Snapshot os.environ, yield, restore it IN PLACE (clear + update).
|
|
|
|
Restoring on the real os._Environ preserves the C-level putenv sync that
|
|
spawned subprocesses inherit from — swapping a plain dict in (the removed
|
|
monkeypatch idiom) severs it. Plain generator so the isolation contract is
|
|
directly testable without pytest plumbing.
|
|
"""
|
|
saved = dict(os.environ)
|
|
yield
|
|
os.environ.clear()
|
|
os.environ.update(saved)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _os_environ_isolation():
|
|
"""Restore the EXACT pre-test os.environ after every test.
|
|
|
|
Tests exercise apply_settings_to_env(), owner-settings writers, and ad-hoc
|
|
os.environ mutation — the benchmark launchers write it directly
|
|
(`run_tb.apply_all_model`, `fixed_model_actor_snapshot(target=os.environ)`)
|
|
and `monkeypatch.delenv(raising=False)` records nothing for a key that did
|
|
not exist; under xdist a leaked variable poisons whichever tests share the
|
|
worker afterwards (order-dependent flakes, the `benchmark-scope-1`
|
|
contamination class). One structural snapshot/restore closes the whole leak
|
|
class instead of policing each call site.
|
|
"""
|
|
yield from restored_os_environ()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_runtime_mode_baseline_between_tests():
|
|
"""v5.1.2 iter-2 test isolation fix (Gemini finding F2-7):
|
|
``ouroboros.config._BOOT_RUNTIME_MODE`` is a module-level global
|
|
pinned by ``initialize_runtime_mode_baseline``. Tests that boot a
|
|
Starlette ``TestClient`` trigger ``server.lifespan`` which pins the
|
|
baseline; subsequent tests inherit the pin and may see different
|
|
rank-comparison behaviour depending on test order. Reset to ``None``
|
|
+ remove the env var on every test boundary so each test starts
|
|
with the documented "no pin" state. Tests that need a pin call
|
|
``initialize_runtime_mode_baseline(...)`` explicitly.
|
|
"""
|
|
# The baseline reset only clears OUROBOROS_BOOT_RUNTIME_MODE; the MAIN runtime-mode
|
|
# env (`OUROBOROS_RUNTIME_MODE`, set by apply_settings_to_env/save_settings) is what
|
|
# `get_runtime_mode()` reads. The operator's inherited runtime mode must not change
|
|
# test semantics either: hermetic review intentionally loads the live non-secret
|
|
# settings before spawning pytest. Remove it for the test so the documented
|
|
# default applies; the autouse os.environ snapshot restores it afterwards.
|
|
os.environ.pop("OUROBOROS_RUNTIME_MODE", None)
|
|
try:
|
|
from ouroboros.config import reset_runtime_mode_baseline_for_tests
|
|
reset_runtime_mode_baseline_for_tests()
|
|
except Exception:
|
|
pass
|
|
yield
|
|
try:
|
|
from ouroboros.config import reset_runtime_mode_baseline_for_tests
|
|
reset_runtime_mode_baseline_for_tests()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hide_bundled_skills(monkeypatch):
|
|
"""Keep skill tests isolated from the developer machine's data plane.
|
|
|
|
v4.50: neutralise the data-plane skills lookup so a developer
|
|
machine with installed skills under ``~/Ouroboros/data/skills/`` does
|
|
not poison test results. ``discover_skills`` consults
|
|
``_resolve_data_skills_dir`` for its primary scan; pinning that to
|
|
``None`` forces tests to either pass an explicit ``drive_root`` (the
|
|
new contract since v4.50 — the helper now honours that argument)
|
|
or stick to ``OUROBOROS_SKILLS_REPO_PATH`` fixtures under tmp_path.
|
|
|
|
Production keeps the default behaviour untouched; this fixture only
|
|
neutralises global data-plane lookups inside the pytest process.
|
|
"""
|
|
# Patch the data-plane resolver to None unless the caller supplied
|
|
# an explicit ``drive_root`` (in which case the v4.50 implementation
|
|
# honours that argument and never touches the global). The signature
|
|
# check via ``*args`` keeps the fixture compatible with both the
|
|
# legacy zero-arg call and the new drive_root-aware one.
|
|
real_resolver = None
|
|
try:
|
|
import ouroboros.skill_loader as loader_mod
|
|
real_resolver = loader_mod._resolve_data_skills_dir
|
|
except Exception:
|
|
pass
|
|
|
|
def _hermetic_resolver(*args, **kwargs):
|
|
if args and args[0] is not None:
|
|
return real_resolver(*args, **kwargs) if real_resolver else None
|
|
return None
|
|
|
|
if real_resolver is not None:
|
|
monkeypatch.setattr(
|
|
"ouroboros.skill_loader._resolve_data_skills_dir",
|
|
_hermetic_resolver,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_workspace_executor_globals():
|
|
"""Isolate process/service registry module-globals between tests (parallel-safety).
|
|
|
|
Two modules keep service/process state in module-level dicts that nothing reset between tests
|
|
— a latent ordering bug that pytest-xdist's test REDISTRIBUTION exposes (a test inherits
|
|
another's leftover registry → e.g. the docker-cleanup tests flake under ``-n``):
|
|
* ``ouroboros.workspace_executor._SERVICES`` / ``_FOREGROUND`` (re-entrant ``_STATE_LOCK``);
|
|
* the legacy ``ouroboros.tools.services._SERVICES`` (a PLAIN ``_LOCK``).
|
|
Snapshot → clear → run → restore each around every test so each starts from an empty registry,
|
|
in both serial and parallel runs. Registry isolation ONLY — the records may wrap live Popen
|
|
handles, so we never terminate them (production owns process teardown). Each module is
|
|
lazy-imported under its own guard so a stripped build still collects, and only raw dict ops run
|
|
under the lock (never a services function that re-acquires the plain ``_LOCK`` → no deadlock).
|
|
Makes the ad-hoc manual ``_SERVICES.clear()`` calls in the executor tests redundant (harmless).
|
|
"""
|
|
try:
|
|
from ouroboros import workspace_executor as we
|
|
except Exception:
|
|
we = None
|
|
try:
|
|
from ouroboros.tools import services as svc
|
|
except Exception:
|
|
svc = None
|
|
if we is not None:
|
|
with we._STATE_LOCK:
|
|
saved_we_services = dict(we._SERVICES)
|
|
saved_we_foreground = dict(we._FOREGROUND)
|
|
we._SERVICES.clear()
|
|
we._FOREGROUND.clear()
|
|
if svc is not None:
|
|
with svc._LOCK:
|
|
saved_svc_services = dict(svc._SERVICES)
|
|
svc._SERVICES.clear()
|
|
try:
|
|
yield
|
|
finally:
|
|
if we is not None:
|
|
with we._STATE_LOCK:
|
|
we._SERVICES.clear()
|
|
we._SERVICES.update(saved_we_services)
|
|
we._FOREGROUND.clear()
|
|
we._FOREGROUND.update(saved_we_foreground)
|
|
if svc is not None:
|
|
with svc._LOCK:
|
|
svc._SERVICES.clear()
|
|
svc._SERVICES.update(saved_svc_services)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_repo_writer_gate():
|
|
"""Reset the process-global repo-writer admission latch between tests.
|
|
|
|
``supervisor.workers._repo_writer_gate_reason`` is process-wide by design (the
|
|
managed-update fence). A test that drives a REAL ``rollback_managed_update``
|
|
boot path closes it with ``reopen_writer_admission=False`` — deliberately, on
|
|
the production contract that a restart clears it — but the pytest process
|
|
never restarts, so the latch leaks into whatever test xdist schedules next
|
|
(e.g. the emergency-cleanup shutdown test then sees ``preserve_pending``).
|
|
Snapshot → run → restore, same pattern as the service-registry isolation."""
|
|
try:
|
|
from supervisor import workers
|
|
except Exception:
|
|
yield
|
|
return
|
|
with workers._repo_writer_gate_lock:
|
|
saved = workers._repo_writer_gate_reason
|
|
try:
|
|
yield
|
|
finally:
|
|
with workers._repo_writer_gate_lock:
|
|
workers._repo_writer_gate_reason = saved
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_teardown(item, nextitem): # noqa: ARG001
|
|
"""Keep a valid asyncio event loop available during the teardown phase.
|
|
|
|
Fixture finalizers run during teardown (LIFO order). If they call
|
|
asyncio.get_event_loop() after a test that used asyncio.run(), they
|
|
would raise RuntimeError because pytest_runtest_call already cleared
|
|
the loop. This hook installs a temporary loop for teardown and
|
|
closes it afterwards.
|
|
"""
|
|
teardown_loop = item.stash[_PHASE_EVENT_LOOPS][1]
|
|
asyncio.set_event_loop(teardown_loop)
|
|
try:
|
|
yield # fixture finalizers and teardown run here
|
|
finally:
|
|
teardown_loop.close()
|
|
asyncio.set_event_loop(None)
|
|
_fail_if_the_password_resolver_leaked(item)
|
|
_fail_if_a_thread_leaked(item)
|
|
|
|
|
|
_PRISTINE_PASSWORD_RESOLVER = None
|
|
|
|
|
|
def _fail_if_the_password_resolver_leaked(item):
|
|
"""A started-and-never-stopped ``patch("ouroboros.server_auth.get_configured_network_password")``
|
|
on a shared xdist worker made the password gate answer '' for every later module (the rc.11
|
|
macos-latest red, the rc.12 ubuntu/macos red — the victim was named, never the leaker). After
|
|
EVERY fixture of the item is torn down (monkeypatch included) the module attribute must be the
|
|
genuine function again; otherwise the test that leaked it is named here and the attribute is
|
|
restored so no victim fails by worker ordering."""
|
|
import os
|
|
|
|
import ouroboros.server_auth as server_auth
|
|
|
|
global _PRISTINE_PASSWORD_RESOLVER
|
|
current = server_auth.__dict__.get("get_configured_network_password")
|
|
genuine = (getattr(current, "__module__", None) == "ouroboros.server_auth"
|
|
and getattr(current, "__name__", "") == "get_configured_network_password")
|
|
if genuine:
|
|
_PRISTINE_PASSWORD_RESOLVER = _PRISTINE_PASSWORD_RESOLVER or current
|
|
return
|
|
server_auth.get_configured_network_password = _PRISTINE_PASSWORD_RESOLVER or (
|
|
lambda: server_auth.resolve_network_password(
|
|
os.environ.get(server_auth.NETWORK_PASSWORD_KEY, ""), server_auth.load_settings))
|
|
pytest.fail(f"{item.nodeid} left ouroboros.server_auth.get_configured_network_password patched "
|
|
f"({type(current).__name__}); a started patch was never stopped", pytrace=False)
|
|
|
|
|
|
# ---- thread hygiene: name the test that LEAKS a thread, not the victim it pollutes ----
|
|
#
|
|
# A daemon thread that outlives its test keeps running on the shared xdist worker: a 0.5 s poll
|
|
# loop lands in a later test's GLOBAL ``time.sleep`` patch (tests/test_delegate_hold.py pinned
|
|
# its backoff by presence instead of position for that), a settings-to-environment re-applier
|
|
# overwrites os.environ after the conftest snapshot restored it (tests/test_server_auth.py was
|
|
# rewritten around a pure resolver for that). Both times the victim was named and the leaker
|
|
# never was. Same shape as the password-resolver guard above: snapshot the live thread idents
|
|
# BEFORE the item's fixtures set up, and after EVERY fixture of the item is torn down every
|
|
# thread that appeared since must be gone (a bounded grace lets a stopped-but-not-joined
|
|
# thread finish); otherwise the item is failed with the thread names and recorded for the
|
|
# session report line.
|
|
_THREADS_BEFORE_ITEM = pytest.StashKey()
|
|
_THREAD_LEAKS: list = [] # (nodeid, [thread names]) — session-scoped, merged onto the controller
|
|
_THREAD_LEAK_GRACE_SEC = 2.0
|
|
# By-design detached threads, listed by name prefix — each entry names its owner and why.
|
|
_DETACHED_THREAD_NAME_PREFIXES = (
|
|
# ouroboros/project_naming.py: the inner namer call is deliberately abandoned when it
|
|
# overruns the wall-clock bound (the outer ``namer-<task_id>`` returns without joining it).
|
|
"namer-call-",
|
|
# ouroboros/gateway/onboarding.py: the idle worker of the module-lifetime single-worker
|
|
# snapshot executor — kept process-global on purpose so a retried completion JOINS an
|
|
# in-flight daemon read (issue #464) instead of starting a second blocked thread.
|
|
"onboarding-snapshot",
|
|
)
|
|
|
|
|
|
|
|
def _fail_if_a_thread_leaked(item):
|
|
before = item.stash.get(_THREADS_BEFORE_ITEM, None)
|
|
if before is None:
|
|
return
|
|
deadline = time.monotonic() + _THREAD_LEAK_GRACE_SEC
|
|
leaked = []
|
|
for thread in threading.enumerate():
|
|
if thread in before or thread is threading.current_thread():
|
|
continue
|
|
if thread.name.startswith(_DETACHED_THREAD_NAME_PREFIXES):
|
|
continue
|
|
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
|
if thread.is_alive():
|
|
leaked.append(f"{thread.name}{'' if thread.daemon else ' (non-daemon)'}")
|
|
if not leaked:
|
|
return
|
|
_THREAD_LEAKS.append((item.nodeid, leaked))
|
|
pytest.fail(f"{item.nodeid} leaked {len(leaked)} thread(s) still alive after every fixture "
|
|
f"was torn down: {', '.join(leaked)} — stop/join it at its owner (a fixture "
|
|
f"finalizer or the test's own missing stop), do not widen the tolerance of the "
|
|
f"test it pollutes", pytrace=False)
|
|
|
|
|
|
@pytest.hookimpl(optionalhook=True)
|
|
def pytest_testnodedown(node, error): # noqa: ARG001
|
|
# pytest-xdist controller: merge each worker's leak list (shipped via workeroutput below).
|
|
_THREAD_LEAKS.extend(getattr(node, "workeroutput", {}).get("thread_leaks", []))
|
|
|
|
|
|
def pytest_terminal_summary(terminalreporter):
|
|
if _THREAD_LEAKS:
|
|
tests = ", ".join(f"{nodeid} [{', '.join(names)}]" for nodeid, names in _THREAD_LEAKS)
|
|
terminalreporter.write_line(
|
|
f"thread hygiene: {sum(len(n) for _, n in _THREAD_LEAKS)} leaked thread(s) in "
|
|
f"{len(_THREAD_LEAKS)} test(s): {tests}")
|
|
else:
|
|
terminalreporter.write_line("thread hygiene: no leaked threads")
|
|
|
|
|
|
# Pre-v5.15 conftest exported four fixtures (``make_git_repo``, ``tool_context``,
|
|
# ``make_chat_mock``, ``make_extension_skill``) that no test ever requested as a
|
|
# parameter. They were removed in v5.15.0; tests build their own minimal repos /
|
|
# contexts under ``tmp_path`` because the per-test layouts diverged enough that a
|
|
# shared fixture was always wrong (different branch names, different ``ToolContext``
|
|
# shapes, ``MagicMock`` vs real, etc.).
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_direct_activities(monkeypatch):
|
|
from supervisor import active_activity
|
|
|
|
monkeypatch.setattr(active_activity, "_DIRECT_ACTIVITY_REGISTRY", active_activity.DirectActivityRegistry())
|