forked from cleveragents/cleveragents-core
410 lines
15 KiB
Python
410 lines
15 KiB
Python
"""Step definitions for devcontainer health check and concurrency operations.
|
|
|
|
Covers: single health probe, health check loop iterations, start/stop
|
|
health check threads, thread registration and join, probe workspace
|
|
fallback, health check argument validation, concurrent activation,
|
|
and concurrent stop.
|
|
|
|
All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch as _patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.container_lifecycle import (
|
|
ContainerLifecycleState,
|
|
ContainerLifecycleTracker,
|
|
)
|
|
from cleveragents.resource.handlers import devcontainer as _dc_mod
|
|
from cleveragents.resource.handlers.devcontainer import (
|
|
DevcontainerHandler,
|
|
_health_check_loop,
|
|
_health_check_stop_events,
|
|
_health_check_threads,
|
|
_single_probe,
|
|
_stop_health_check,
|
|
activate_container,
|
|
clear_lifecycle_registry,
|
|
get_lifecycle_tracker,
|
|
set_lifecycle_tracker,
|
|
start_health_check,
|
|
stop_container,
|
|
)
|
|
|
|
# Import _OneShotEvent from the core lifecycle steps module to avoid
|
|
# duplicating the helper class.
|
|
from features.steps.devcontainer_lifecycle_steps import _OneShotEvent
|
|
|
|
# ── Given steps ──────────────────────────────────────────────
|
|
|
|
|
|
@given("the runner configured for failed health check")
|
|
def step_configure_hc_failure(context: Context) -> None:
|
|
context.mock_runner.set_exec_failure()
|
|
|
|
|
|
@given("the runner configured for successful health check")
|
|
def step_configure_hc_success(context: Context) -> None:
|
|
context.mock_runner.set_exec_result(returncode=0, stdout="ping")
|
|
|
|
|
|
@given("the runner configured to raise exception on exec")
|
|
def step_configure_exec_exception(context: Context) -> None:
|
|
"""Replace mock_runner with a wrapper that raises on exec."""
|
|
inner = context.mock_runner
|
|
|
|
def raising_runner(args: list[str], **kwargs: object) -> object:
|
|
inner.calls.append((list(args), dict(kwargs)))
|
|
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "exec":
|
|
raise OSError("connection refused")
|
|
return inner(args, **kwargs)
|
|
|
|
context.mock_runner = cast(Any, raising_runner)
|
|
|
|
|
|
@given('a registered health check for "{resource_id}"')
|
|
def step_register_health_check(context: Context, resource_id: str) -> None:
|
|
"""Register a real (but idle) health check thread so _stop_health_check
|
|
can join it and we can verify the full thread-join path (F9 fix).
|
|
"""
|
|
stop_event = threading.Event()
|
|
_health_check_stop_events[resource_id] = stop_event
|
|
|
|
# F9 fix: use a real short-lived thread instead of current_thread()
|
|
# so _stop_health_check actually calls thread.join() rather than
|
|
# skipping the join due to the ``is not current_thread()`` guard.
|
|
def _idle_loop() -> None:
|
|
stop_event.wait(timeout=5.0)
|
|
|
|
thread = threading.Thread(target=_idle_loop, daemon=True)
|
|
_health_check_threads[resource_id] = thread
|
|
thread.start()
|
|
|
|
|
|
@given('a running health check for "{resource_id}"')
|
|
def step_start_health_check(context: Context, resource_id: str) -> None:
|
|
"""Start a health check thread that we can later verify was joined."""
|
|
context.mock_runner.set_exec_result(returncode=0, stdout="ping")
|
|
start_health_check(resource_id, interval=0.1, run_command=context.mock_runner)
|
|
# Give it a moment to start
|
|
time.sleep(0.05)
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────
|
|
|
|
|
|
@when('I run a single health check probe for "{resource_id}"')
|
|
def step_single_health_probe(context: Context, resource_id: str) -> None:
|
|
"""Run a single health check probe via the production _health_check_loop.
|
|
|
|
Uses the shared _OneShotEvent to run exactly one probe iteration
|
|
through the real production code path, avoiding reimplementation
|
|
of transition logic in the test step.
|
|
"""
|
|
_health_check_loop(
|
|
resource_id,
|
|
interval=0.0,
|
|
stop_event=cast(threading.Event, _OneShotEvent()),
|
|
run_command=context.mock_runner,
|
|
)
|
|
|
|
|
|
@when('I run one iteration of the health check loop for "{resource_id}"')
|
|
def step_run_health_check_loop_once(
|
|
context: Context,
|
|
resource_id: str,
|
|
) -> None:
|
|
"""Run _health_check_loop with the shared _OneShotEvent."""
|
|
context.hc_resource_id = resource_id
|
|
|
|
_health_check_loop(
|
|
resource_id,
|
|
interval=0.0,
|
|
stop_event=cast(threading.Event, _OneShotEvent()),
|
|
run_command=context.mock_runner,
|
|
)
|
|
|
|
|
|
@when('I call start_health_check for "{resource_id}"')
|
|
def step_call_start_health_check(context: Context, resource_id: str) -> None:
|
|
"""Call start_health_check and immediately stop the thread."""
|
|
start_health_check(
|
|
resource_id,
|
|
interval=60.0, # long interval — we stop it immediately
|
|
run_command=context.mock_runner,
|
|
)
|
|
context.hc_resource_id = resource_id
|
|
# Give thread just enough time to register
|
|
time.sleep(0.05)
|
|
|
|
|
|
@when('I stop the health check for "{resource_id}"')
|
|
def step_stop_health_check(context: Context, resource_id: str) -> None:
|
|
_stop_health_check(resource_id)
|
|
context.health_check_resource_id = resource_id
|
|
|
|
|
|
@when("I attempt to start health check with empty resource_id")
|
|
def step_start_hc_empty_id(context: Context) -> None:
|
|
context.hc_error = None
|
|
try:
|
|
start_health_check("")
|
|
except ValueError as exc:
|
|
context.hc_error = exc
|
|
|
|
|
|
@when("I run a single probe with no workspace_path set")
|
|
def step_probe_no_workspace(context: Context) -> None:
|
|
"""Run _single_probe with a tracker that has workspace_path=None."""
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id="01TESTLIFECYCLE0000000320",
|
|
current_state=ContainerLifecycleState.RUNNING,
|
|
container_id="ctr-t3",
|
|
workspace_path=None,
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
context.mock_runner.set_exec_result(returncode=0, stdout="ping")
|
|
_single_probe(tracker, context.mock_runner)
|
|
context.probe_calls = context.mock_runner.exec_calls
|
|
|
|
|
|
# ── Then steps ───────────────────────────────────────────────
|
|
|
|
|
|
@then('the health check should have probed "{resource_id}"')
|
|
def step_check_health_probe(context: Context, resource_id: str) -> None:
|
|
exec_calls = context.mock_runner.exec_calls
|
|
assert len(exec_calls) > 0, "Expected at least one exec call from health check"
|
|
|
|
|
|
@then('a health check thread should be registered for "{resource_id}"')
|
|
def step_check_thread_registered(context: Context, resource_id: str) -> None:
|
|
assert resource_id in _health_check_threads, (
|
|
f"Thread not registered for {resource_id}"
|
|
)
|
|
assert resource_id in _health_check_stop_events, (
|
|
f"Stop event not registered for {resource_id}"
|
|
)
|
|
# Clean up — stop the thread
|
|
clear_lifecycle_registry()
|
|
|
|
|
|
@then('the health check for "{resource_id}" should be unregistered')
|
|
def step_check_hc_unregistered(context: Context, resource_id: str) -> None:
|
|
assert resource_id not in _health_check_stop_events, (
|
|
f"Stop event still registered for {resource_id}"
|
|
)
|
|
assert resource_id not in _health_check_threads, (
|
|
f"Thread still registered for {resource_id}"
|
|
)
|
|
|
|
|
|
@then("the health check should raise ValueError")
|
|
def step_check_hc_value_error(context: Context) -> None:
|
|
assert context.hc_error is not None
|
|
assert isinstance(context.hc_error, ValueError)
|
|
|
|
|
|
@then("the health check thread should have been joined")
|
|
def step_check_thread_joined(context: Context) -> None:
|
|
rid = context.health_check_resource_id
|
|
# After _stop_health_check, thread should have been removed from registry
|
|
assert rid not in _health_check_threads, (
|
|
f"Thread for {rid} still in _health_check_threads after stop"
|
|
)
|
|
assert rid not in _health_check_stop_events, (
|
|
f"Stop event for {rid} still in _health_check_stop_events after stop"
|
|
)
|
|
|
|
|
|
@then('the probe command should use "{path}" as workspace folder')
|
|
def step_check_probe_workspace(context: Context, path: str) -> None:
|
|
assert len(context.probe_calls) >= 1, "Expected at least one exec call"
|
|
args, _ = context.probe_calls[-1]
|
|
# args: ["devcontainer", "exec", "--workspace-folder", path, "echo", "ping"]
|
|
ws_idx = args.index("--workspace-folder") + 1
|
|
assert args[ws_idx] == path, f"Expected workspace '{path}', got '{args[ws_idx]}'"
|
|
|
|
|
|
# ── Concurrent operation steps ───────────────────────────────
|
|
|
|
|
|
@when("I activate the container concurrently from two threads")
|
|
def step_concurrent_activation(context: Context) -> None:
|
|
"""Launch two threads both trying to activate the same resource.
|
|
|
|
TEST-1 fix: use ``threading.Barrier(2)`` so both threads reach the
|
|
activation call at the same time, preventing the first thread from
|
|
completing before the second starts.
|
|
"""
|
|
resource_id = context.resource_id
|
|
results: list[tuple[str, object]] = []
|
|
lock = threading.Lock()
|
|
barrier = threading.Barrier(2, timeout=5.0)
|
|
|
|
def _activate(label: str) -> None:
|
|
try:
|
|
barrier.wait()
|
|
activate_container(
|
|
resource_id,
|
|
"/workspace",
|
|
run_command=context.mock_runner,
|
|
)
|
|
with lock:
|
|
results.append((label, "ok"))
|
|
except (ValueError, RuntimeError) as exc:
|
|
with lock:
|
|
results.append((label, exc))
|
|
|
|
t1 = threading.Thread(target=_activate, args=("t1",))
|
|
t2 = threading.Thread(target=_activate, args=("t2",))
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=5.0)
|
|
t2.join(timeout=5.0)
|
|
|
|
context.concurrent_results = results
|
|
|
|
|
|
@when("I stop the container concurrently from two threads")
|
|
def step_concurrent_stop(context: Context) -> None:
|
|
"""Launch two threads both trying to stop the same resource.
|
|
|
|
TEST-1 fix: use ``threading.Barrier(2)`` so both threads reach the
|
|
stop call at the same time, preventing sequential execution.
|
|
"""
|
|
resource_id = "01TESTLIFECYCLE0000000390"
|
|
results: list[tuple[str, object]] = []
|
|
lock = threading.Lock()
|
|
barrier = threading.Barrier(2, timeout=5.0)
|
|
|
|
def _stop(label: str) -> None:
|
|
try:
|
|
barrier.wait()
|
|
stop_container(
|
|
resource_id,
|
|
run_command=context.mock_runner,
|
|
)
|
|
with lock:
|
|
results.append((label, "ok"))
|
|
except (ValueError, RuntimeError) as exc:
|
|
with lock:
|
|
results.append((label, exc))
|
|
|
|
t1 = threading.Thread(target=_stop, args=("t1",))
|
|
t2 = threading.Thread(target=_stop, args=("t2",))
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=5.0)
|
|
t2.join(timeout=5.0)
|
|
|
|
context.concurrent_stop_results = results
|
|
|
|
|
|
@then("exactly one activation should succeed")
|
|
def step_one_success(context: Context) -> None:
|
|
successes = [r for r in context.concurrent_results if r[1] == "ok"]
|
|
assert len(successes) == 1, (
|
|
f"Expected exactly 1 success, got {len(successes)}: {context.concurrent_results}"
|
|
)
|
|
|
|
|
|
@then("exactly one activation should fail with ValueError")
|
|
def step_one_failure(context: Context) -> None:
|
|
failures = [r for r in context.concurrent_results if isinstance(r[1], ValueError)]
|
|
assert len(failures) == 1, (
|
|
f"Expected exactly 1 ValueError, got {len(failures)}: {context.concurrent_results}"
|
|
)
|
|
|
|
|
|
@then("both stops should succeed")
|
|
def step_both_stops_succeed(context: Context) -> None:
|
|
# R8-F2 fix: stop_container is now idempotent — the second caller
|
|
# sees a terminal state and returns early instead of raising.
|
|
successes = [r for r in context.concurrent_stop_results if r[1] == "ok"]
|
|
assert len(successes) == 2, (
|
|
f"Expected 2 stop successes (idempotent), got {len(successes)}: "
|
|
f"{context.concurrent_stop_results}"
|
|
)
|
|
|
|
|
|
@then('the container state for "{resource_id}" should not be "{state}"')
|
|
def step_check_state_not(context: Context, resource_id: str, state: str) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.current_state != ContainerLifecycleState(state), (
|
|
f"Expected state NOT to be {state}, but got {tracker.current_state.value}"
|
|
)
|
|
|
|
|
|
@then("exactly {count:d} docker stop call should have been made")
|
|
def step_check_docker_stop_count(context: Context, count: int) -> None:
|
|
actual = len(context.mock_runner.stop_calls)
|
|
assert actual == count, (
|
|
f"Expected exactly {count} docker stop call(s), got {actual}"
|
|
)
|
|
|
|
|
|
# ── F16: resolve() lazy activation steps ─────────────────────
|
|
|
|
|
|
@when('I resolve a devcontainer-instance "{resource_id}" at "{location}"')
|
|
def step_resolve_devcontainer(
|
|
context: Context, resource_id: str, location: str
|
|
) -> None:
|
|
"""Call DevcontainerHandler.resolve() with mocked Resource and SandboxManager.
|
|
|
|
The handler's resolve() triggers activate_container() for
|
|
non-running devcontainer-instance resources (F16 fix), then
|
|
delegates to super().resolve() which needs a SandboxManager.
|
|
We patch activate_container with a side_effect that injects
|
|
the mock runner, so the real activation logic executes.
|
|
"""
|
|
handler = DevcontainerHandler()
|
|
mock_resource = MagicMock()
|
|
mock_resource.resource_id = resource_id
|
|
mock_resource.resource_type_name = "devcontainer-instance"
|
|
mock_resource.location = location
|
|
mock_resource.sandbox_strategy = None
|
|
|
|
mock_sandbox_mgr = MagicMock()
|
|
mock_sandbox_ctx = MagicMock()
|
|
mock_sandbox_ctx.sandbox_path = "/tmp/sandbox"
|
|
mock_sandbox = MagicMock()
|
|
mock_sandbox.context = mock_sandbox_ctx
|
|
mock_sandbox_mgr.get_or_create_sandbox.return_value = mock_sandbox
|
|
|
|
real_activate = _dc_mod.activate_container
|
|
|
|
def _activate_with_mock(
|
|
rid: str,
|
|
ws: str,
|
|
session_id: str | None = None,
|
|
**kwargs: object,
|
|
) -> object:
|
|
return real_activate(
|
|
rid, ws, run_command=context.mock_runner, session_id=session_id
|
|
)
|
|
|
|
context.resolve_error = None
|
|
with _patch.object(
|
|
_dc_mod, "activate_container", side_effect=_activate_with_mock
|
|
) as mock_activate:
|
|
try:
|
|
context.bound_resource = handler.resolve(
|
|
resource=mock_resource,
|
|
plan_id="plan-001",
|
|
slot_name="workspace",
|
|
sandbox_manager=mock_sandbox_mgr,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.resolve_error = exc
|
|
context.resolve_activate_mock = mock_activate
|