371 lines
11 KiB
Python
371 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from server.sidecar import (
|
|
PROBE_OK_PREFIX,
|
|
NativeProbeResult,
|
|
SidecarIdentityApp,
|
|
SidecarStartupError,
|
|
build_desktop_app,
|
|
build_probe_command,
|
|
load_config,
|
|
main,
|
|
normalize_loopback_host,
|
|
normalize_nonce,
|
|
parent_is_alive,
|
|
parse_parent_pid,
|
|
run_native_probe,
|
|
run_sidecar,
|
|
run_solver_child,
|
|
watch_parent,
|
|
)
|
|
|
|
|
|
def test_sidecar_config_defaults_and_env_overrides() -> None:
|
|
assert load_config({}).host == "127.0.0.1"
|
|
assert load_config({}).port == 8000
|
|
assert load_config({}).ui_dir is None
|
|
assert load_config({}).nonce is None
|
|
assert load_config({}).parent_pid is None
|
|
assert load_config({"APS_API_HOST": "localhost", "APS_API_PORT": "43117"}) == (
|
|
load_config({"APS_API_HOST": "127.0.0.1", "APS_API_PORT": "43117"})
|
|
)
|
|
assert load_config({"APS_API_HOST": "::1", "APS_API_PORT": "65535"}).host == "::1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
["", "0.0.0.0", "192.168.1.20", "example.com", "::", "[::1]"],
|
|
)
|
|
def test_sidecar_rejects_non_loopback_or_ambiguous_hosts(host: str) -> None:
|
|
with pytest.raises(SidecarStartupError):
|
|
normalize_loopback_host(host)
|
|
|
|
|
|
@pytest.mark.parametrize("port", ["0", "65536", "not-a-port", "3.14"])
|
|
def test_sidecar_rejects_invalid_ports(port: str) -> None:
|
|
with pytest.raises(SidecarStartupError):
|
|
load_config({"APS_API_PORT": port})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"nonce", ["short", "g" * 64, "a" * 63, "a" * 65, "a" * 63 + "\n"]
|
|
)
|
|
def test_sidecar_rejects_invalid_nonce(nonce: str) -> None:
|
|
with pytest.raises(SidecarStartupError):
|
|
normalize_nonce(nonce)
|
|
|
|
|
|
@pytest.mark.parametrize("pid", ["bad", "0", "-1", str(__import__("os").getpid())])
|
|
def test_sidecar_rejects_invalid_parent_pid(pid: str) -> None:
|
|
with pytest.raises(SidecarStartupError):
|
|
parse_parent_pid(pid)
|
|
|
|
|
|
def test_parent_liveness_detects_current_process() -> None:
|
|
assert parent_is_alive(__import__("os").getpid()) is True
|
|
|
|
|
|
def test_parent_watchdog_exits_after_parent_disappears() -> None:
|
|
checks = iter([True, False])
|
|
exits: list[int] = []
|
|
watch_parent(
|
|
12345,
|
|
is_alive=lambda _pid: next(checks),
|
|
exit_process=exits.append,
|
|
interval_seconds=0,
|
|
)
|
|
assert exits == [0]
|
|
|
|
|
|
def test_probe_command_supports_python_and_frozen_sidecar() -> None:
|
|
assert build_probe_command(executable="python", frozen=False) == [
|
|
"python",
|
|
"-X",
|
|
"faulthandler",
|
|
"-m",
|
|
"server.sidecar",
|
|
"--probe-child",
|
|
]
|
|
assert build_probe_command(executable="aps-sidecar.exe", frozen=True) == [
|
|
"aps-sidecar.exe",
|
|
"--probe-child",
|
|
]
|
|
|
|
|
|
def test_solver_child_uses_protocol_main_with_passthrough_args(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[list[str]] = []
|
|
|
|
class WorkerModule:
|
|
@staticmethod
|
|
def main() -> int:
|
|
calls.append(sys.argv[1:])
|
|
return 17
|
|
|
|
monkeypatch.setitem(sys.modules, "server.engines.solver_worker", WorkerModule())
|
|
|
|
original_argv = sys.argv[:]
|
|
assert run_solver_child(["--protocol-version", "1"]) == 17
|
|
assert calls == [["--protocol-version", "1"]]
|
|
assert sys.argv == original_argv
|
|
|
|
|
|
def test_frozen_solver_child_entry_runs_stdin_stdout_protocol_without_web() -> None:
|
|
completed = subprocess.run(
|
|
[sys.executable, "-m", "server.sidecar", "--solver-child"],
|
|
input="{}",
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
check=False,
|
|
timeout=15,
|
|
)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
payload = json.loads(completed.stdout)
|
|
assert payload["marker"] == "APS_SOLVER_ERROR_V1"
|
|
assert payload["error"]["code"] == "SOLVER_PROTOCOL_MISMATCH"
|
|
assert "Uvicorn running" not in completed.stderr
|
|
|
|
|
|
def test_source_solver_child_entry_runs_under_isolated_python(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from pathlib import Path
|
|
|
|
from server.engines.base import EngineParams
|
|
from server.engines.solver_process import run_cp_assignment
|
|
from server.state.seed import seed_world
|
|
from server.timeutil import fmt_date, today0
|
|
|
|
sidecar_source = Path(__file__).resolve().parents[2] / "server" / "sidecar.py"
|
|
command = [
|
|
sys.executable,
|
|
"-I",
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
"-X",
|
|
"faulthandler",
|
|
str(sidecar_source),
|
|
"--solver-child",
|
|
]
|
|
monkeypatch.setenv("APS_SOLVER_CHILD_COMMAND_JSON", json.dumps(command))
|
|
params = EngineParams(
|
|
orderIds=[],
|
|
engineType="CP",
|
|
strategyTemplate="COMPREHENSIVE",
|
|
planningHorizonDays=14,
|
|
startDate=fmt_date(today0()),
|
|
timeLimitSeconds=2.0,
|
|
)
|
|
|
|
ordered, solver_meta = run_cp_assignment(
|
|
seed_world(), [], params, pipeline_label="isolated-sidecar-test"
|
|
)
|
|
|
|
assert ordered == []
|
|
process_meta = solver_meta["solverProcess"]
|
|
assert process_meta["protocolVersion"] == "aps.solver-process.v2"
|
|
assert process_meta["runtimeIdentity"]["safe"] is True
|
|
|
|
|
|
def test_main_routes_solver_child_without_probe_or_web(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[list[str]] = []
|
|
monkeypatch.setattr(
|
|
"server.sidecar.run_solver_child",
|
|
lambda argv: calls.append(list(argv)) or 23,
|
|
)
|
|
monkeypatch.setattr(
|
|
"server.sidecar.probe_native_dependencies",
|
|
lambda: pytest.fail("solver child must not execute the sidecar probe"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"server.sidecar.run_sidecar",
|
|
lambda: pytest.fail("solver child must not start the Web service"),
|
|
)
|
|
|
|
assert main(["--solver-child", "--protocol-version", "1"]) == 23
|
|
assert calls == [["--protocol-version", "1"]]
|
|
|
|
|
|
def test_run_native_probe_times_out_fail_closed() -> None:
|
|
def runner(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
raise subprocess.TimeoutExpired(cmd=["probe"], timeout=3)
|
|
|
|
with pytest.raises(SidecarStartupError, match="timed out after 3s"):
|
|
run_native_probe(timeout_seconds=3, runner=runner)
|
|
|
|
|
|
def test_run_native_probe_requires_clean_exit_and_success_marker() -> None:
|
|
calls: list[tuple[list[str], dict[str, object]]] = []
|
|
|
|
def runner(
|
|
command: list[str], **kwargs: object
|
|
) -> subprocess.CompletedProcess[str]:
|
|
calls.append((command, kwargs))
|
|
return subprocess.CompletedProcess(
|
|
command, 0, f"{PROBE_OK_PREFIX} numpy=2 pandas=2", ""
|
|
)
|
|
|
|
result = run_native_probe(timeout_seconds=5, runner=runner)
|
|
|
|
assert result.stdout.startswith(PROBE_OK_PREFIX)
|
|
assert calls[0][0][-2:] == ["server.sidecar", "--probe-child"]
|
|
assert calls[0][1]["timeout"] == 5
|
|
assert calls[0][1]["check"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("completed", "message"),
|
|
[
|
|
(subprocess.CompletedProcess(["probe"], 7, "", "loader failed"), "code 7"),
|
|
(
|
|
subprocess.CompletedProcess(
|
|
["probe"],
|
|
0,
|
|
f"{PROBE_OK_PREFIX}\n",
|
|
"Windows fatal exception: access violation",
|
|
),
|
|
"windows fatal exception",
|
|
),
|
|
(
|
|
subprocess.CompletedProcess(["probe"], 0, "imports loaded", ""),
|
|
"success marker",
|
|
),
|
|
],
|
|
)
|
|
def test_run_native_probe_fails_closed(
|
|
completed: subprocess.CompletedProcess[str],
|
|
message: str,
|
|
) -> None:
|
|
with pytest.raises(SidecarStartupError, match=message):
|
|
run_native_probe(runner=lambda *args, **kwargs: completed)
|
|
|
|
|
|
def test_run_sidecar_probes_before_uvicorn_and_forwards_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
events: list[object] = []
|
|
|
|
def probe() -> NativeProbeResult:
|
|
events.append("probe")
|
|
return NativeProbeResult(stdout=PROBE_OK_PREFIX, stderr="")
|
|
|
|
def uvicorn_runner(app: str, **kwargs: object) -> None:
|
|
events.append((app, kwargs))
|
|
|
|
monkeypatch.delenv("APS_MODE", raising=False)
|
|
assert (
|
|
run_sidecar(
|
|
env={
|
|
"APS_API_HOST": "127.0.0.1",
|
|
"APS_API_PORT": "43117",
|
|
"APS_UI_DIR": "C:/aps/ui",
|
|
"APS_SIDECAR_NONCE": "a" * 64,
|
|
"APS_PARENT_PID": "4242",
|
|
},
|
|
probe=probe,
|
|
uvicorn_runner=uvicorn_runner,
|
|
app_builder=lambda ui_dir, nonce: (
|
|
events.append(("ui", ui_dir, nonce)) or "desktop-app"
|
|
),
|
|
parent_watchdog=lambda pid: events.append(("watchdog", pid)),
|
|
)
|
|
== 0
|
|
)
|
|
assert events == [
|
|
"probe",
|
|
("watchdog", 4242),
|
|
("ui", "C:/aps/ui", "a" * 64),
|
|
(
|
|
"desktop-app",
|
|
{"host": "127.0.0.1", "port": 43117, "log_level": "info", "workers": 1},
|
|
),
|
|
]
|
|
assert __import__("os").environ["APS_MODE"] == "desktop"
|
|
|
|
|
|
def test_build_desktop_app_requires_an_index(tmp_path) -> None:
|
|
with pytest.raises(SidecarStartupError, match="desktop UI index is missing"):
|
|
build_desktop_app(str(tmp_path), None)
|
|
|
|
|
|
def test_build_desktop_app_mounts_same_origin_ui(
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
(tmp_path / "index.html").write_text("<!doctype html>", encoding="utf-8")
|
|
mounts: list[tuple[str, object, str]] = []
|
|
|
|
class FakeApp:
|
|
def mount(self, route: str, mounted: object, *, name: str) -> None:
|
|
mounts.append((route, mounted, name))
|
|
|
|
monkeypatch.setattr("server.main.app", FakeApp())
|
|
mounted_app = build_desktop_app(str(tmp_path), None)
|
|
|
|
assert isinstance(mounted_app, FakeApp)
|
|
assert mounts[0][0] == "/"
|
|
assert mounts[0][2] == "desktop-ui"
|
|
|
|
|
|
def test_sidecar_identity_app_requires_nonce_and_adds_identity_header() -> None:
|
|
calls: list[str] = []
|
|
|
|
async def inner(scope, receive, send) -> None:
|
|
calls.append("called")
|
|
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
await send({"type": "http.response.body", "body": b"ok"})
|
|
|
|
nonce = "b" * 64
|
|
client = TestClient(SidecarIdentityApp(inner, nonce))
|
|
|
|
denied = client.get("/")
|
|
wrong = client.get("/", headers={"x-aps-sidecar-nonce": "c" * 64})
|
|
response = client.get("/", headers={"x-aps-sidecar-nonce": nonce})
|
|
|
|
assert denied.status_code == 403
|
|
assert wrong.status_code == 403
|
|
assert "x-aps-sidecar-nonce" not in denied.headers
|
|
assert response.status_code == 200
|
|
assert response.headers["x-aps-sidecar-nonce"] == nonce
|
|
assert calls == ["called"]
|
|
|
|
|
|
def test_main_rejects_non_loopback_before_starting_probe(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
monkeypatch.setenv("APS_API_HOST", "0.0.0.0")
|
|
assert main([]) == 2
|
|
assert "APS_API_HOST must be loopback-only" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "win32", reason="release blocker targets Windows native wheels"
|
|
)
|
|
def test_native_dependency_probe_smoke() -> None:
|
|
result = run_native_probe(timeout_seconds=30)
|
|
assert PROBE_OK_PREFIX in result.stdout
|
|
assert "numpy=" in result.stdout
|
|
assert "pandas=" in result.stdout
|
|
assert "ortools=" in result.stdout
|
|
assert "python=" in result.stdout
|
|
assert "machine=" in result.stdout
|
|
assert "runtimeIdentity" in result.stdout
|
|
assert "baseExecutable" in result.stdout
|
|
assert "ENABLE_USER_SITE" in result.stdout
|
|
assert '"path"' in result.stdout
|
|
assert '"version"' in result.stdout
|