chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
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
|
||||
Reference in New Issue
Block a user