Files
comet-ml--opik/sdks/python/tests/unit/runner/test_supervisor.py
T
wehub-resource-sync 5a558eb09e
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

580 lines
18 KiB
Python

import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
from opik.cli.local_runner.pairing import RunnerType
from opik.rest_api.core.api_error import ApiError
from opik.rest_api.types.local_runner_heartbeat_response import (
LocalRunnerHeartbeatResponse,
)
from opik.runner.supervisor import Supervisor
_SENTINEL = object()
def _make_supervisor(
command=_SENTINEL,
env=None,
repo_root=None,
runner_id="runner-1",
api=None,
watch=None,
runner_type=RunnerType.ENDPOINT,
) -> Supervisor:
if command is _SENTINEL:
command = [sys.executable, "-c", "import time; time.sleep(60)"]
if env is None:
env = dict(os.environ)
if repo_root is None:
repo_root = Path.cwd()
if api is None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
api.runners.next_bridge_commands.return_value = MagicMock(commands=[])
return Supervisor(
command=command,
env=env,
repo_root=repo_root,
runner_id=runner_id,
api=api,
watch=watch,
runner_type=runner_type,
project_name="test-project",
workspace=None,
heartbeat_interval_seconds=0.05,
graceful_timeout_seconds=0.1,
main_loop_tick_seconds=0.05,
)
class TestStartChild:
def test_launches_process(self, tmp_path: Path) -> None:
marker = tmp_path / "started"
sup = _make_supervisor(
command=[sys.executable, "-c", f"open('{marker}', 'w').write('ok')"],
repo_root=tmp_path,
)
child = sup._start_child()
child.wait(timeout=5)
assert marker.read_text() == "ok"
def test_captures_output_via_pipe(self) -> None:
sup = _make_supervisor()
with patch("opik.runner.supervisor.subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.stdout = MagicMock()
mock_proc.stdout.readline = MagicMock(return_value=b"")
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b"")
mock_popen.return_value = mock_proc
sup._start_child()
call_kwargs = mock_popen.call_args.kwargs
assert call_kwargs.get("stdout") == subprocess.PIPE
assert call_kwargs.get("stderr") == subprocess.PIPE
class TestStopChild:
def test_sigterm_then_wait(self) -> None:
sup = _make_supervisor()
with sup._child_lock:
sup._child = sup._start_child()
assert sup._child.poll() is None
sup._stop_child()
assert sup._child is None
def test_sigkill_after_timeout(self) -> None:
sup = _make_supervisor(
command=[
sys.executable,
"-c",
"import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)",
],
)
with sup._child_lock:
sup._child = sup._start_child()
time.sleep(0.2)
sup._stop_child(graceful_timeout=0.1)
assert sup._child is None
def test_already_dead__no_error(self) -> None:
sup = _make_supervisor(
command=[sys.executable, "-c", "pass"],
)
with sup._child_lock:
sup._child = sup._start_child()
sup._child.wait(timeout=5)
sup._stop_child()
assert sup._child is None
class TestRestart:
def test_stops_and_starts(self) -> None:
sup = _make_supervisor()
with sup._child_lock:
sup._child = sup._start_child()
old_pid = sup._child.pid
sup._restart_child("test reason")
assert sup._child is not None
assert sup._child.pid != old_pid
sup._stop_child()
def test_debounce(self) -> None:
sup = _make_supervisor()
with sup._child_lock:
sup._child = sup._start_child()
start_count = 0
original_start = sup._start_child
def counting_start():
nonlocal start_count
start_count += 1
return original_start()
sup._start_child = counting_start
# Three rapid calls — only the first should actually restart
for i in range(3):
sup._restart_child(f"trigger {i}")
assert start_count == 1
sup._stop_child()
class TestChildExit:
def test_restarts_on_nonzero_exit_if_stable(self) -> None:
sup = _make_supervisor()
sup._on_child_restart = MagicMock()
sup._on_error = MagicMock()
# Guard stable, call _handle_child_exit with exit code 1
# (Guard tracks crashes in this method)
should_restart = sup._handle_child_exit(exit_code=1)
assert should_restart is True
sup._on_child_restart.assert_called_once_with("agent process has failed")
sup._on_error.assert_not_called()
def test_triggers_error_on_crash_loop(self) -> None:
sup = _make_supervisor()
sup._on_child_restart = MagicMock()
sup._on_error = MagicMock()
sup._guard._max_crashes = 2
sup._guard._window_seconds = 60.0
# Pre-record 2 crashes to make guard unstable
sup._guard.record_crash()
sup._guard.record_crash()
assert not sup._guard.is_stable()
# Call _handle_child_exit — should trigger error, not restart
should_restart = sup._handle_child_exit(exit_code=1)
assert should_restart is False
sup._on_error.assert_called_once()
sup._on_child_restart.assert_not_called()
class TestErrorCallback:
def test_on_error_called_on_crash_loop(self) -> None:
error_messages = []
sup = _make_supervisor(
command=[sys.executable, "-c", "import sys; sys.exit(1)"],
)
sup._on_error = lambda msg: error_messages.append(msg)
sup._guard._max_crashes = 1
sup._guard._window_seconds = 60.0
# Exhaust stability guard so next crash triggers crash-loop
sup._guard.record_crash()
# Record another crash — guard should now be unstable
sup._guard.record_crash()
assert not sup._guard.is_stable()
# Simulate the on_error path directly
if not sup._guard.is_stable():
if sup._on_error:
sup._on_error("Crash loop detected — waiting for file change to retry")
assert len(error_messages) == 1
assert "Crash loop" in error_messages[0]
def test_on_child_restart_called_on_crash_restart(self) -> None:
restart_reasons = []
sup = _make_supervisor(
command=[sys.executable, "-c", "import sys; sys.exit(1)"],
)
sup._on_child_restart = lambda reason: restart_reasons.append(reason)
# Guard is stable — should trigger on_child_restart
assert sup._guard.is_stable()
if sup._on_child_restart:
sup._on_child_restart("agent process has failed")
assert len(restart_reasons) == 1
assert "agent process has failed" in restart_reasons[0]
def test_on_error_not_called_when_stable(self) -> None:
error_messages = []
sup = _make_supervisor(
command=[sys.executable, "-c", "import sys; sys.exit(1)"],
)
sup._on_error = lambda msg: error_messages.append(msg)
# Guard is stable — on_error should not fire
sup._guard.record_crash()
assert sup._guard.is_stable()
if not sup._guard.is_stable():
if sup._on_error:
sup._on_error("should not happen")
assert len(error_messages) == 0
class TestShutdown:
def test_stops_all(self) -> None:
sup = _make_supervisor(watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.2)
sup._shutdown_event.set()
t.join(timeout=10)
assert sup._child is None
def test_waits_for_child(self) -> None:
sup = _make_supervisor(watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.2)
sup._shutdown_event.set()
t.join(timeout=15)
assert not t.is_alive()
class TestHeartbeat:
def test_sends_heartbeat__endpoint__reports_jobs(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(api=api, runner_type=RunnerType.ENDPOINT)
t = threading.Thread(target=sup._heartbeat_loop, daemon=True)
t.start()
time.sleep(0.1)
sup._shutdown_event.set()
t.join(timeout=5)
api.runners.heartbeat.assert_called()
call_kwargs = api.runners.heartbeat.call_args.kwargs
assert call_kwargs["capabilities"] == ["jobs"]
def test_sends_heartbeat__connect__reports_bridge(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(api=api, runner_type=RunnerType.CONNECT)
t = threading.Thread(target=sup._heartbeat_loop, daemon=True)
t.start()
time.sleep(0.1)
sup._shutdown_event.set()
t.join(timeout=5)
api.runners.heartbeat.assert_called()
call_kwargs = api.runners.heartbeat.call_args.kwargs
assert call_kwargs["capabilities"] == ["bridge"]
def test_410__shuts_down(self) -> None:
api = MagicMock()
api.runners.heartbeat.side_effect = ApiError(status_code=410, body=None)
sup = _make_supervisor(api=api)
t = threading.Thread(target=sup._heartbeat_loop, daemon=True)
t.start()
t.join(timeout=5)
assert sup._shutdown_event.is_set()
class TestDisconnectNotification:
def test_disconnect_called_on_shutdown(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.2)
sup._shutdown_event.set()
t.join(timeout=10)
api.runners.disconnect_runner.assert_called_once()
args, _ = api.runners.disconnect_runner.call_args
assert args[0] == sup._runner_id
def test_disconnect_error_is_non_fatal(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
api.runners.disconnect_runner.side_effect = ApiError(status_code=503, body=None)
sup = _make_supervisor(api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.2)
sup._shutdown_event.set()
t.join(timeout=10)
assert not t.is_alive()
api.runners.disconnect_runner.assert_called_once()
def test_disconnect_called_in_standalone_mode(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(command=None, api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.1)
sup._shutdown_event.set()
t.join(timeout=10)
api.runners.disconnect_runner.assert_called_once()
def test_signal_handler_sets_shutdown_event(self) -> None:
# Without this, "if event set, disconnect fires" tests don't prove that SIGINT/SIGTERM
# actually drive the event. The CLI runs on the main thread in production, so
# signal.signal() installs cleanly there; this exercises that path directly.
sup = _make_supervisor()
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
try:
sup._install_signal_handlers()
installed_sigint = signal.getsignal(signal.SIGINT)
installed_sigterm = signal.getsignal(signal.SIGTERM)
assert callable(installed_sigint)
assert callable(installed_sigterm)
assert installed_sigint is installed_sigterm
assert not sup._shutdown_event.is_set()
installed_sigint(signal.SIGINT, None)
assert sup._shutdown_event.is_set()
finally:
signal.signal(signal.SIGINT, original_sigint)
signal.signal(signal.SIGTERM, original_sigterm)
def test_disconnect_called_on_heartbeat_410_eviction(self) -> None:
# A 410 from the heartbeat loop sets _shutdown_event from a background thread.
# The main loop must still tear down via the finally block, including disconnect.
api = MagicMock()
api.runners.heartbeat.side_effect = ApiError(status_code=410, body=None)
sup = _make_supervisor(api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
t.join(timeout=10)
assert not t.is_alive()
api.runners.disconnect_runner.assert_called_once()
# Subprocess driver used by TestSignalDeliveryEndToEnd. Lives at module scope so its
# source can be passed via `python -c`. Runs Supervisor in standalone mode (no child
# process) so the test isolates the shutdown path, and writes a marker file from a
# fake disconnect_runner so the parent can verify the call happened.
_SIGNAL_INTEGRATION_SCRIPT = """
import json
import sys
from pathlib import Path
ready_path = Path(sys.argv[1])
marker_path = Path(sys.argv[2])
from opik.cli.local_runner.pairing import RunnerType
from opik.rest_api.types.local_runner_heartbeat_response import LocalRunnerHeartbeatResponse
from opik.runner.supervisor import Supervisor
class _FakeRunners:
def heartbeat(self, *_a, **_kw):
return LocalRunnerHeartbeatResponse()
def patch_checklist(self, *_a, **_kw):
return None
def disconnect_runner(self, runner_id, *, request_options=None):
marker_path.write_text(json.dumps({"runner_id": runner_id}))
class _FakeApi:
runners = _FakeRunners()
sup = Supervisor(
command=None,
env={},
repo_root=Path.cwd(),
runner_id="runner-xyz",
api=_FakeApi(),
runner_type=RunnerType.ENDPOINT,
project_name="signal-integration",
workspace=None,
heartbeat_interval_seconds=60,
graceful_timeout_seconds=0.1,
main_loop_tick_seconds=0.05,
)
# Signal readiness only after the signal handlers are in place. We piggyback on
# the file-watcher branch: when watch=False (standalone mode), run() reaches the
# wait() after installing handlers. Touch the ready file just before that by
# wrapping _install_signal_handlers.
_original_install = sup._install_signal_handlers
def _install_and_signal_ready():
_original_install()
ready_path.write_text("ready")
sup._install_signal_handlers = _install_and_signal_ready
sup.run()
"""
class TestSignalDeliveryEndToEnd:
# Mock-driven tests above cover the wiring; this exercises the real OS-signal path
# by spawning a subprocess (so the supervisor runs on a real main thread) and
# checking that `disconnect_runner` actually fires before exit.
def _run_subprocess_with_signal(self, sig: signal.Signals, tmp_path: Path) -> Path:
ready = tmp_path / "ready"
marker = tmp_path / "disconnect-called.json"
proc = subprocess.Popen(
[sys.executable, "-c", _SIGNAL_INTEGRATION_SCRIPT, str(ready), str(marker)],
env={**os.environ},
)
try:
deadline = time.monotonic() + 10
while not ready.exists():
if time.monotonic() > deadline:
proc.kill()
raise AssertionError("Supervisor subprocess never became ready")
if proc.poll() is not None:
raise AssertionError(
f"Supervisor exited early (code={proc.returncode}) before ready"
)
time.sleep(0.05)
proc.send_signal(sig)
exit_code = proc.wait(timeout=10)
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
assert exit_code == 0, f"Subprocess exited with {exit_code}"
assert marker.exists(), "disconnect_runner was not called"
return marker
def test_sigint_triggers_disconnect(self, tmp_path: Path) -> None:
self._run_subprocess_with_signal(signal.SIGINT, tmp_path)
def test_sigterm_triggers_disconnect(self, tmp_path: Path) -> None:
marker = self._run_subprocess_with_signal(signal.SIGTERM, tmp_path)
# Sanity: the marker captures the runner_id we passed in, confirming we hit
# the real disconnect path and not some other exit code path.
import json
payload = json.loads(marker.read_text())
assert payload["runner_id"] == "runner-xyz"
class TestStandaloneMode:
def test_no_command__runs_without_child(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(command=None, api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.1)
assert sup._child is None
sup._shutdown_event.set()
t.join(timeout=10)
def test_no_command__sends_checklist_with_null_command(self) -> None:
api = MagicMock()
api.runners.heartbeat.return_value = LocalRunnerHeartbeatResponse()
sup = _make_supervisor(command=None, api=api, watch=False)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.1)
sup._shutdown_event.set()
t.join(timeout=10)
api.runners.patch_checklist.assert_called()
checklist = api.runners.patch_checklist.call_args.kwargs.get(
"request"
) or api.runners.patch_checklist.call_args[1].get("request")
assert checklist["command"] is None
class TestBridgeIntegration:
def test_bridge_loop_runs(self) -> None:
sup = _make_supervisor(watch=False, runner_type=RunnerType.CONNECT)
t = threading.Thread(target=sup.run, daemon=True)
t.start()
time.sleep(0.1)
bridge_alive = False
for thread in threading.enumerate():
if thread.name == "bridge-poll":
bridge_alive = True
break
sup._shutdown_event.set()
t.join(timeout=10)
assert bridge_alive