414 lines
16 KiB
Python
414 lines
16 KiB
Python
"""E2E coverage for the ``omnigent host`` Ctrl+C stop-server prompt.
|
|
|
|
``omnigent host ""`` (local mode) spawns a *detached* background local
|
|
AP server (:func:`omnigent.host.local_server.ensure_local_omnigent_server`) that
|
|
intentionally outlives the foreground host daemon so sessions and the Web
|
|
UI stay reachable across ``host`` / ``run``. Because users expect Ctrl+C
|
|
to stop "everything", the connect command now prompts on a clean stop:
|
|
|
|
Stop it too? [y/N]
|
|
|
|
These tests drive the real CLI under a PTY, send a real SIGINT (Ctrl+C),
|
|
and verify the branches against the *actual* server process:
|
|
|
|
1. Answering ``y`` stops the detached server — its ``/health`` endpoint
|
|
stops responding.
|
|
2. Answering ``n`` leaves the server running — ``/health`` still answers
|
|
200 after the host process has exited. The test then stops the
|
|
stranded server itself so it does not leak past the test.
|
|
3. When connect *reuses* a server it did not spawn (one already brought up
|
|
by a prior daemon, via the real ``ensure_local_omnigent_server`` path), Ctrl+C
|
|
shows NO prompt and leaves that server running — connect must never
|
|
offer to stop a server it didn't start.
|
|
|
|
The detached server is the genuine production object (a real ``omnigent
|
|
server`` subprocess), so these tests fail loudly if the prompt is dropped,
|
|
wired to the wrong default, fires for a reused server, or if ``y`` fails to
|
|
actually terminate the server — none of which a mock-based test would catch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import threading
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pexpect
|
|
|
|
# The host daemon's WS tunnel + local-server boot take the same path the
|
|
# REPL lifecycle e2e exercises; 90s mirrors that suite's readiness budget so
|
|
# a cold import + server start on a loaded CI box still settles.
|
|
_BOOT_TIMEOUT = 90.0
|
|
_PROMPT_TIMEOUT = 30.0
|
|
_EXIT_TIMEOUT = 30.0
|
|
# Bounded poll budget for observing the detached server flip up/down. The
|
|
# server lives in another process, so we poll its /health rather than wait on
|
|
# an in-process signal.
|
|
_HEALTH_POLL_TIMEOUT = 30.0
|
|
|
|
# ``run_host_process`` prints this once the host tunnel is up and the local
|
|
# server is confirmed reachable — our readiness signal that Ctrl+C will now
|
|
# land inside the asyncio run loop (not mid-server-spawn).
|
|
_LISTENING_MARKER = "Listening for sessions"
|
|
# The ``click.confirm`` prompt text emitted on a clean stop in local mode.
|
|
_PROMPT_MARKER = "Stop it too?"
|
|
_STOPPED_MARKER = "Stopped the local server"
|
|
_LEFT_RUNNING_MARKER = "Left the local server running"
|
|
|
|
# Shared, interruptible sleep handle for bounded external-state polling (no
|
|
# raw time.sleep — see omnigent-testing rule 13).
|
|
_POLL_PAUSE = threading.Event()
|
|
|
|
|
|
def _connect_env(base_env: Mapping[str, str], home: Path) -> dict[str, str]:
|
|
"""
|
|
Build the subprocess environment for a ``host`` PTY run.
|
|
|
|
Isolates ``HOME`` so the local-server pidfile, host registry, and sqlite
|
|
db land under the per-test directory (``ensure_local_omnigent_server`` keys its
|
|
data dir off ``~/.omnigent`` when ``OMNIGENT_DATA_DIR`` is unset),
|
|
keeping the test from touching the developer's real local server.
|
|
|
|
:param base_env: Fixture-provided credentials environment, e.g.
|
|
``mock_credentials_env``.
|
|
:param home: Isolated HOME for this test's runtime data.
|
|
:returns: Environment dict for ``pexpect.spawn``.
|
|
"""
|
|
env = dict(base_env)
|
|
env["HOME"] = str(home)
|
|
env["TERM"] = "xterm-256color"
|
|
env["LINES"] = "40"
|
|
env["COLUMNS"] = "120"
|
|
return env
|
|
|
|
|
|
def _spawn_connect(
|
|
omnigent_python: Path,
|
|
repo_root: Path,
|
|
env: Mapping[str, str],
|
|
) -> pexpect.spawn:
|
|
"""
|
|
Spawn ``omnigent host ""`` (local mode) under a real PTY.
|
|
|
|
The empty positional argument selects local mode — connect spawns (or
|
|
reuses) the detached local Omnigent server and connects the foreground daemon
|
|
to it. Databricks auth comes from the env (the ``--profile`` flag was
|
|
removed from the omnigent CLI).
|
|
|
|
:param omnigent_python: Python interpreter with Omnigent installed.
|
|
:param repo_root: Checkout root used as the subprocess cwd.
|
|
:param env: Subprocess environment from :func:`_connect_env`.
|
|
:returns: A live pexpect child.
|
|
"""
|
|
return pexpect.spawn(
|
|
str(omnigent_python),
|
|
["-m", "omnigent", "host", ""],
|
|
env=dict(env),
|
|
cwd=str(repo_root),
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
timeout=_BOOT_TIMEOUT,
|
|
dimensions=(40, 120),
|
|
)
|
|
|
|
|
|
def _read_local_server_record(home: Path) -> tuple[int, int]:
|
|
"""
|
|
Read the detached local server's pid + port from the pidfile.
|
|
|
|
:param home: Isolated HOME passed to the connect subprocess.
|
|
:returns: ``(pid, port)`` recorded by ``ensure_local_omnigent_server``.
|
|
:raises AssertionError: If the pidfile is missing or malformed.
|
|
"""
|
|
pid_path = home / ".omnigent" / "local_server.pid"
|
|
try:
|
|
lines = pid_path.read_text().strip().splitlines()
|
|
return int(lines[0]), int(lines[1])
|
|
except (IndexError, OSError, ValueError) as exc:
|
|
raise AssertionError(f"missing or malformed local server pidfile at {pid_path}") from exc
|
|
|
|
|
|
def _server_healthy(port: int) -> bool:
|
|
"""
|
|
Return whether the local server answers ``/health`` with 200.
|
|
|
|
:param port: Loopback port the server bound, e.g. ``8000``.
|
|
:returns: ``True`` when ``/health`` responds 200, ``False`` on any
|
|
non-200 or transport error (connection refused → server down).
|
|
"""
|
|
try:
|
|
resp = httpx.get(f"http://127.0.0.1:{port}/health", timeout=2.0)
|
|
except httpx.HTTPError:
|
|
return False
|
|
return resp.status_code == 200
|
|
|
|
|
|
def _wait_for_health(port: int, *, expected: bool, timeout: float) -> bool:
|
|
"""
|
|
Poll ``/health`` until it reaches the expected up/down state.
|
|
|
|
:param port: Loopback port the server bound.
|
|
:param expected: Target reachability — ``True`` waits for the server to
|
|
be up, ``False`` waits for it to go down.
|
|
:param timeout: Max seconds to poll.
|
|
:returns: ``True`` if the expected state was observed within *timeout*,
|
|
else ``False``.
|
|
"""
|
|
elapsed = 0.0
|
|
while elapsed < timeout:
|
|
if _server_healthy(port) == expected:
|
|
return True
|
|
# Bounded pause between external-process probes (rule 13).
|
|
_POLL_PAUSE.wait(0.25)
|
|
elapsed += 0.25
|
|
return _server_healthy(port) == expected
|
|
|
|
|
|
def _force_stop_server(pid: int) -> None:
|
|
"""
|
|
Best-effort SIGTERM the detached local server so it never leaks.
|
|
|
|
:param pid: Recorded server process id.
|
|
:returns: None.
|
|
"""
|
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
os.kill(pid, signal.SIGTERM)
|
|
|
|
|
|
def _boot_connect_and_get_server(child: pexpect.spawn, home: Path) -> tuple[int, int]:
|
|
"""
|
|
Wait for connect to be fully up and return the live server's pid/port.
|
|
|
|
Blocks on the ``Listening for sessions`` banner (proves the tunnel is up
|
|
and we are inside the asyncio run loop, so a subsequent SIGINT lands on
|
|
the clean-stop path), then confirms the detached server actually answers
|
|
``/health`` before the test proceeds.
|
|
|
|
:param child: Live connect pexpect child.
|
|
:param home: Isolated HOME holding the server pidfile.
|
|
:returns: ``(pid, port)`` of the running detached server.
|
|
"""
|
|
child.expect(_LISTENING_MARKER, timeout=_BOOT_TIMEOUT)
|
|
pid, port = _read_local_server_record(home)
|
|
assert _wait_for_health(port, expected=True, timeout=_HEALTH_POLL_TIMEOUT), (
|
|
f"detached local server on port {port} never became healthy after the "
|
|
f"host daemon reported it was listening"
|
|
)
|
|
return pid, port
|
|
|
|
|
|
def _prespawn_persistent_server(
|
|
omnigent_python: Path,
|
|
repo_root: Path,
|
|
env: Mapping[str, str],
|
|
) -> tuple[int, int]:
|
|
"""
|
|
Bring up the persistent local server the way a prior daemon would.
|
|
|
|
Invokes the real :func:`ensure_local_omnigent_server` in a short-lived
|
|
subprocess so it spawns the detached server AND stamps the config-
|
|
signature sidecar with the same auth config a later ``host`` will
|
|
compute (both subprocesses share the same env, so the signatures
|
|
agree). That matching signature is what makes connect *reuse* this
|
|
server (``spawned=False``) instead of treating it as config drift and
|
|
respawning — i.e. it reproduces "a server is already running that connect
|
|
did not start".
|
|
|
|
:param omnigent_python: Python interpreter with Omnigent installed.
|
|
:param repo_root: Checkout root used as the subprocess cwd.
|
|
:param env: Subprocess environment (isolated HOME) from
|
|
:func:`_connect_env`.
|
|
:returns: ``(pid, port)`` of the running detached server.
|
|
"""
|
|
code = (
|
|
"from omnigent.host.local_server import ensure_local_omnigent_server;"
|
|
"print(ensure_local_omnigent_server().url)"
|
|
)
|
|
proc = subprocess.run(
|
|
[str(omnigent_python), "-c", code],
|
|
env=dict(env),
|
|
cwd=str(repo_root),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_BOOT_TIMEOUT,
|
|
)
|
|
home = Path(env["HOME"])
|
|
# The subprocess detaches the server (start_new_session=True) before
|
|
# returning, so any failure past the spawn would otherwise leak a live
|
|
# server into later tests. Stop it via the pidfile before re-raising.
|
|
try:
|
|
assert proc.returncode == 0, f"pre-spawn failed (rc={proc.returncode}):\n{proc.stderr}"
|
|
pid, port = _read_local_server_record(home)
|
|
assert _wait_for_health(port, expected=True, timeout=_HEALTH_POLL_TIMEOUT), (
|
|
f"pre-spawned local server on port {port} never became healthy"
|
|
)
|
|
return pid, port
|
|
except BaseException:
|
|
with contextlib.suppress(AssertionError, OSError, ValueError, IndexError):
|
|
leaked_pid, _leaked_port = _read_local_server_record(home)
|
|
_force_stop_server(leaked_pid)
|
|
raise
|
|
|
|
|
|
def test_host_ctrl_c_yes_stops_local_server(
|
|
omnigent_python: Path,
|
|
omnigent_repo_root: Path,
|
|
mock_credentials_env: dict[str, str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Ctrl+C then ``y`` stops the detached local server.
|
|
|
|
:param omnigent_python: Python interpreter fixture.
|
|
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
|
|
:param mock_credentials_env: Mock-LLM credential environment fixture.
|
|
:param tmp_path: Per-test temp directory.
|
|
:returns: None.
|
|
"""
|
|
home = tmp_path / "home"
|
|
env = _connect_env(mock_credentials_env, home)
|
|
child = _spawn_connect(omnigent_python, omnigent_repo_root, env)
|
|
server_pid = -1
|
|
try:
|
|
server_pid, port = _boot_connect_and_get_server(child, home)
|
|
|
|
# Real SIGINT to the foreground host process group. The detached
|
|
# server runs in its own session (start_new_session=True) so it does
|
|
# NOT receive this signal — only the prompt decides its fate.
|
|
child.sendcontrol("c")
|
|
child.expect(_PROMPT_MARKER, timeout=_PROMPT_TIMEOUT)
|
|
child.send("y\r")
|
|
|
|
# The prompt's success line proves stop_local_omnigent_server() was invoked.
|
|
child.expect(_STOPPED_MARKER, timeout=_PROMPT_TIMEOUT)
|
|
child.expect(pexpect.EOF, timeout=_EXIT_TIMEOUT)
|
|
|
|
# The decisive end-to-end assertion: the real server process actually
|
|
# went down. If the prompt's "yes" branch were wired wrong (or stop
|
|
# was a no-op), /health would keep answering 200 here.
|
|
assert _wait_for_health(port, expected=False, timeout=_HEALTH_POLL_TIMEOUT), (
|
|
f"local server on port {port} was still healthy after answering "
|
|
f"'y' — the stop-server prompt did not actually stop it"
|
|
)
|
|
finally:
|
|
if server_pid > 0:
|
|
_force_stop_server(server_pid)
|
|
if not child.closed:
|
|
child.close(force=True)
|
|
|
|
|
|
def test_host_ctrl_c_no_leaves_local_server_running(
|
|
omnigent_python: Path,
|
|
omnigent_repo_root: Path,
|
|
mock_credentials_env: dict[str, str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Ctrl+C then ``n`` leaves the detached local server running.
|
|
|
|
The test stops the surviving server itself in teardown so it does not
|
|
leak past the test (mirroring how a user would reuse it across
|
|
``host`` / ``run`` and stop it later).
|
|
|
|
:param omnigent_python: Python interpreter fixture.
|
|
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
|
|
:param mock_credentials_env: Mock-LLM credential environment fixture.
|
|
:param tmp_path: Per-test temp directory.
|
|
:returns: None.
|
|
"""
|
|
home = tmp_path / "home"
|
|
env = _connect_env(mock_credentials_env, home)
|
|
child = _spawn_connect(omnigent_python, omnigent_repo_root, env)
|
|
server_pid = -1
|
|
try:
|
|
server_pid, port = _boot_connect_and_get_server(child, home)
|
|
|
|
child.sendcontrol("c")
|
|
child.expect(_PROMPT_MARKER, timeout=_PROMPT_TIMEOUT)
|
|
child.send("n\r")
|
|
|
|
# The decline line proves we took the "leave it running" branch.
|
|
child.expect(_LEFT_RUNNING_MARKER, timeout=_PROMPT_TIMEOUT)
|
|
child.expect(pexpect.EOF, timeout=_EXIT_TIMEOUT)
|
|
|
|
# The decisive end-to-end assertion: the real server is STILL up after
|
|
# the host process exited. If "no" accidentally stopped it (or the
|
|
# default were inverted), /health would fail here.
|
|
assert _server_healthy(port), (
|
|
f"local server on port {port} was stopped after answering 'n' — "
|
|
f"declining the prompt must leave the detached server running"
|
|
)
|
|
finally:
|
|
# The whole point of "no" is that the server survives the connect
|
|
# process, so the test owns stopping it to avoid leaking a server.
|
|
if server_pid > 0:
|
|
_force_stop_server(server_pid)
|
|
if not child.closed:
|
|
child.close(force=True)
|
|
|
|
|
|
def test_host_ctrl_c_reused_server_shows_no_prompt(
|
|
omnigent_python: Path,
|
|
omnigent_repo_root: Path,
|
|
mock_credentials_env: dict[str, str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Ctrl+C shows NO prompt when connect reused a server it did not spawn.
|
|
|
|
A server is brought up first via the real ``ensure_local_omnigent_server`` path
|
|
(as a prior ``run`` / ``host`` daemon would), then ``connect ""``
|
|
reuses it. On Ctrl+C there must be no stop-server prompt — connect must
|
|
never offer to stop a server it didn't start — and the server must still
|
|
be running after connect exits.
|
|
|
|
:param omnigent_python: Python interpreter fixture.
|
|
:param omnigent_repo_root: Repo root fixture (subprocess cwd).
|
|
:param mock_credentials_env: Mock-LLM credential environment fixture.
|
|
:param tmp_path: Per-test temp directory.
|
|
:returns: None.
|
|
"""
|
|
home = tmp_path / "home"
|
|
env = _connect_env(mock_credentials_env, home)
|
|
|
|
# Bring the server up first, independently of connect, with a config
|
|
# signature that matches what connect will compute — so connect reuses it.
|
|
server_pid, port = _prespawn_persistent_server(omnigent_python, omnigent_repo_root, env)
|
|
child: pexpect.spawn | None = None
|
|
try:
|
|
child = _spawn_connect(omnigent_python, omnigent_repo_root, env)
|
|
# Connect attaches to the already-running server (same pid/port — it
|
|
# reused it rather than spawning a new one).
|
|
child.expect(_LISTENING_MARKER, timeout=_BOOT_TIMEOUT)
|
|
reused_pid, reused_port = _read_local_server_record(home)
|
|
assert (reused_pid, reused_port) == (server_pid, port), (
|
|
f"connect did not reuse the pre-spawned server: expected "
|
|
f"pid/port {(server_pid, port)}, pidfile now shows "
|
|
f"{(reused_pid, reused_port)}"
|
|
)
|
|
|
|
child.sendcontrol("c")
|
|
# Connect must exit cleanly with NO prompt. If the prompt fired,
|
|
# ``click.confirm`` would block on stdin (we send nothing), so we'd
|
|
# match the prompt marker instead of EOF — caught explicitly below.
|
|
idx = child.expect([pexpect.EOF, _PROMPT_MARKER], timeout=_EXIT_TIMEOUT)
|
|
assert idx == 0, (
|
|
"connect offered to stop a server it reused (did not spawn) — the "
|
|
"stop-server prompt must only appear for a server connect started"
|
|
)
|
|
|
|
# The reused server is untouched and still serving.
|
|
assert _server_healthy(port), (
|
|
f"reused local server on port {port} went down after connect exited "
|
|
f"— connect must not stop a server it did not spawn"
|
|
)
|
|
finally:
|
|
_force_stop_server(server_pid)
|
|
if child is not None and not child.closed:
|
|
child.close(force=True)
|