dashboard test runner . backend
This commit is contained in:
@@ -363,7 +363,10 @@ def main() -> None:
|
||||
common.add_argument(
|
||||
"--enable-test-runner",
|
||||
action="store_true",
|
||||
help="Enable development test-runner hooks for this tracker",
|
||||
help=(
|
||||
"Enable the admin-only dashboard test runner API "
|
||||
"(disabled by default; also honors MESHNET_ENABLE_TEST_RUNNER=1)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--no-file-logs",
|
||||
@@ -442,6 +445,7 @@ def main() -> None:
|
||||
hf_pricing_refresh_interval=args.hf_pricing_refresh_interval,
|
||||
models_dir=args.models_dir,
|
||||
routing_config=_routing_config_from_args(args),
|
||||
enable_test_runner=args.enable_test_runner,
|
||||
)
|
||||
port = server.start()
|
||||
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1254 1254" width="1254" height="1254">
|
||||
<title>meshnet tracker</title>
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="0%" stop-color="#071229"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
@@ -53,6 +53,8 @@ from .calibration import DEFAULT_CALIBRATION_DB_PATH, ToplocCalibrationStore
|
||||
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH, HfPricingLog, refresh_preset_price
|
||||
from .gossip import NodeGossip
|
||||
from .logging_setup import tracker_logger
|
||||
from .test_runner import ENABLE_ENV_VAR as TEST_RUNNER_ENABLE_ENV_VAR
|
||||
from .test_runner import TestRunManager, TestRunnerError
|
||||
from .routing_stats import (
|
||||
RouteCandidate,
|
||||
RouteStatsStore,
|
||||
@@ -1283,8 +1285,12 @@ def _request_model_load_locked(server: "_TrackerHTTPServer", model_key: str) ->
|
||||
return None
|
||||
required_start, required_end = _preset_layer_bounds(preset)
|
||||
total_layers = required_end - required_start + 1
|
||||
required_bytes = int(preset.get("required_model_bytes") or 0)
|
||||
for host in _memory_pool_map(server)["hosts"]:
|
||||
if host["spare_slots"] <= 0:
|
||||
# A host qualifies with a declared spare slot, or — for an explicit
|
||||
# operator request — with enough spare assignable memory for the model.
|
||||
has_spare_memory = required_bytes > 0 and host["memory_spare_bytes"] >= required_bytes
|
||||
if host["spare_slots"] <= 0 and not has_spare_memory:
|
||||
continue
|
||||
host_nodes = [server.registry[item["node_id"]] for item in host["loaded"] if item["node_id"] in server.registry]
|
||||
if not host_nodes:
|
||||
@@ -1923,6 +1929,9 @@ def _rebalance_model_locked(server: "_TrackerHTTPServer", model: str) -> None:
|
||||
if not managed_nodes:
|
||||
return
|
||||
|
||||
# Directives must name a repo the node can actually load, not the preset key.
|
||||
directive_model = str(preset.get("hf_repo") or model)
|
||||
|
||||
coverage = _coverage_map(model_nodes, required_start, required_end)
|
||||
gaps = _coverage_gaps(coverage)
|
||||
unassigned = _unassigned_managed_nodes(managed_nodes)
|
||||
@@ -1930,7 +1939,7 @@ def _rebalance_model_locked(server: "_TrackerHTTPServer", model: str) -> None:
|
||||
return
|
||||
if not gaps and unassigned:
|
||||
_assign_redundant_managed_nodes(
|
||||
unassigned, model, preset, required_start, required_end,
|
||||
unassigned, directive_model, preset, required_start, required_end,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1982,7 +1991,7 @@ def _rebalance_model_locked(server: "_TrackerHTTPServer", model: str) -> None:
|
||||
for node in managed_nodes:
|
||||
_emit_shard_change_directives(
|
||||
node,
|
||||
model,
|
||||
directive_model,
|
||||
previous_ranges[node.node_id],
|
||||
preset,
|
||||
)
|
||||
@@ -2512,6 +2521,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
models_dir: Path | None = None,
|
||||
route_stats: "RouteStatsStore | None" = None,
|
||||
relay_status: dict | None = None,
|
||||
test_runner: "TestRunManager | None" = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
self.registry = registry
|
||||
@@ -2552,6 +2562,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
self.relay_status = dict(
|
||||
relay_status or {"mode": "external" if self.relay_url else "off"}
|
||||
)
|
||||
self.test_runner: TestRunManager | None = test_runner
|
||||
|
||||
|
||||
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -2710,6 +2721,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/v1/wallet/register":
|
||||
self._handle_wallet_register()
|
||||
return
|
||||
if self.path == "/v1/tests/run":
|
||||
self._handle_tests_run()
|
||||
return
|
||||
parts = self.path.split("/")
|
||||
# /v1/nodes/<node_id>/heartbeat -> ['', 'v1', 'nodes', '<id>', 'heartbeat']
|
||||
if len(parts) == 5 and parts[1] == "v1" and parts[2] == "nodes" and parts[4] == "heartbeat":
|
||||
@@ -2784,6 +2798,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._handle_toploc_calibration_results()
|
||||
elif parsed.path == "/v1/pricing/hf/history":
|
||||
self._handle_hf_pricing_history(parsed)
|
||||
elif parsed.path == "/v1/tests":
|
||||
self._handle_tests_list(parsed)
|
||||
elif parsed.path == "/v1/tests/status":
|
||||
self._handle_tests_status()
|
||||
elif parsed.path == "/v1/registry/wallets":
|
||||
self._handle_registry_wallets()
|
||||
elif parsed.path in ("/dashboard", "/dashboard/"):
|
||||
@@ -2846,8 +2864,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
"served_model_copies": served_copies,
|
||||
})
|
||||
seen_ids.add(name)
|
||||
if hf_repo:
|
||||
seen_ids.add(hf_repo)
|
||||
# Note: the preset's hf_repo is deliberately NOT added to seen_ids —
|
||||
# nodes registered with an explicit hf_repo also get their own
|
||||
# repo-keyed entry below, with the node's short name as an alias.
|
||||
|
||||
hf_model_ids = sorted({
|
||||
node.hf_repo or node.model
|
||||
@@ -4328,7 +4347,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_model_load_request(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self._require_role("admin"):
|
||||
if not self._require_role("admin", "validator"):
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
@@ -4467,6 +4486,57 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
finish_proxy_inflight()
|
||||
return True
|
||||
|
||||
# ---- opt-in test runner (dashboard-test-runner US-001) ----
|
||||
|
||||
def _test_runner_or_reject(self) -> "TestRunManager | None":
|
||||
"""Admin session first, then the explicit enable gate — both fail closed."""
|
||||
if not self._require_role("admin"):
|
||||
return None
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if server.test_runner is None:
|
||||
self._send_json(403, {
|
||||
"error": (
|
||||
"test runner disabled; start the tracker with "
|
||||
f"--enable-test-runner or {TEST_RUNNER_ENABLE_ENV_VAR}=1"
|
||||
)
|
||||
})
|
||||
return None
|
||||
return server.test_runner
|
||||
|
||||
def _handle_tests_list(self, parsed: urllib.parse.ParseResult) -> None:
|
||||
runner = self._test_runner_or_reject()
|
||||
if runner is None:
|
||||
return
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
refresh = query.get("refresh", ["0"])[0] in {"1", "true"}
|
||||
try:
|
||||
data = runner.collect(refresh=refresh)
|
||||
except TestRunnerError as exc:
|
||||
self._send_json(exc.status, {"error": str(exc)})
|
||||
return
|
||||
self._send_json(200, {"enabled": True, **data})
|
||||
|
||||
def _handle_tests_run(self) -> None:
|
||||
runner = self._test_runner_or_reject()
|
||||
if runner is None:
|
||||
return
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
target = body.get("target")
|
||||
try:
|
||||
state = runner.start(target if isinstance(target, str) else "")
|
||||
except TestRunnerError as exc:
|
||||
self._send_json(exc.status, {"error": str(exc)})
|
||||
return
|
||||
self._send_json(202, state)
|
||||
|
||||
def _handle_tests_status(self) -> None:
|
||||
runner = self._test_runner_or_reject()
|
||||
if runner is None:
|
||||
return
|
||||
self._send_json(200, runner.status())
|
||||
|
||||
def _handle_registry_wallets(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self._require_role("admin"):
|
||||
@@ -5943,6 +6013,8 @@ class TrackerServer:
|
||||
hf_pricing_fetch_html: Any | None = None,
|
||||
models_dir: str | Path | None = None,
|
||||
routing_config: RoutingConfig | None = None,
|
||||
enable_test_runner: bool = False,
|
||||
test_runner: TestRunManager | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._requested_port = port
|
||||
@@ -6063,6 +6135,13 @@ class TrackerServer:
|
||||
),
|
||||
)
|
||||
self._route_stats = RouteStatsStore(routing_config, db_path=stats_db)
|
||||
# Opt-in test runner: an injected manager implies enabled; otherwise
|
||||
# only the explicit flag or env var constructs one (fails closed).
|
||||
if test_runner is None and (
|
||||
enable_test_runner or os.environ.get(TEST_RUNNER_ENABLE_ENV_VAR) == "1"
|
||||
):
|
||||
test_runner = TestRunManager()
|
||||
self._test_runner: TestRunManager | None = test_runner
|
||||
self.port: int | None = None
|
||||
|
||||
def _start_embedded_relay(self) -> dict:
|
||||
@@ -6148,6 +6227,7 @@ class TrackerServer:
|
||||
models_dir=self._models_dir,
|
||||
route_stats=self._route_stats,
|
||||
relay_status=http_relay_status,
|
||||
test_runner=self._test_runner,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
|
||||
@@ -6469,6 +6549,8 @@ class TrackerServer:
|
||||
self._embedded_relay_actual_port = None
|
||||
if embedded_relay is not None:
|
||||
embedded_relay.stop()
|
||||
if self._test_runner is not None:
|
||||
self._test_runner.shutdown()
|
||||
if self._server is None:
|
||||
return
|
||||
self._rebalance_stop.set()
|
||||
|
||||
314
packages/tracker/meshnet_tracker/test_runner.py
Normal file
314
packages/tracker/meshnet_tracker/test_runner.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Opt-in tracker test runner backing the dashboard Testing tab.
|
||||
|
||||
Security posture (dashboard-test-runner US-001):
|
||||
|
||||
- **Disabled by default** — the tracker only constructs a manager when started
|
||||
with ``--enable-test-runner`` / ``MESHNET_ENABLE_TEST_RUNNER=1`` or
|
||||
``TrackerServer(enable_test_runner=True)``.
|
||||
- **No arbitrary commands** — a run target must be either a pytest node ID
|
||||
returned by our own ``--collect-only`` pass or an approved named suite.
|
||||
Callers never supply command arguments.
|
||||
- **No shell** — pytest runs as ``[sys.executable, "-m", "pytest", ...]`` via
|
||||
``subprocess.Popen`` without ``shell=True``.
|
||||
- **One run at a time** — a second start while a run is active is rejected.
|
||||
- **Bounded logs** — stdout/stderr are retained as line deques with caps.
|
||||
- **Real inference stays gated** — ``tests/test_real_*.py`` modules are never
|
||||
collected and never part of a suite unless
|
||||
``MESHNET_ENABLE_REAL_INFERENCE_TESTS=1`` is set, and even then only via the
|
||||
dedicated ``suite:real-inference`` target, never a default suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
ENABLE_ENV_VAR = "MESHNET_ENABLE_TEST_RUNNER"
|
||||
REAL_INFERENCE_ENV_VAR = "MESHNET_ENABLE_REAL_INFERENCE_TESTS"
|
||||
|
||||
# Modules that talk to live nodes / spend API credit; matched by filename.
|
||||
_REAL_INFERENCE_PATTERN = re.compile(r"(^|/)test_real_[^/]*\.py")
|
||||
|
||||
# Fixed, reviewed suite targets. Values are pytest paths relative to the repo
|
||||
# root — never influenced by API input.
|
||||
APPROVED_SUITES: dict[str, list[str]] = {
|
||||
"smoke": ["tests/test_smoke.py"],
|
||||
"dashboard": ["tests/test_dashboard.py"],
|
||||
"routing": ["tests/test_tracker_routing.py", "tests/test_dynamic_routing.py"],
|
||||
}
|
||||
|
||||
# Only exists when REAL_INFERENCE_ENV_VAR=1; kept out of APPROVED_SUITES so it
|
||||
# can never appear by default.
|
||||
_REAL_INFERENCE_SUITE = {
|
||||
"real-inference": [
|
||||
"tests/test_real_distributed_inference.py",
|
||||
"tests/test_real_model_backend.py",
|
||||
]
|
||||
}
|
||||
|
||||
DEFAULT_MAX_LOG_LINES = 4000
|
||||
_MAX_LINE_CHARS = 4000
|
||||
DEFAULT_COLLECT_TIMEOUT = 120.0
|
||||
DEFAULT_RUN_TIMEOUT = 1800.0
|
||||
|
||||
_NODE_ID_RE = re.compile(r"^[\w./\[\]:,= @-]+$")
|
||||
|
||||
|
||||
class TestRunnerError(Exception):
|
||||
"""Base class; carries the HTTP status the handler should send."""
|
||||
|
||||
status = 400
|
||||
|
||||
|
||||
class UnknownTargetError(TestRunnerError):
|
||||
status = 400
|
||||
|
||||
|
||||
class RunInProgressError(TestRunnerError):
|
||||
status = 409
|
||||
|
||||
|
||||
class CollectionError(TestRunnerError):
|
||||
status = 500
|
||||
|
||||
|
||||
def discover_repo_root(start: Path | None = None) -> Path:
|
||||
"""Find the repository root independent of the tracker's cwd.
|
||||
|
||||
Walks up from this module (or ``start``) to the first directory holding
|
||||
both a ``pyproject.toml`` and ``tests/conftest.py`` — the layout of the
|
||||
meshnet monorepo root. Package-level ``pyproject.toml`` files (which have
|
||||
no ``tests/`` dir) are skipped naturally.
|
||||
"""
|
||||
current = (start or Path(__file__)).resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / "pyproject.toml").is_file() and (candidate / "tests" / "conftest.py").is_file():
|
||||
return candidate
|
||||
raise RuntimeError("could not locate repository root (pyproject.toml + tests/conftest.py)")
|
||||
|
||||
|
||||
def _real_inference_enabled() -> bool:
|
||||
return os.environ.get(REAL_INFERENCE_ENV_VAR) == "1"
|
||||
|
||||
|
||||
class TestRunManager:
|
||||
"""Collects pytest node IDs and runs one fixed target at a time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo_root: str | Path | None = None,
|
||||
*,
|
||||
max_log_lines: int = DEFAULT_MAX_LOG_LINES,
|
||||
collect_timeout: float = DEFAULT_COLLECT_TIMEOUT,
|
||||
run_timeout: float = DEFAULT_RUN_TIMEOUT,
|
||||
) -> None:
|
||||
self.repo_root = Path(repo_root).resolve() if repo_root else discover_repo_root()
|
||||
self.max_log_lines = max_log_lines
|
||||
self.collect_timeout = collect_timeout
|
||||
self.run_timeout = run_timeout
|
||||
self._lock = threading.Lock()
|
||||
self._collected: set[str] = set()
|
||||
self._collected_at: float | None = None
|
||||
self._run: dict | None = None
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._stdout: deque[str] = deque(maxlen=max_log_lines)
|
||||
self._stderr: deque[str] = deque(maxlen=max_log_lines)
|
||||
self._reader_threads: list[threading.Thread] = []
|
||||
|
||||
# ---- collection ----
|
||||
|
||||
def suites(self) -> dict[str, list[str]]:
|
||||
"""Approved suites whose files exist in this repo checkout."""
|
||||
available = dict(APPROVED_SUITES)
|
||||
if _real_inference_enabled():
|
||||
available.update(_REAL_INFERENCE_SUITE)
|
||||
return {
|
||||
name: paths
|
||||
for name, paths in available.items()
|
||||
if all((self.repo_root / p).is_file() for p in paths)
|
||||
}
|
||||
|
||||
def collect(self, *, refresh: bool = False) -> dict:
|
||||
"""Run ``pytest --collect-only -q`` and cache the node IDs.
|
||||
|
||||
Real-inference modules are ignored at collection time (they are never
|
||||
imported) unless the explicit environment gate is set.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._collected_at is not None and not refresh:
|
||||
return self._collection_snapshot_locked()
|
||||
|
||||
cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"]
|
||||
if not _real_inference_enabled():
|
||||
for name in sorted(
|
||||
p.name for p in (self.repo_root / "tests").glob("test_real_*.py")
|
||||
):
|
||||
cmd.append(f"--ignore=tests/{name}")
|
||||
cmd.append("tests")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=self.repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.collect_timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise CollectionError(f"pytest collection timed out after {self.collect_timeout}s") from exc
|
||||
# Exit code 5 = no tests collected; still a valid (empty) result.
|
||||
if proc.returncode not in (0, 5):
|
||||
tail = (proc.stdout or "")[-2000:] + (proc.stderr or "")[-2000:]
|
||||
raise CollectionError(f"pytest collection failed (exit {proc.returncode}): {tail}")
|
||||
node_ids = [
|
||||
line.strip()
|
||||
for line in proc.stdout.splitlines()
|
||||
if "::" in line and not line.startswith(("=", " ", "<"))
|
||||
]
|
||||
if not _real_inference_enabled():
|
||||
node_ids = [n for n in node_ids if not _REAL_INFERENCE_PATTERN.search(n.split("::", 1)[0])]
|
||||
with self._lock:
|
||||
self._collected = set(node_ids)
|
||||
self._collected_at = time.time()
|
||||
return self._collection_snapshot_locked()
|
||||
|
||||
def _collection_snapshot_locked(self) -> dict:
|
||||
return {
|
||||
"tests": sorted(self._collected),
|
||||
"suites": [
|
||||
{"id": f"suite:{name}", "name": name, "paths": paths}
|
||||
for name, paths in sorted(self.suites().items())
|
||||
],
|
||||
"collected_at": self._collected_at,
|
||||
}
|
||||
|
||||
# ---- running ----
|
||||
|
||||
def start(self, target: str) -> dict:
|
||||
"""Start one collected node ID or approved ``suite:<name>`` target."""
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
raise UnknownTargetError("target is required")
|
||||
target = target.strip()
|
||||
if target.startswith("-") or not _NODE_ID_RE.match(target):
|
||||
raise UnknownTargetError("target must be a collected test node ID or an approved suite")
|
||||
|
||||
if target.startswith("suite:"):
|
||||
suite_name = target.removeprefix("suite:")
|
||||
paths = self.suites().get(suite_name)
|
||||
if paths is None:
|
||||
raise UnknownTargetError(f"unknown suite: {suite_name!r}")
|
||||
pytest_args = list(paths)
|
||||
else:
|
||||
with self._lock:
|
||||
known = target in self._collected
|
||||
if not known:
|
||||
raise UnknownTargetError(
|
||||
"target is not a collected test node ID; call GET /v1/tests first"
|
||||
)
|
||||
pytest_args = [target]
|
||||
|
||||
with self._lock:
|
||||
if self._run is not None and self._run["status"] == "running":
|
||||
raise RunInProgressError("a test run is already in progress")
|
||||
cmd = [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *pytest_args]
|
||||
self._stdout = deque(maxlen=self.max_log_lines)
|
||||
self._stderr = deque(maxlen=self.max_log_lines)
|
||||
process = subprocess.Popen( # noqa: S603 — fixed argv, no shell
|
||||
cmd,
|
||||
cwd=self.repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self._process = process
|
||||
self._run = {
|
||||
"run_id": uuid.uuid4().hex,
|
||||
"target": target,
|
||||
"args": pytest_args,
|
||||
"status": "running",
|
||||
"started_at": time.time(),
|
||||
"finished_at": None,
|
||||
"exit_code": None,
|
||||
}
|
||||
self._reader_threads = [
|
||||
threading.Thread(
|
||||
target=self._drain, args=(process.stdout, self._stdout), daemon=True
|
||||
),
|
||||
threading.Thread(
|
||||
target=self._drain, args=(process.stderr, self._stderr), daemon=True
|
||||
),
|
||||
]
|
||||
for thread in self._reader_threads:
|
||||
thread.start()
|
||||
waiter = threading.Thread(target=self._wait, args=(process,), daemon=True)
|
||||
waiter.start()
|
||||
return self._status_locked()
|
||||
|
||||
@staticmethod
|
||||
def _drain(stream, sink: deque[str]) -> None:
|
||||
try:
|
||||
for line in stream:
|
||||
sink.append(line.rstrip("\n")[:_MAX_LINE_CHARS])
|
||||
except ValueError: # stream closed mid-read on shutdown
|
||||
pass
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
def _wait(self, process: subprocess.Popen) -> None:
|
||||
timed_out = False
|
||||
try:
|
||||
exit_code = process.wait(timeout=self.run_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
exit_code = process.wait()
|
||||
for thread in self._reader_threads:
|
||||
thread.join(timeout=5)
|
||||
with self._lock:
|
||||
if self._process is not process or self._run is None:
|
||||
return
|
||||
self._run["exit_code"] = exit_code
|
||||
self._run["finished_at"] = time.time()
|
||||
if timed_out:
|
||||
self._run["status"] = "timeout"
|
||||
elif exit_code == 0:
|
||||
self._run["status"] = "passed"
|
||||
else:
|
||||
self._run["status"] = "failed"
|
||||
self._process = None
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
return self._status_locked()
|
||||
|
||||
def _status_locked(self) -> dict:
|
||||
if self._run is None:
|
||||
return {"run": None, "stdout": "", "stderr": ""}
|
||||
run = dict(self._run)
|
||||
if run["status"] == "running":
|
||||
run["elapsed_seconds"] = time.time() - run["started_at"]
|
||||
elif run["finished_at"] is not None:
|
||||
run["elapsed_seconds"] = run["finished_at"] - run["started_at"]
|
||||
return {
|
||||
"run": run,
|
||||
"stdout": "\n".join(self._stdout),
|
||||
"stderr": "\n".join(self._stderr),
|
||||
"log_lines_limit": self.max_log_lines,
|
||||
}
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Kill any active child process (tracker stop path)."""
|
||||
with self._lock:
|
||||
process = self._process
|
||||
if process is not None and process.poll() is None:
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
Reference in New Issue
Block a user