fb329e4aaa
* feat: add thread metadata index for improved performance in thread listing - Implemented a new SQLite index on the `checkpoints` table to optimize thread listing queries by indexing relevant metadata fields. - Updated the `list_threads` function to ensure the index is created if it does not exist. - Added a test to verify the creation of the metadata index during thread listing. feat: enhance workspace sidecar management with owner tracking - Modified the workspace sidecar to include `owner_pids` to track the current process owners. - Updated tests to validate the new owner tracking functionality and ensure proper behavior when managing workspace sidecars. chore: introduce model registry for streamlined model management - Created a new `registry.py` file to maintain a comprehensive model registry, including model names, IDs, providers, and routing tables. - Added functions to retrieve models by provider and list available models, enhancing the modularity and maintainability of model management. * feat: enhance workspace sidecar management and improve thread metadata indexing * fix(tests): ensure sidecar correctly registers owner with original workspace and pid * refactor: simplify workspace sidecar management by removing owner tracking * feat(server): add commands to manage background langgraph dev server - Introduced `server_app` for managing the langgraph dev server with commands to check status and stop the server. - Enhanced workspace sidecar management to include configuration fingerprint for drift detection. - Updated deployment functions to handle server configuration and state more effectively. * feat(server): enhance server status command to display PID with stale record warning * feat(langgraph_dev): exclusion-set config fingerprint, webui keepalive, unified stop guidance * fix(cli): platform-specific manual-stop hint; document keepalive endpoint-change limitation
562 lines
20 KiB
Python
562 lines
20 KiB
Python
"""Tests for ``EvoSci deploy`` command flow.
|
|
|
|
Verifies the orchestration:
|
|
- workspace resolution (CLI > config > cwd)
|
|
- port resolution (CLI > config > default)
|
|
- host resolution (CLI > config > default) + the public-bind warning
|
|
- port collision pre-flight
|
|
- ccproxy lifecycle (only if OAuth configured)
|
|
- ``start_langgraph_dev(deploy_mode=True)`` invocation
|
|
- clean shutdown via signal handler / KeyboardInterrupt
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from EvoScientist.deploy import server as deploy_server
|
|
|
|
|
|
def _make_config(
|
|
*,
|
|
default_workdir: str = "",
|
|
langgraph_dev_port: int = 6174,
|
|
langgraph_dev_host: str = "127.0.0.1",
|
|
anthropic_auth_mode: str = "api_key",
|
|
openai_auth_mode: str = "api_key",
|
|
log_level: str = "warning",
|
|
langgraph_dev_jobs_per_worker: int = 10,
|
|
langgraph_dev_file_persistence: bool = True,
|
|
dangerous_mode: bool = False,
|
|
):
|
|
return SimpleNamespace(
|
|
default_workdir=default_workdir,
|
|
langgraph_dev_port=langgraph_dev_port,
|
|
langgraph_dev_host=langgraph_dev_host,
|
|
anthropic_auth_mode=anthropic_auth_mode,
|
|
openai_auth_mode=openai_auth_mode,
|
|
log_level=log_level,
|
|
langgraph_dev_jobs_per_worker=langgraph_dev_jobs_per_worker,
|
|
langgraph_dev_file_persistence=langgraph_dev_file_persistence,
|
|
dangerous_mode=dangerous_mode,
|
|
)
|
|
|
|
|
|
class _ImmediateEvent:
|
|
"""Fake ``threading.Event``: first ``is_set()`` returns False so the
|
|
while-loop body runs once; subsequent calls return True, exiting the
|
|
loop. ``wait`` is a no-op (no real blocking)."""
|
|
|
|
def __init__(self):
|
|
self._called = 0
|
|
self.set_was_called = False
|
|
|
|
def is_set(self) -> bool:
|
|
self._called += 1
|
|
return self._called > 1
|
|
|
|
def wait(self, timeout: float | None = None):
|
|
return None
|
|
|
|
def set(self):
|
|
self.set_was_called = True
|
|
self._called = 99
|
|
|
|
|
|
def _run_deploy_once(
|
|
monkeypatch,
|
|
config,
|
|
*,
|
|
workdir: str | None = None,
|
|
port: int | None = None,
|
|
host: str | None = None,
|
|
debug: bool = False,
|
|
cwd: str | None = None,
|
|
port_occupied: bool = False,
|
|
langgraph_dev_running: bool = True, # health-check passes after start
|
|
tunnel: bool = False,
|
|
tunnel_url: str | None = None,
|
|
):
|
|
"""Run ``deploy()`` end-to-end with all external dependencies mocked.
|
|
Returns a ``captured`` dict with observation points."""
|
|
import EvoScientist.config as config_mod
|
|
|
|
captured: dict[str, Any] = {
|
|
"ccproxy_started": False,
|
|
"ccproxy_stopped": False,
|
|
"langgraph_dev_started": False,
|
|
"langgraph_dev_stopped": False,
|
|
"deploy_mode_passed": None,
|
|
"workspace_passed": None,
|
|
"port_passed": None,
|
|
"host_passed": None,
|
|
"printed": [],
|
|
"atexit_callbacks": [],
|
|
}
|
|
|
|
def _fake_get_effective_config(cli_overrides=None):
|
|
captured["cli_overrides"] = dict(cli_overrides or {})
|
|
merged = vars(config).copy()
|
|
merged.update(cli_overrides or {})
|
|
return SimpleNamespace(**merged)
|
|
|
|
monkeypatch.setattr(config_mod, "get_effective_config", _fake_get_effective_config)
|
|
monkeypatch.setattr(config_mod, "apply_config_to_env", lambda _cfg: None)
|
|
|
|
monkeypatch.setattr(deploy_server, "console", _SilentConsole(captured["printed"]))
|
|
|
|
# Workspace setup mocks
|
|
from EvoScientist import paths as paths_mod
|
|
|
|
monkeypatch.setattr(paths_mod, "set_workspace_root", lambda _p: None)
|
|
monkeypatch.setattr(paths_mod, "ensure_dirs", lambda: None)
|
|
|
|
# langgraph_dev.manager mocks
|
|
from EvoScientist.langgraph_dev import manager as lgm
|
|
|
|
monkeypatch.setattr(lgm, "_is_port_occupied", lambda _p, *_a, **_kw: port_occupied)
|
|
monkeypatch.setattr(
|
|
lgm,
|
|
"is_langgraph_dev_running",
|
|
lambda **_kw: langgraph_dev_running,
|
|
)
|
|
|
|
def _fake_start_langgraph_dev(
|
|
workspace_dir=None,
|
|
*,
|
|
port=None,
|
|
host=None,
|
|
file_persistence=True,
|
|
jobs_per_worker=10,
|
|
deploy_mode=False,
|
|
tunnel=False,
|
|
config_fingerprint=None,
|
|
):
|
|
captured["langgraph_dev_started"] = True
|
|
captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None
|
|
captured["port_passed"] = port
|
|
captured["host_passed"] = host
|
|
captured["deploy_mode_passed"] = deploy_mode
|
|
captured["jobs_per_worker_passed"] = jobs_per_worker
|
|
captured["file_persistence_passed"] = file_persistence
|
|
captured["tunnel_passed"] = tunnel
|
|
return SimpleNamespace(pid=99999)
|
|
|
|
def _fake_stop_langgraph_dev(_proc=None):
|
|
captured["langgraph_dev_stopped"] = True
|
|
|
|
monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev)
|
|
monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop_langgraph_dev)
|
|
# Never poll a real log for the tunnel URL in tests.
|
|
monkeypatch.setattr(
|
|
lgm, "read_tunnel_url", lambda *a, **k: tunnel_url, raising=False
|
|
)
|
|
|
|
# ccproxy mocks
|
|
from EvoScientist import ccproxy_manager as ccp
|
|
|
|
def _fake_maybe_start_ccproxy(_cfg):
|
|
captured["ccproxy_started"] = True
|
|
return SimpleNamespace(pid=88888)
|
|
|
|
def _fake_stop_ccproxy(_proc):
|
|
captured["ccproxy_stopped"] = True
|
|
|
|
monkeypatch.setattr(ccp, "maybe_start_ccproxy", _fake_maybe_start_ccproxy)
|
|
monkeypatch.setattr(ccp, "stop_ccproxy", _fake_stop_ccproxy)
|
|
|
|
# atexit mock — capture without executing (don't pollute test process)
|
|
import atexit
|
|
|
|
def _fake_atexit_register(fn, *args, **kwargs):
|
|
captured["atexit_callbacks"].append((fn.__name__, args, kwargs))
|
|
return fn
|
|
|
|
monkeypatch.setattr(atexit, "register", _fake_atexit_register)
|
|
|
|
# signal mock — capture handlers so tests can exercise _handle_shutdown.
|
|
# Returning a no-op original means deploy()'s finally block restores
|
|
# something harmless onto the real signal module.
|
|
import signal
|
|
|
|
# deploy() calls signal.signal twice per signum: first to install
|
|
# _handle_shutdown, then in finally to restore the original. We want the
|
|
# first (real) handler — keep first-write-wins semantics.
|
|
captured["signal_handlers"] = {}
|
|
|
|
def _capture_signal(signum, handler):
|
|
if signum not in captured["signal_handlers"]:
|
|
captured["signal_handlers"][signum] = handler
|
|
return lambda *_a, **_kw: None
|
|
|
|
monkeypatch.setattr(signal, "signal", _capture_signal)
|
|
|
|
# threading.Event mock — exits the wait loop after one iteration;
|
|
# factory captures the instance so tests can inspect set() calls.
|
|
import threading
|
|
|
|
def _make_event():
|
|
ev = _ImmediateEvent()
|
|
captured["event_instance"] = ev
|
|
return ev
|
|
|
|
monkeypatch.setattr(threading, "Event", _make_event)
|
|
|
|
# os.makedirs / os.getcwd
|
|
import os
|
|
|
|
monkeypatch.setattr(os, "makedirs", lambda *a, **k: None)
|
|
if cwd is not None:
|
|
monkeypatch.setattr(os, "getcwd", lambda: cwd)
|
|
|
|
deploy_server.deploy(
|
|
workdir=workdir, port=port, host=host, debug=debug, tunnel=tunnel
|
|
)
|
|
return captured
|
|
|
|
|
|
class _SilentConsole:
|
|
"""Stand-in for the Rich console — swallows all output so test runs
|
|
don't spew ANSI to the captured pytest output (but doesn't break the
|
|
code paths that call ``console.print`` / ``console.status``).
|
|
|
|
Optionally records what was printed so tests can assert on banners
|
|
(e.g. the public-bind warning) without letting them reach the terminal.
|
|
"""
|
|
|
|
def __init__(self, sink: list | None = None):
|
|
self._sink = sink
|
|
|
|
def print(self, *args, **kwargs):
|
|
if self._sink is not None:
|
|
self._sink.append(" ".join(str(a) for a in args))
|
|
|
|
def status(self, *args, **kwargs):
|
|
class _Ctx:
|
|
def __enter__(self_inner):
|
|
return self_inner
|
|
|
|
def __exit__(self_inner, *a):
|
|
return False
|
|
|
|
return _Ctx()
|
|
|
|
|
|
# =============================================================================
|
|
# Tests
|
|
# =============================================================================
|
|
|
|
|
|
def test_deploy_starts_langgraph_dev_with_deploy_mode_true(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["langgraph_dev_started"] is True
|
|
assert captured["deploy_mode_passed"] is True, (
|
|
"deploy command MUST call start_langgraph_dev with deploy_mode=True"
|
|
)
|
|
|
|
|
|
def test_deploy_tunnel_default_off(monkeypatch, tmp_path):
|
|
"""Without ``--tunnel``, start_langgraph_dev is called with tunnel=False."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["tunnel_passed"] is False
|
|
|
|
|
|
def test_deploy_tunnel_flag_passed_through(monkeypatch, tmp_path):
|
|
"""``--tunnel`` propagates to start_langgraph_dev(tunnel=True)."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(
|
|
monkeypatch,
|
|
config,
|
|
tunnel=True,
|
|
tunnel_url="https://demo-xyz.trycloudflare.com",
|
|
)
|
|
|
|
assert captured["tunnel_passed"] is True
|
|
|
|
|
|
def test_deploy_workdir_cli_arg_beats_config(monkeypatch, tmp_path):
|
|
cli_ws = tmp_path / "cli_ws"
|
|
cfg_ws = tmp_path / "cfg_ws"
|
|
config = _make_config(default_workdir=str(cfg_ws))
|
|
captured = _run_deploy_once(monkeypatch, config, workdir=str(cli_ws))
|
|
|
|
assert captured["workspace_passed"] == str(cli_ws)
|
|
|
|
|
|
def test_deploy_workdir_config_beats_cwd(monkeypatch, tmp_path):
|
|
cfg_ws = tmp_path / "cfg_ws"
|
|
config = _make_config(default_workdir=str(cfg_ws))
|
|
captured = _run_deploy_once(monkeypatch, config, cwd="/tmp/should_not_be_used")
|
|
|
|
assert captured["workspace_passed"] == str(cfg_ws)
|
|
|
|
|
|
def test_deploy_workdir_falls_back_to_cwd(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir="")
|
|
cwd = str(tmp_path / "cwd")
|
|
captured = _run_deploy_once(monkeypatch, config, cwd=cwd)
|
|
|
|
assert captured["workspace_passed"] == cwd
|
|
|
|
|
|
def test_deploy_port_cli_arg_beats_config(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_port=6174)
|
|
captured = _run_deploy_once(monkeypatch, config, port=7000)
|
|
|
|
assert captured["port_passed"] == 7000
|
|
|
|
|
|
def test_deploy_port_defaults_to_config(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_port=6543)
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["port_passed"] == 6543
|
|
|
|
|
|
def test_deploy_host_cli_arg_beats_config(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1")
|
|
captured = _run_deploy_once(monkeypatch, config, host="192.168.1.5")
|
|
|
|
assert captured["host_passed"] == "192.168.1.5"
|
|
|
|
|
|
def test_deploy_host_defaults_to_config(monkeypatch, tmp_path):
|
|
config = _make_config(
|
|
default_workdir=str(tmp_path), langgraph_dev_host="192.168.1.5"
|
|
)
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["host_passed"] == "192.168.1.5"
|
|
|
|
|
|
def test_deploy_host_falls_back_when_config_lacks_field(monkeypatch, tmp_path):
|
|
"""A config object missing the field entirely still resolves to the
|
|
module default rather than passing None down to socket.bind()."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
del config.langgraph_dev_host
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["host_passed"] == "127.0.0.1"
|
|
|
|
|
|
def test_deploy_blank_host_keeps_config_value(monkeypatch, tmp_path):
|
|
"""``--host " "`` means "not passed" (as in serve), so it must not discard
|
|
the configured bind."""
|
|
config = _make_config(
|
|
default_workdir=str(tmp_path), langgraph_dev_host="192.168.1.5"
|
|
)
|
|
captured = _run_deploy_once(monkeypatch, config, host=" ")
|
|
|
|
assert captured["host_passed"] == "192.168.1.5"
|
|
|
|
|
|
def test_deploy_blank_host_and_blank_config_use_default(monkeypatch, tmp_path):
|
|
"""Whitespace on both sides would reach socket.bind() as an empty string
|
|
and raise an opaque gaierror; it degrades to the default instead."""
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host=" ")
|
|
captured = _run_deploy_once(monkeypatch, config, host=" ")
|
|
|
|
assert captured["host_passed"] == "127.0.0.1"
|
|
|
|
|
|
def test_deploy_host_public_opt_in(monkeypatch, tmp_path):
|
|
"""``--host 0.0.0.0`` must actually widen the bind even though the config
|
|
default keeps the unauthenticated backend on loopback."""
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host="127.0.0.1")
|
|
captured = _run_deploy_once(monkeypatch, config, host="0.0.0.0")
|
|
|
|
assert captured["host_passed"] == "0.0.0.0"
|
|
|
|
|
|
def test_deploy_host_is_stripped(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config, host=" 0.0.0.0 ")
|
|
|
|
assert captured["host_passed"] == "0.0.0.0"
|
|
|
|
|
|
def test_deploy_config_host_is_stripped(monkeypatch, tmp_path):
|
|
"""Stripping must not depend on the value arriving via --host.
|
|
``EvoScientistConfig.__post_init__`` normalizes these fields, but deploy()
|
|
reads through ``getattr`` and is handed duck-typed config objects that
|
|
never run it — an unstripped value would reach socket.bind()."""
|
|
config = _make_config(
|
|
default_workdir=str(tmp_path), langgraph_dev_host=" 192.168.1.5 "
|
|
)
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["host_passed"] == "192.168.1.5"
|
|
|
|
|
|
def test_deploy_whitespace_config_host_collapses_to_default(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), langgraph_dev_host=" ")
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["host_passed"] == "127.0.0.1"
|
|
|
|
|
|
def test_deploy_padded_loopback_config_host_suppresses_warning(monkeypatch, tmp_path):
|
|
"""Consequence of the unstripped path: `_is_loopback_host` would not match
|
|
" 127.0.0.1 ", so a padded loopback config would print a false PUBLIC BIND
|
|
warning while binding a value socket.bind() rejects outright."""
|
|
config = _make_config(
|
|
default_workdir=str(tmp_path), langgraph_dev_host=" 127.0.0.1 "
|
|
)
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["host_passed"] == "127.0.0.1"
|
|
assert not any("PUBLIC BIND" in line for line in captured["printed"])
|
|
|
|
|
|
@pytest.mark.parametrize("exposed_host", ["0.0.0.0", "192.168.1.5", "::"])
|
|
def test_deploy_warns_on_public_bind(monkeypatch, tmp_path, exposed_host):
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config, host=exposed_host)
|
|
|
|
assert any("PUBLIC BIND" in line for line in captured["printed"]), (
|
|
f"binding {exposed_host} reaches other machines and MUST warn"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("loopback_host", ["127.0.0.1", "::1", "localhost"])
|
|
def test_deploy_no_warning_on_loopback_bind(monkeypatch, tmp_path, loopback_host):
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config, host=loopback_host)
|
|
|
|
assert not any("PUBLIC BIND" in line for line in captured["printed"]), (
|
|
f"{loopback_host} is unreachable off-box — warning would be noise"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_port", [0, -1, 70000])
|
|
def test_deploy_refuses_invalid_port(monkeypatch, tmp_path, bad_port):
|
|
"""CLI must reject out-of-range ports (port=0 was the original silent-fail
|
|
case: ``port or default`` treated 0 as falsy)."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
with pytest.raises(typer.Exit) as exc:
|
|
_run_deploy_once(monkeypatch, config, port=bad_port)
|
|
assert exc.value.exit_code == 1
|
|
|
|
|
|
def test_deploy_no_ccproxy_when_api_key_auth(monkeypatch, tmp_path):
|
|
config = _make_config(
|
|
default_workdir=str(tmp_path),
|
|
anthropic_auth_mode="api_key",
|
|
openai_auth_mode="api_key",
|
|
)
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["ccproxy_started"] is False
|
|
|
|
|
|
def test_deploy_starts_ccproxy_when_anthropic_oauth(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), anthropic_auth_mode="oauth")
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["ccproxy_started"] is True
|
|
|
|
|
|
def test_deploy_starts_ccproxy_when_openai_oauth(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), openai_auth_mode="oauth")
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
assert captured["ccproxy_started"] is True
|
|
|
|
|
|
def test_deploy_refuses_when_port_occupied_by_evosci(monkeypatch, tmp_path):
|
|
"""If port is occupied AND it's serving an EvoSci langgraph dev,
|
|
refuse with exit code 1."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
with pytest.raises(typer.Exit) as exc:
|
|
_run_deploy_once(
|
|
monkeypatch,
|
|
config,
|
|
port_occupied=True,
|
|
langgraph_dev_running=True, # /ok responds → existing EvoSci instance
|
|
)
|
|
assert exc.value.exit_code == 1
|
|
|
|
|
|
def test_deploy_refuses_when_port_occupied_by_foreign(monkeypatch, tmp_path):
|
|
"""If port is occupied but /ok doesn't respond, treat as foreign process
|
|
and refuse."""
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
with pytest.raises(typer.Exit) as exc:
|
|
_run_deploy_once(
|
|
monkeypatch,
|
|
config,
|
|
port_occupied=True,
|
|
langgraph_dev_running=False, # foreign process holds the port
|
|
)
|
|
assert exc.value.exit_code == 1
|
|
|
|
|
|
def test_deploy_registers_cleanup_for_langgraph_dev(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
names = [name for (name, _args, _kw) in captured["atexit_callbacks"]]
|
|
assert "_fake_stop_langgraph_dev" in names, (
|
|
"deploy must register stop_langgraph_dev via atexit for clean shutdown"
|
|
)
|
|
|
|
|
|
def test_deploy_registers_cleanup_for_ccproxy_when_oauth(monkeypatch, tmp_path):
|
|
config = _make_config(default_workdir=str(tmp_path), anthropic_auth_mode="oauth")
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
names = [name for (name, _args, _kw) in captured["atexit_callbacks"]]
|
|
assert "_fake_stop_ccproxy" in names
|
|
|
|
|
|
def test_deploy_registers_signal_handlers_for_shutdown(monkeypatch, tmp_path):
|
|
"""deploy() must register SIGINT and SIGTERM handlers so external signals
|
|
trigger clean shutdown via the shutdown_event wait loop."""
|
|
import signal
|
|
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
handlers = captured["signal_handlers"]
|
|
assert signal.SIGINT in handlers, "deploy must register a SIGINT handler"
|
|
assert signal.SIGTERM in handlers, "deploy must register a SIGTERM handler"
|
|
assert callable(handlers[signal.SIGINT])
|
|
assert callable(handlers[signal.SIGTERM])
|
|
|
|
|
|
def test_handle_shutdown_sigterm_sets_shutdown_event(monkeypatch, tmp_path):
|
|
"""Invoking the captured SIGTERM handler must set deploy()'s shutdown_event.
|
|
|
|
SIGTERM is used (not SIGINT) because the SIGINT branch of _handle_shutdown
|
|
calls signal.default_int_handler which raises KeyboardInterrupt — that
|
|
would terminate the test process rather than exercise the event path.
|
|
"""
|
|
import signal
|
|
|
|
config = _make_config(default_workdir=str(tmp_path))
|
|
captured = _run_deploy_once(monkeypatch, config)
|
|
|
|
event = captured["event_instance"]
|
|
assert event is not None, "deploy() must have constructed a threading.Event"
|
|
# During the normal helper run the wait loop exits via is_set() flipping
|
|
# to True (not via set()), so set_was_called should still be False here.
|
|
assert event.set_was_called is False, (
|
|
"Sanity check: helper's _ImmediateEvent should exit naturally without "
|
|
"set() being called; if this fails, the helper changed behavior."
|
|
)
|
|
|
|
sigterm_handler = captured["signal_handlers"][signal.SIGTERM]
|
|
sigterm_handler(signal.SIGTERM, None)
|
|
|
|
assert event.set_was_called is True, (
|
|
"_handle_shutdown(SIGTERM, None) must call shutdown_event.set()"
|
|
)
|