Files
2026-07-13 13:12:00 +08:00

1124 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for :class:`omnigent.runtime.harnesses.process_manager.HarnessProcessManager`.
Covers the lifecycle / behavior surface defined by §Process
management of ``designs/SERVER_HARNESS_CONTRACT.md``: lazy spawn,
caching, crash detection, release, idle reaping, orphan sweep.
The tests use the real fixture harness module
``tests.runtime.harnesses._test_harness`` (a minimal FastAPI app
with ``/health`` + introspection endpoints) wired through a real
runner subprocess. End-to-end with real uvicorn rather than a mock
because the spawn handshake (waiting for the socket to appear) is
what's most likely to break.
Tests intentionally use a short tmp parent dir (``/tmp/omnigent-tests-...``)
rather than pytest's :data:`tmp_path` because macOS's
``AF_UNIX`` socket path limit is ~104 chars and pytest's
per-test temp dirs (under ``/private/var/folders/...``) push the
full socket path past that ceiling. Each test cleans up its
parent dir on teardown.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import shutil
import signal
import sys
import tempfile
import time
import uuid
from collections.abc import Iterator
from pathlib import Path
import pytest
from omnigent.runtime.harnesses import _HARNESS_MODULES
from omnigent.runtime.harnesses.process_manager import (
_AP_PID_FILE,
_TMP_PARENT_ENV_VAR,
HarnessProcessManager,
NoLiveHarnessError,
_pid_alive,
_pids_holding_socket,
_SubprocessEntry,
)
_TEST_HARNESS_NAME = "test"
_TEST_HARNESS_MODULE = "tests.runtime.harnesses._test_harness"
@pytest.fixture
def register_test_harness() -> Iterator[None]:
"""
Add the test fixture harness to ``_HARNESS_MODULES`` for the
test, removing it on teardown so other tests see a clean
registry.
:yields: Nothing — fixture exists for the side effect.
"""
_HARNESS_MODULES[_TEST_HARNESS_NAME] = _TEST_HARNESS_MODULE
try:
yield
finally:
_HARNESS_MODULES.pop(_TEST_HARNESS_NAME, None)
@pytest.fixture
def short_tmp_parent() -> Iterator[Path]:
"""
Per-test parent directory under a short writable temp root.
macOS limits AF_UNIX socket paths to ~104 characters; pytest's
:data:`tmp_path` resolves to ``/private/var/folders/...``
which already eats most of that budget. Use a short
``/tmp/omni-pm-<short_uuid>`` parent when possible, falling
back to :func:`tempfile.gettempdir` for sandboxes where the
host's real ``/tmp`` is not writable.
"""
roots = [Path("/tmp")]
temp_root = Path(tempfile.gettempdir())
if temp_root not in roots:
roots.append(temp_root)
last_error: OSError | None = None
for root in roots:
parent = root / f"omni-pm-{uuid.uuid4().hex[:8]}"
try:
parent.mkdir(mode=0o700)
except OSError as exc:
last_error = exc
continue
try:
yield parent
finally:
shutil.rmtree(parent, ignore_errors=True)
return
assert last_error is not None
raise last_error
@pytest.fixture
def manager(
short_tmp_parent: Path,
register_test_harness: None,
) -> HarnessProcessManager:
"""
Manager rooted in an isolated tmp dir, with the test harness
pre-registered.
Tests own ``await manager.start()`` / ``shutdown()`` so they
can assert state across the lifecycle.
"""
return HarnessProcessManager(
# Aggressive defaults so reaper / orphan tests don't have
# to wait minutes. Individual tests override per-case.
idle_timeout_s=60.0,
reaper_interval_s=60.0,
tmp_parent=short_tmp_parent,
)
# ── Boot / shutdown ─────────────────────────────────────────────
async def test_get_client_before_start_raises(
manager: HarnessProcessManager,
) -> None:
"""Calling get_client before start() is a programming error.
Catches a regression where the lazy-init path silently
initialized state without booting the reaper — the reaper
not running would then leak subprocesses indefinitely.
"""
with pytest.raises(RuntimeError, match="before start"):
await manager.get_client("conv_x", _TEST_HARNESS_NAME)
async def test_start_creates_instance_dir_with_sentinel(
manager: HarnessProcessManager,
) -> None:
"""start() creates the per-AP-instance dir and writes AP_PID.
The sentinel is what the orphan sweep keys off; without it,
a subsequent Omnigent boot can't tell live instances from dead.
"""
await manager.start()
try:
assert manager.instance_dir.is_dir()
sentinel = manager.instance_dir / _AP_PID_FILE
assert sentinel.exists()
# The recorded PID is this process — proves the sweep on
# a sibling Omnigent boot would correctly identify us as alive.
assert sentinel.read_text(encoding="utf-8").strip() == str(os.getpid())
finally:
await manager.shutdown()
async def test_start_is_idempotent(manager: HarnessProcessManager) -> None:
"""A second start() is a no-op; doesn't recreate / relaunch.
Lifespan handlers sometimes fire start() more than once
during AP's startup; the second call must not clobber state.
"""
await manager.start()
try:
first_dir = manager.instance_dir
await manager.start()
# Same dir, still has sentinel, still serves clients.
assert manager.instance_dir == first_dir
assert (manager.instance_dir / _AP_PID_FILE).exists()
finally:
await manager.shutdown()
async def test_start_uses_harness_tmp_parent_env(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The production default socket root can be moved by env.
Local and hosted runtimes sometimes deny writes to the host's
real ``/tmp``. The process manager should honor the deployment
knob without requiring the FastAPI server to thread a test-only
constructor argument through production wiring.
"""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv(_TMP_PARENT_ENV_VAR, "harness-sockets")
manager = HarnessProcessManager()
await manager.start()
try:
assert manager.instance_dir.parent == Path("harness-sockets")
assert (manager.instance_dir / _AP_PID_FILE).read_text(encoding="utf-8")
finally:
await manager.shutdown()
async def test_shutdown_without_start_is_noop(
manager: HarnessProcessManager,
) -> None:
"""shutdown() before start() should not raise.
Defensive — AP's lifespan teardown might run after a failed
boot where start() never completed. shutdown() should be
safe to call regardless.
"""
# No start(); just shutdown.
await manager.shutdown()
# ── Spawn / cache / crash ──────────────────────────────────────
async def _ping_health(client) -> None: # type: ignore[no-untyped-def]
"""Drive a /health round-trip; raises on non-200 to fail the test."""
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
async def test_get_client_spawns_and_serves(
manager: HarnessProcessManager,
) -> None:
"""First get_client spawns the runner; /health round-trips.
End-to-end smoke test through the real runner. If this fails
most likely culprits are: PYTHONPATH not propagating to the
subprocess (the test fixture module won't import); the spawn
handshake (socket appearance polling) breaking; or the
runner CLI plumbing breaking.
"""
await manager.start()
try:
client = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
await _ping_health(client)
# The runner stashed the conversation id on app.state per
# the contract; verify the round-trip works.
cid_resp = await client.get("/conversation-id")
assert cid_resp.json() == {"conversation_id": "conv_a"}
finally:
await manager.shutdown()
async def test_get_client_caches_subprocess(
manager: HarnessProcessManager,
) -> None:
"""Subsequent get_client calls reuse the same subprocess.
Verified by hitting /pid twice and asserting the same PID —
if the manager were respawning per call, the PIDs would
differ. This test exists because spawn cost is non-trivial
(13s per uvicorn boot) and the contract explicitly says
one subprocess per conversation, lazy.
"""
await manager.start()
try:
client_a = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
pid_first = (await client_a.get("/pid")).json()["pid"]
# Same conv_id again — should hit the cached client.
client_a_again = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
assert client_a_again is client_a
pid_second = (await client_a_again.get("/pid")).json()["pid"]
# Same subprocess proves no respawn happened on the
# second call.
assert pid_first == pid_second
finally:
await manager.shutdown()
async def test_get_client_isolates_per_conversation(
manager: HarnessProcessManager,
) -> None:
"""Different conversations get different subprocesses.
Each conversation_id maps to its own subprocess per the
contract (one-conversation-one-process). The isolation is
enforced by separate Unix sockets keyed on conv_id; this
test verifies the keying actually hits distinct PIDs.
"""
await manager.start()
try:
client_a = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
client_b = await manager.get_client("conv_b", _TEST_HARNESS_NAME)
assert client_a is not client_b
pid_a = (await client_a.get("/pid")).json()["pid"]
pid_b = (await client_b.get("/pid")).json()["pid"]
# Different subprocesses → different PIDs. If they matched
# we'd be running both conversations through one process,
# violating the isolation guarantee.
assert pid_a != pid_b
finally:
await manager.shutdown()
async def test_release_terminates_subprocess(
manager: HarnessProcessManager,
) -> None:
"""release() ends the subprocess and cleans up the socket.
Verifies both halves of the teardown contract: the OS
process exits AND the socket file is removed. Either gap
leaks resources across conversation lifetimes.
"""
await manager.start()
try:
client = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
# Capture the subprocess PID via the introspection endpoint;
# if release worked, the PID won't be alive after.
pid = (await client.get("/pid")).json()["pid"]
socket_path = manager.instance_dir / "conv-conv_a.sock"
assert socket_path.exists()
await manager.release("conv_a")
# Socket cleanup is part of release's contract — leaving
# the file behind would fail uvicorn binding on a
# subsequent spawn for the same conv id.
assert not socket_path.exists()
# Process should be gone — give the OS a brief moment
# since SIGTERM → wait is async.
for _ in range(20):
if not _pid_alive(pid):
break
await asyncio.sleep(0.05)
assert not _pid_alive(pid)
finally:
await manager.shutdown()
async def test_close_entry_kills_process_when_aclose_raises(
manager: HarnessProcessManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failing ``client.aclose()`` must not skip the subprocess kill.
``_close_entry`` used to ``await client.aclose()`` first with no guard, so a
raise there (a broken transport, a wedged client) skipped the SIGTERM/SIGKILL
below and the subprocess leaked un-killed — and untracked, since ``release``
already popped the entry. Force ``aclose()`` to raise and assert the process
is still terminated (and ``release`` itself doesn't raise, since teardown is
now best-effort).
"""
await manager.start()
try:
client = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
pid = (await client.get("/pid")).json()["pid"]
entry = manager._entries["conv_a"]
async def _boom() -> None:
raise RuntimeError("simulated aclose failure")
monkeypatch.setattr(entry.client, "aclose", _boom)
# release() -> _close_entry(): the aclose failure must not prevent the
# kill, and best-effort teardown means release itself completes.
await manager.release("conv_a")
assert "conv_a" not in manager._entries
for _ in range(20):
if not _pid_alive(pid):
break
await asyncio.sleep(0.05)
assert not _pid_alive(pid), "subprocess survived a teardown where aclose() raised"
finally:
await manager.shutdown()
async def test_get_client_respawns_after_crash(
manager: HarnessProcessManager,
) -> None:
"""If the subprocess died, the next get_client respawns.
This covers the "harness crashed mid-conversation" branch in
get_client. We simulate the crash by sending SIGKILL to the
runner subprocess directly, then call get_client again and
verify a new PID comes back.
"""
await manager.start()
try:
client = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
original_pid = (await client.get("/pid")).json()["pid"]
os.kill(original_pid, signal.SIGKILL)
# Wait for the OS to mark the process dead so the next
# get_client's ``returncode`` check sees it.
for _ in range(40):
if not _pid_alive(original_pid):
break
await asyncio.sleep(0.05)
# Now get_client should detect the corpse and respawn.
new_client = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
new_pid = (await new_client.get("/pid")).json()["pid"]
# New PID proves a fresh subprocess; same PID would mean
# crash detection is broken.
assert new_pid != original_pid
assert _pid_alive(new_pid)
finally:
await manager.shutdown()
async def test_get_client_respawns_on_harness_change(
manager: HarnessProcessManager,
) -> None:
"""A different harness for the same conversation respawns the subprocess.
The socket is keyed by conversation only, so without the harness-change
branch in get_client an in-place agent switch (which resolves a new
harness for the same conv) would keep serving the OLD harness's
subprocess. We register a second harness name pointing at the same
fixture app, call get_client with each, and assert the PID changed —
proving the old subprocess was torn down and a new one spawned.
"""
# Second registry entry → same fixture app, different harness NAME.
_HARNESS_MODULES["test2"] = _TEST_HARNESS_MODULE
await manager.start()
try:
client_first = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
pid_first = (await client_first.get("/pid")).json()["pid"]
# Same conversation, DIFFERENT harness → must respawn.
client_second = await manager.get_client("conv_a", "test2")
pid_second = (await client_second.get("/pid")).json()["pid"]
# Different PID proves the harness-change branch tore down the old
# subprocess and spawned a new one. Same PID would mean the switch
# kept serving the old harness (the bug this branch fixes).
assert pid_second != pid_first
assert _pid_alive(pid_second)
# The original subprocess was terminated by the respawn's close.
for _ in range(40):
if not _pid_alive(pid_first):
break
await asyncio.sleep(0.05)
assert not _pid_alive(pid_first)
finally:
await manager.shutdown()
_HARNESS_MODULES.pop("test2", None)
async def test_get_client_any_harness_sentinel_reuses_subprocess(
manager: HarnessProcessManager,
) -> None:
"""``get_client(conv, "any")`` reuses the live subprocess — never respawns.
``"any"`` is the harness-AGNOSTIC sentinel that steering / cancel /
interrupt callers pass to reach the already-running subprocess (it is not
a real harness). It must NOT count as a harness mismatch — otherwise the
harness-change branch tears down and respawns the harness on every such
call, killing the in-flight turn. That is the regression that broke
queued-message streaming: sending a 2nd message mid-turn issues a
``get_client(conv, "any")`` steering call, which respawned the live
openai-agents subprocess and left the assistant with no output.
"""
await manager.start()
try:
client_first = await manager.get_client("conv_a", _TEST_HARNESS_NAME)
pid_first = (await client_first.get("/pid")).json()["pid"]
# Harness-agnostic sentinel → must hit the cached client, not respawn.
client_any = await manager.get_client("conv_a", "any")
assert client_any is client_first
pid_any = (await client_any.get("/pid")).json()["pid"]
# Same PID proves no respawn. A different PID means the "any" sentinel
# spuriously tripped the harness-change branch (the bug this guards).
assert pid_any == pid_first
assert _pid_alive(pid_first)
finally:
await manager.shutdown()
async def test_get_client_any_harness_sentinel_no_subprocess_raises(
manager: HarnessProcessManager,
) -> None:
"""``get_client(conv, "any")`` raises ``NoLiveHarnessError`` when no
subprocess is live.
Before the fix, this fell through to ``_spawn_entry("any", ...)``
which called ``_resolve_module_path("any")`` and raised the misleading
``RuntimeError: unknown harness 'any'; registered names: [...]``.
"""
await manager.start()
try:
with pytest.raises(NoLiveHarnessError, match="no live harness subprocess"):
await manager.get_client("conv_never_spawned", "any")
finally:
await manager.shutdown()
async def test_get_client_concurrent_first_calls_share_subprocess(
manager: HarnessProcessManager,
) -> None:
"""Concurrent first get_client calls don't race two subprocesses.
The per-conversation spawn lock should serialize the lazy-init
window so only one subprocess gets created. Verified by
issuing two get_client calls concurrently from different
asyncio tasks and asserting the same client instance comes
back from both.
"""
await manager.start()
try:
# asyncio.gather schedules both calls before either has a
# chance to populate the cache — exercises the spawn
# lock's serialization.
client_a, client_b = await asyncio.gather(
manager.get_client("conv_a", _TEST_HARNESS_NAME),
manager.get_client("conv_a", _TEST_HARNESS_NAME),
)
# Identity equality proves only one entry was created;
# without the lock, the second call would race a second
# subprocess and either race to bind the same socket
# (one fails) or succeed with two distinct entries.
assert client_a is client_b
finally:
await manager.shutdown()
# ── Idle reaping ───────────────────────────────────────────────
async def test_idle_reaper_releases_stale_entries(
register_test_harness: None,
short_tmp_parent: Path,
) -> None:
"""An entry untouched past idle_timeout_s gets reaped.
Sets a short idle timeout and a fast reaper interval so the
test completes promptly without letting the reaper kill the
subprocess before the setup health probe has completed. After
reaping, the conversation is no longer registered and its
socket file is gone.
"""
fast = HarnessProcessManager(
idle_timeout_s=2.0,
reaper_interval_s=0.1,
tmp_parent=short_tmp_parent,
)
await fast.start()
try:
await fast.get_client("conv_a", _TEST_HARNESS_NAME)
# No HTTP ping: with idle_timeout_s=0.0 the reaper can fire during an
# inline HTTP call and yank the client mid-request. Socket-existence
# loop below is the real "entry was reaped" assertion.
socket_path = fast.instance_dir / "conv-conv_a.sock"
assert socket_path.exists()
# Wait long enough for the 2s idle window plus multiple
# reaper passes. A 0s timeout races with subprocess startup
# under CI load and can close the client before the socket is
# ready to service requests.
for _ in range(60):
if not socket_path.exists():
break
await asyncio.sleep(0.1)
# If this assertion flips, the reaper isn't running OR
# isn't acting on stale entries — both regressions in
# the contract.
assert not socket_path.exists()
finally:
await fast.shutdown()
async def test_idle_reaper_survives_release_error(
register_test_harness: None,
short_tmp_parent: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``release`` failure during reaping must not kill the reaper loop.
``_idle_reaper_loop`` awaits ``self.release(conv_id)`` for each stale
entry with no guard around it. ``release`` -> ``_close_entry`` awaits
``client.aclose()`` and ``process.wait()``, any of which can raise
(broken transport, dead process, ``ProcessLookupError``). An unguarded
raise propagates out of the ``while True`` loop and the reaper task
exits permanently — and silently, since nothing awaits the dead task —
so the AP instance never reclaims another idle subprocess for the rest
of its lifetime (FD / memory / socket leak).
Inject a one-shot ``release`` failure on the first reaper-triggered
call and assert the loop keeps going: the still-stale entry is reaped
on a later pass. Before the fix the socket never disappears (the loop
died); after it, a subsequent pass reclaims it.
"""
fast = HarnessProcessManager(
idle_timeout_s=2.0,
reaper_interval_s=0.1,
tmp_parent=short_tmp_parent,
)
await fast.start()
try:
await fast.get_client("conv_a", _TEST_HARNESS_NAME)
socket_path = fast.instance_dir / "conv-conv_a.sock"
assert socket_path.exists()
# Make the first reaper-triggered release raise, then defer to the
# real release on later calls — a transient teardown failure.
real_release = fast.release
calls = {"n": 0}
async def flaky_release(conversation_id: str, **kw: object) -> None:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("simulated close failure")
await real_release(conversation_id, **kw) # type: ignore[arg-type]
monkeypatch.setattr(fast, "release", flaky_release)
# Across many reaper passes: with the bug the first raise kills the
# loop and the socket lingers; with the guard a later pass reaps it.
for _ in range(60):
if not socket_path.exists():
break
await asyncio.sleep(0.1)
assert calls["n"] >= 1, "reaper never attempted to release the stale entry"
assert not socket_path.exists(), (
"reaper died on the first release error and never reclaimed the "
"stale subprocess on a later pass"
)
finally:
await fast.shutdown()
async def test_idle_reaper_skips_in_flight_turn(
register_test_harness: None,
short_tmp_parent: Path,
) -> None:
"""A conversation with a live harness turn is never reaped mid-flight.
Regression test for #1414. ``last_used_at`` is stamped once per turn at
``get_client``, so a turn that runs longer than ``idle_timeout_s`` looks
"idle" to the reaper. The only guard against killing it —
``conv_id in _in_flight_response_ids`` — had no writers and was always
empty, so long turns were ``SIGTERM``'d mid-stream. ``mark_in_flight`` /
``clear_in_flight`` populate that guard (the runner calls them from
``proxy_stream`` on ``response.created`` and from ``_on_proxy_stream_end``).
Marks a turn in-flight, holds it well past the 2 s idle window across many
reaper passes, and asserts the subprocess survives; then clears the marker
and asserts the now-genuinely-idle entry is reaped (so the fix doesn't
leak entries that never get reclaimed — the inverse failure, cf. #1349).
"""
fast = HarnessProcessManager(
idle_timeout_s=2.0,
reaper_interval_s=0.1,
tmp_parent=short_tmp_parent,
)
await fast.start()
try:
await fast.get_client("conv_a", _TEST_HARNESS_NAME)
socket_path = fast.instance_dir / "conv-conv_a.sock"
assert socket_path.exists()
# Mark the turn live, as the runner does on ``response.created``.
fast.mark_in_flight("conv_a", "resp_x")
assert fast.has_active_turn("conv_a")
# Hold past the 2 s idle window across ~40 reaper passes (~4 s). An
# unguarded reaper would have reaped this stale-looking entry; the
# in-flight guard must keep the subprocess alive the whole time.
for _ in range(40):
await asyncio.sleep(0.1)
assert socket_path.exists(), "in-flight turn was reaped mid-flight"
# Turn ends: clear the marker (as ``_on_proxy_stream_end`` does). The
# entry is now genuinely idle and must become reapable.
fast.clear_in_flight("conv_a")
assert not fast.has_active_turn("conv_a")
for _ in range(60):
if not socket_path.exists():
break
await asyncio.sleep(0.1)
assert not socket_path.exists()
finally:
await fast.shutdown()
class _FakeReapProc:
"""Minimal process stand-in recording whether the reaper killed it."""
def __init__(self) -> None:
self.returncode: int | None = None
self.killed = False
self._done = asyncio.Event()
def send_signal(self, sig: int) -> None:
self.killed = True
self.returncode = -15
self._done.set()
def kill(self) -> None:
self.killed = True
self.returncode = -9
self._done.set()
async def wait(self) -> int | None:
await self._done.wait()
return self.returncode
class _SlowCloseClient:
"""httpx-client stand-in whose aclose() stalls, holding the reaper pass open."""
def __init__(self, delay_s: float) -> None:
self._delay_s = delay_s
async def aclose(self) -> None:
await asyncio.sleep(self._delay_s)
class _FakeEndpoint:
def cleanup(self) -> None:
pass
async def test_idle_reaper_spares_turn_started_during_pass(tmp_path: Path) -> None:
"""A turn that starts while an earlier stale entry tears down is not reaped.
The reaper snapshots its stale list under the registry lock, then releases
each entry outside it; a single teardown can hold the pass open for seconds
(graceful-SIGTERM wait). A turn that starts on a later-listed conversation
during that window refreshes ``last_used_at`` and marks itself in flight —
but the snapshot has already been taken, and ``release`` used to tear the
entry down without re-checking, SIGTERMing the subprocess mid-turn
("Harness stream connection error" seconds after messaging an idle
session). ``only_if_idle_cutoff`` re-checks idleness atomically with the
unregister, so the now-active entry is spared; the genuinely idle entry in
the same pass is still reaped, and the spared one is reclaimed by a later
pass once it goes idle again.
"""
mgr = HarnessProcessManager(idle_timeout_s=0.5, reaper_interval_s=0.2, tmp_parent=tmp_path)
e1 = _SubprocessEntry(_FakeReapProc(), _SlowCloseClient(0.6), _FakeEndpoint(), "h") # type: ignore[arg-type]
e2 = _SubprocessEntry(_FakeReapProc(), _SlowCloseClient(0.0), _FakeEndpoint(), "h") # type: ignore[arg-type]
e1.last_used_at = time.monotonic() - 100.0
e2.last_used_at = time.monotonic() - 100.0
mgr._entries = {"conv1": e1, "conv2": e2}
reaper = asyncio.create_task(mgr._idle_reaper_loop())
try:
# Wait for the pass to claim conv1 and enter its slow teardown.
deadline = time.monotonic() + 3.0
while "conv1" in mgr._entries:
assert time.monotonic() < deadline, "reaper never started a pass"
await asyncio.sleep(0.01)
# While conv1 tears down, a new turn arrives for conv2: get_client
# refreshes last_used_at and the runner marks the response in flight.
e2.last_used_at = time.monotonic()
mgr.mark_in_flight("conv2", "resp_live")
await asyncio.sleep(1.0)
assert e1.process.killed, "the genuinely idle entry must still be reaped"
assert not e2.process.killed, (
"reaper SIGTERMed a subprocess whose turn started during the pass"
)
assert "conv2" in mgr._entries
# Once the turn ends and the entry goes idle again, a later pass
# reclaims it — sparing is a deferral, not an exemption.
mgr.clear_in_flight("conv2")
e2.last_used_at = time.monotonic() - 100.0
deadline = time.monotonic() + 3.0
while not e2.process.killed:
assert time.monotonic() < deadline, "spared entry never reaped later"
await asyncio.sleep(0.05)
finally:
reaper.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reaper
async def test_idle_reaper_disabled_when_timeout_zero(
register_test_harness: None,
short_tmp_parent: Path,
) -> None:
"""A non-positive idle window disables reaping (``OMNIGENT_HARNESS_IDLE_TIMEOUT_S=0``).
Regression: ``0`` must mean "never reap", not "reap everything". Without the
``idle_timeout_s <= 0`` guard the reaper computes ``cutoff = now - 0 == now``,
and since every ``last_used_at`` is <= now it reaps every entry on the first
pass. The spawned entry must survive many fast reaper passes.
"""
fast = HarnessProcessManager(
idle_timeout_s=0.0,
reaper_interval_s=0.05,
tmp_parent=short_tmp_parent,
)
await fast.start()
try:
await fast.get_client("conv_a", _TEST_HARNESS_NAME)
socket_path = fast.instance_dir / "conv-conv_a.sock"
assert socket_path.exists()
# ~20 reaper passes at 0.05 s. With the bug the socket is gone almost
# immediately; with the guard it survives because reaping is disabled.
await asyncio.sleep(1.0)
assert socket_path.exists(), (
"idle_timeout_s=0 must DISABLE reaping, not reap every entry each pass"
)
finally:
await fast.shutdown()
async def test_orphan_sweep_removes_dead_omnigent_dirs(
short_tmp_parent: Path,
) -> None:
"""A sibling Omnigent dir with a non-running PID gets cleaned.
Plants a fake AP-instance dir under tmp_parent with an
AP_PID sentinel pointing at a non-running PID, then boots a
fresh manager. start() runs the orphan sweep, which should
remove the dead dir while leaving its own intact.
Uses ``99999999`` as the non-running PID — a valid integer
that's almost certainly not allocated. If this becomes
flaky on a future host with that pid, increase the value.
"""
fake_dir = short_tmp_parent / "ap-deaduuid"
fake_dir.mkdir(mode=0o700)
(fake_dir / _AP_PID_FILE).write_text("99999999", encoding="utf-8")
# Plant a stale socket file too so the sweep has something to
# try-and-clean (no live runner to kill, but the dir removal
# path is what matters).
(fake_dir / "conv-orphan.sock").write_text("", encoding="utf-8")
fresh = HarnessProcessManager(tmp_parent=short_tmp_parent)
await fresh.start()
try:
# The dead sibling dir is gone; ours is fresh.
assert not fake_dir.exists()
assert fresh.instance_dir.exists()
finally:
await fresh.shutdown()
async def test_orphan_sweep_preserves_live_omnigent_dirs(
short_tmp_parent: Path,
) -> None:
"""A sibling Omnigent dir with a live PID is left alone.
Plants a fake AP-instance dir whose AP_PID sentinel points at
*this test process* (which is live by definition). The
sweep on a fresh manager's start() should leave the sibling
intact — that's the zero-downtime-restart / multi-tenant
isolation guarantee from §Process management.
"""
sibling_dir = short_tmp_parent / "ap-livepid"
sibling_dir.mkdir(mode=0o700)
(sibling_dir / _AP_PID_FILE).write_text(str(os.getpid()), encoding="utf-8")
fresh = HarnessProcessManager(tmp_parent=short_tmp_parent)
await fresh.start()
try:
# If sweep removed the sibling, a concurrent Omnigent would
# have its dir deleted out from under it — exactly the
# bug the live-PID check is meant to prevent.
assert sibling_dir.exists()
assert (sibling_dir / _AP_PID_FILE).exists()
finally:
await fresh.shutdown()
# ── Helper-level tests (small, fast) ───────────────────────────
def test_pid_alive_for_self() -> None:
"""The running process is, by definition, alive.
Sanity check that the helper is wired up correctly — if this
flips, the orphan-sweep "is sibling alive?" check is broken.
"""
assert _pid_alive(os.getpid())
def test_pid_alive_for_unallocated_pid() -> None:
"""An almost-certainly-unallocated PID is not alive.
Uses ``99999999`` as a sentinel that's a valid integer pid
but vanishingly unlikely to exist. If this becomes flaky
raise the number.
"""
assert not _pid_alive(99999999)
async def test_pids_holding_socket_returns_empty_for_missing(
short_tmp_parent: Path,
) -> None:
"""``lsof`` against a nonexistent socket yields no PIDs.
Locks in the best-effort contract: the orphan sweep must
not crash if a socket file disappears between glob and
lookup. Empty list is the expected result.
"""
nonexistent = short_tmp_parent / "no-such.sock"
pids = await _pids_holding_socket(nonexistent)
assert pids == []
async def test_pids_holding_socket_returns_empty_when_lsof_is_missing(
monkeypatch: pytest.MonkeyPatch,
short_tmp_parent: Path,
) -> None:
"""Missing ``lsof`` is best-effort cleanup noise, not a boot failure."""
async def missing_lsof(*_args: object, **_kwargs: object) -> object:
raise FileNotFoundError(2, "No such file or directory", "lsof")
monkeypatch.setattr(asyncio, "create_subprocess_exec", missing_lsof)
pids = await _pids_holding_socket(short_tmp_parent / "conv-stale.sock")
assert pids == []
# ── Per-spawn env override ─────────────────────────────────────
async def test_get_client_env_override_propagates_to_subprocess(
manager: HarnessProcessManager,
) -> None:
"""``get_client(env=...)`` threads env vars into the spawned subprocess.
Verifies the v1 spec-config flow: Omnigent passes per-spec env vars
via ``env`` to ``get_client``, and the spawned subprocess
sees them in its own ``os.environ``. Without this propagation,
Omnigent would have to mutate its own ``os.environ`` (which races
across concurrent conversations with different specs).
"""
await manager.start()
try:
client = await manager.get_client(
"conv_env",
_TEST_HARNESS_NAME,
env={"HARNESS_TEST_CUSTOM": "marker_alpha"},
)
# Subprocess saw the override in its env.
resp = await client.get("/env/HARNESS_TEST_CUSTOM")
assert resp.json() == {"value": "marker_alpha"}
finally:
await manager.shutdown()
async def test_get_client_env_override_is_per_conversation(
manager: HarnessProcessManager,
) -> None:
"""Different conversations get their own ``env`` overrides.
Spawns two subprocesses with different env values for the
same env-var name; each subprocess should see ITS OWN value.
Without per-spawn isolation, AP's only option would be to
mutate ``os.environ``, which would race when concurrent
conversations want different config — the failure mode this
test guards against.
"""
await manager.start()
try:
client_a = await manager.get_client(
"conv_a",
_TEST_HARNESS_NAME,
env={"HARNESS_TEST_CUSTOM": "alpha"},
)
client_b = await manager.get_client(
"conv_b",
_TEST_HARNESS_NAME,
env={"HARNESS_TEST_CUSTOM": "beta"},
)
resp_a = await client_a.get("/env/HARNESS_TEST_CUSTOM")
resp_b = await client_b.get("/env/HARNESS_TEST_CUSTOM")
assert resp_a.json()["value"] == "alpha"
assert resp_b.json()["value"] == "beta"
finally:
await manager.shutdown()
# ── Runner subprocess cleanup ──────────────────────
async def test_runner_subprocess_exits_on_sigterm(
manager: HarnessProcessManager,
) -> None:
"""A harness runner exits promptly after a plain SIGTERM.
This catches the failure mode where ``pkill`` left
``omnigent.runtime.harnesses._runner`` processes alive because
shutdown never reached uvicorn's normal exit path.
"""
await manager.start()
try:
client = await manager.get_client("conv_sigterm", _TEST_HARNESS_NAME)
runner_pid = (await client.get("/pid")).json()["pid"]
os.kill(runner_pid, signal.SIGTERM)
for _ in range(60):
if not _pid_alive(runner_pid):
break
await asyncio.sleep(0.1)
assert not _pid_alive(runner_pid)
finally:
await manager.shutdown()
@pytest.mark.flaky(reruns=2, reruns_delay=0)
async def test_runner_subprocess_exits_when_spawning_parent_exits(
short_tmp_parent: Path,
register_test_harness: None,
) -> None:
"""A harness runner exits when its spawning parent process exits.
The helper process below starts a real ``HarnessProcessManager``
and real ``_runner`` child, then exits without shutting the
manager down. The runner's ``--parent-pid`` watchdog should
observe OS reparenting and terminate itself, preventing the
orphan accumulation.
"""
import subprocess
import textwrap
parent_script = textwrap.dedent(
f"""
import asyncio
import os
import pathlib
from omnigent.runtime.harnesses import _HARNESS_MODULES
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
async def main():
_HARNESS_MODULES[{_TEST_HARNESS_NAME!r}] = {_TEST_HARNESS_MODULE!r}
mgr = HarnessProcessManager(tmp_parent=pathlib.Path({str(short_tmp_parent)!r}))
await mgr.start()
client = await mgr.get_client('conv_parent_death', {_TEST_HARNESS_NAME!r})
pid = (await client.get('/pid')).json()['pid']
print(pid, flush=True)
os._exit(0)
asyncio.run(main())
"""
)
proc = subprocess.run(
[sys.executable, "-c", parent_script],
check=True,
text=True,
capture_output=True,
timeout=30,
env={**os.environ, "PYTHONPATH": os.getcwd()},
)
runner_pid = int(proc.stdout.strip().splitlines()[-1])
try:
for _ in range(60):
if not _pid_alive(runner_pid):
break
await asyncio.sleep(0.1)
assert not _pid_alive(runner_pid)
finally:
if _pid_alive(runner_pid):
with contextlib.suppress(ProcessLookupError):
os.kill(runner_pid, signal.SIGKILL)
async def test_runner_subprocess_hard_exits_when_sigterm_shutdown_wedges(
manager: HarnessProcessManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A SIGTERM'ed runner hard-exits even if uvicorn shutdown wedges.
The fixture starts a background task that ignores cancellation,
which prevents uvicorn's graceful shutdown from reaching lifespan
teardown. This exercises the fallback for plain
``pkill -f omnigent.runtime.harnesses._runner``: the runner
should not remain alive forever just because graceful shutdown is
stuck.
"""
monkeypatch.setenv("OMNIGENT_HARNESS_SHUTDOWN_TIMEOUT_S", "0.2")
monkeypatch.setenv("OMNIGENT_HARNESS_HARD_EXIT_TIMEOUT_S", "0.5")
await manager.start()
try:
client = await manager.get_client("conv_stuck_sigterm", _TEST_HARNESS_NAME)
runner_pid = (await client.get("/pid")).json()["pid"]
resp = await client.get("/stuck-shutdown")
assert resp.json()["status"] == "stuck_task_started"
os.kill(runner_pid, signal.SIGTERM)
for _ in range(40):
if not _pid_alive(runner_pid):
break
await asyncio.sleep(0.1)
assert not _pid_alive(runner_pid)
finally:
await manager.shutdown()
async def test_orphan_sweep_escalates_to_sigkill(
short_tmp_parent: Path,
register_test_harness: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Orphan sweep SIGKILLs runners that survive SIGTERM."""
from omnigent.runtime.harnesses import process_manager as pm_mod
killed: list[tuple[int, signal.Signals]] = []
calls = 0
async def fake_pids_holding_socket(socket_path: Path) -> list[int]:
assert socket_path.name == "conv-stale.sock"
return [12345]
def fake_pid_alive(pid: int) -> bool:
assert pid == 12345
return True
def fake_kill(pid: int, sig: signal.Signals) -> None:
nonlocal calls
calls += 1
assert pid == 12345
killed.append((pid, sig))
monkeypatch.setattr(pm_mod, "_ORPHAN_SIGTERM_GRACE_S", 0)
monkeypatch.setattr(pm_mod, "_pids_holding_socket", fake_pids_holding_socket)
monkeypatch.setattr(pm_mod, "_pid_alive", fake_pid_alive)
monkeypatch.setattr(pm_mod.os, "kill", fake_kill)
instance_dir = short_tmp_parent / "ap-dead"
instance_dir.mkdir()
(instance_dir / "conv-stale.sock").touch()
mgr = HarnessProcessManager(tmp_parent=short_tmp_parent)
await mgr._kill_orphan_runners(instance_dir)
assert calls == 2
assert killed == [(12345, signal.SIGTERM), (12345, signal.SIGKILL)]