aps-agent/tests/golden/test_solver_subprocess.py

944 lines
30 KiB
Python

from __future__ import annotations
import copy
import json
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
from server.engines import solver_process
from server.engines.base import EngineParams
from server.engines.solver_process import (
ENV_CHILD_COMMAND,
PROTOCOL_VERSION,
SolverProcessError,
run_cp_assignment,
)
def _set_child(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
source: str,
*,
name: str = "child_fixture.py",
executable: str | None = None,
) -> Path:
script = tmp_path / name
script.write_text(textwrap.dedent(source), encoding="utf-8")
command = [
executable or sys.executable,
"-I",
"-B",
"-X",
"utf8",
"-X",
"faulthandler",
str(script),
]
monkeypatch.setenv(ENV_CHILD_COMMAND, json.dumps(command))
return script
def _set_semantic_child(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
mode: str,
) -> Path:
return _set_child(
monkeypatch,
tmp_path,
f"""
import hashlib, json, sys
mode = {mode!r}
request = json.loads(sys.stdin.read())
entries = list(request["entries"])
status = "TRIVIAL" if not entries else "OPTIMAL"
slots = []
for index, entry in enumerate(entries):
slot = {{"orderIndex": index, "isFrozen": False}}
so = entry.get("so")
if isinstance(so, dict):
slot["orderNo"] = so.get("orderNo")
item = entry.get("item")
if isinstance(item, dict):
slot["productId"] = item.get("productId")
slots.append(slot)
pipeline = request["pipelineLabel"]
if mode == "wrong-pipeline":
pipeline = "FORGED-PIPELINE"
elif mode == "unknown-status":
status = "FORGED_STATUS"
elif mode == "nonempty-trivial":
status = "TRIVIAL"
elif mode == "empty-optimal":
status = "OPTIMAL"
elif mode == "extra-slot":
slots.append({{"orderIndex": 99, "isFrozen": False}})
elif mode == "wrong-order" and slots:
slots[0]["orderNo"] = "FORGED-ORDER"
elif mode == "wrong-product" and slots:
slots[0]["productId"] = "FORGED-PRODUCT"
meta = {{
"pipeline": pipeline,
"status": status,
"objective": 0.0,
"gap": 0.0,
"operationSlots": slots,
}}
response = {{
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {{"safe": True, "implementation": "CPython"}},
"result": {{"entries": entries, "solverMeta": meta}},
}}
canonical = json.dumps(
response, ensure_ascii=False, allow_nan=False,
sort_keys=True, separators=(",", ":")
)
response["responseDigest"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
sys.stdout.write(json.dumps(response))
""",
name=f"semantic_{mode}.py",
)
def _call(*, timeout_seconds: float = 3.0):
return run_cp_assignment(
{"sentinel": {"value": 1}},
[],
EngineParams(engineType="CP", timeLimitSeconds=0.5),
warm_start=None,
pipeline_label="W66-PROTOCOL",
timeout_seconds=timeout_seconds,
)
def _assert_error(code: str, *, timeout_seconds: float = 3.0) -> SolverProcessError:
with pytest.raises(SolverProcessError) as caught:
_call(timeout_seconds=timeout_seconds)
assert caught.value.code == code
assert caught.value.message
assert caught.value.as_dict() == {
"code": code,
"message": caught.value.message,
"details": caught.value.details,
}
return caught.value
def _valid_operation_slot_payload() -> tuple[dict, list[dict], list[dict], dict]:
from server.engines.cp_engine import CpSatEngine, optimize_line_assignment
from tests.golden.test_cp_c3_calendar import _params, _single_line_world
world, start_date = _single_line_world(quantity=500, day_shift_only=True)
params = _params(start_date)
entries, _, _ = CpSatEngine().collect_and_order(world, params)
ordered, meta = optimize_line_assignment(
world, entries, params, pipeline_label="SLOT-CONTRACT",
)
assert meta["status"] in {"OPTIMAL", "FEASIBLE"}
return world, entries, ordered, meta
def _valid_frozen_operation_slot_payload() -> tuple[dict, list[dict], list[dict], dict]:
from server.engines.cp_engine import CpSatEngine, optimize_line_assignment
from tests.golden.test_cp_c3_calendar import _params, _single_line_world
world, start_date = _single_line_world(quantity=60, day_shift_only=True)
world["workOrders"] = [{
"id": 990_001,
"schedulingVersionId": 990_002,
"productionOrderId": None,
"orderNo": "FROZEN-SO",
"lineId": 1,
"workstationId": 1,
"plannedStartTime": f"{start_date} 09:00",
"plannedEndTime": f"{start_date} 11:00",
"isFrozen": True,
}]
params = _params(start_date).model_copy(update={"freezeWindowHours": 4.0})
entries, _, _ = CpSatEngine().collect_and_order(world, params)
ordered, meta = optimize_line_assignment(
world, entries, params, pipeline_label="FROZEN-SLOT-CONTRACT",
)
assert meta["status"] in {"OPTIMAL", "FEASIBLE"}
frozen = [slot for slot in meta["operationSlots"] if slot.get("isFrozen")]
active = [slot for slot in meta["operationSlots"] if not slot.get("isFrozen")]
assert len(frozen) == 1
assert len(active) == 1
assert frozen[0]["workstationId"] == active[0]["workstationId"]
return world, entries, ordered, meta
def _set_single_segment_interval(slot: dict, *, start: int, end: int) -> None:
assert slot["segmentCount"] == 1
assert start < end
duration = end - start
slot.update({
"startMin": start,
"endMin": end,
"durationMin": duration,
"processingMinutes": duration,
"elapsedSpanMinutes": duration,
"pauseMinutes": 0,
})
slot["segments"][0].update({
"startMin": start,
"endMin": end,
"durationMin": duration,
})
def test_real_worker_uses_isolated_runtime_and_does_not_mutate_parent_inputs(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
monkeypatch.delenv(ENV_CHILD_COMMAND, raising=False)
monkeypatch.delenv(solver_process.ENV_CHILD_EXECUTABLE, raising=False)
monkeypatch.chdir(tmp_path)
world = {"sentinel": {"nested": [1, 2, 3]}}
entries: list[dict] = []
before_world = copy.deepcopy(world)
before_entries = copy.deepcopy(entries)
ordered, meta = run_cp_assignment(
world,
entries,
EngineParams(engineType="CP", timeLimitSeconds=0.5),
pipeline_label="W66-PROTOCOL",
timeout_seconds=20,
)
assert ordered == []
assert meta["status"] == "TRIVIAL"
assert meta["pipeline"] == "W66-PROTOCOL"
process_meta = meta["solverProcess"]
assert process_meta["protocolVersion"] == PROTOCOL_VERSION
assert len(process_meta["requestId"]) == 64
identity = process_meta["runtimeIdentity"]
assert identity["safe"] is True
assert identity["implementation"] == "CPython"
assert identity["isolated"] is True
assert identity["enableUserSite"] is False
assert set(identity["packages"]) == {"ortools", "numpy", "pandas"}
assert world == before_world
assert entries == before_entries
def test_real_worker_translates_python_failure_without_mutating_parent(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv(ENV_CHILD_COMMAND, raising=False)
monkeypatch.delenv(solver_process.ENV_CHILD_EXECUTABLE, raising=False)
world = {"sentinel": {"nested": [1, 2, 3]}}
before = copy.deepcopy(world)
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
world,
[],
{"engineType": "CP", "timeLimitSeconds": "not-a-number"},
pipeline_label="W66-PROTOCOL",
timeout_seconds=10,
)
assert caught.value.code == "SOLVER_EXECUTION_FAILED"
assert caught.value.details["exceptionType"] == "ValidationError"
assert world == before
def test_request_is_canonical_detached_and_transmits_warm_start(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
world, _expected_entries, entries, valid_meta = _valid_operation_slot_payload()
valid_meta_json = json.dumps(valid_meta, ensure_ascii=False)
_set_child(
monkeypatch,
tmp_path,
f"""
import hashlib, json, sys
request = json.loads(sys.stdin.read())
meta = json.loads({valid_meta_json!r})
meta.update({{
"pipeline": request["pipelineLabel"],
"warmStart": request["warmStart"],
"engineType": request["params"]["engineType"],
}})
response = {{
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"operation": request["operation"],
"invocationId": request.get("invocationId"),
"runtimeIdentity": {{"safe": True, "implementation": "CPython"}},
"result": {{
"entries": request["entries"],
"solverMeta": meta,
}},
}}
canonical = json.dumps(
response, ensure_ascii=False, allow_nan=False,
sort_keys=True, separators=(",", ":")
)
response["responseDigest"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
sys.stdout.write(json.dumps(response, separators=(",", ":")))
""",
)
warm_start = [{"lineId": 7, "start": 10, "end": 20}]
before = (copy.deepcopy(world), copy.deepcopy(entries), copy.deepcopy(warm_start))
ordered, meta = run_cp_assignment(
world,
entries,
EngineParams(engineType="CP"),
warm_start=warm_start,
pipeline_label="RULE→CP-SAT",
timeout_seconds=3,
)
assert ordered == entries
assert meta["pipeline"] == "RULE→CP-SAT"
assert meta["warmStart"] == warm_start
assert meta["engineType"] == "CP"
assert meta["solverProcess"]["responseDigest"]
assert (world, entries, warm_start) == before
@pytest.mark.parametrize("digest_mode", ["missing", "wrong"])
def test_response_digest_is_mandatory_and_verified(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
digest_mode: str,
) -> None:
digest_statement = (
"pass"
if digest_mode == "missing"
else 'response["responseDigest"] = "0" * 64'
)
_set_child(
monkeypatch,
tmp_path,
f"""
import json, sys
request = json.loads(sys.stdin.read())
response = {{
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {{"safe": True, "implementation": "CPython"}},
"result": {{
"entries": [],
"solverMeta": {{
"pipeline": request["pipelineLabel"],
"status": "TRIVIAL",
"objective": 0,
"gap": 0.0,
}},
}},
}}
{digest_statement}
sys.stdout.write(json.dumps(response))
""",
)
_assert_error("SOLVER_RESPONSE_INVALID")
@pytest.mark.parametrize(
"response_entries",
[
"[]",
'request["entries"][:-1]',
'request["entries"] + [request["entries"][0]]',
'[dict(request["entries"][0], quantity=999), request["entries"][1]]',
],
)
def test_response_entries_must_be_complete_unchanged_permutation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
response_entries: str,
) -> None:
_set_child(
monkeypatch,
tmp_path,
f"""
import hashlib, json, sys
request = json.loads(sys.stdin.read())
response = {{
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {{"safe": True, "implementation": "CPython"}},
"result": {{
"entries": {response_entries},
"solverMeta": {{
"pipeline": request["pipelineLabel"],
"status": "OPTIMAL",
"objective": 0.0,
"gap": 0.0,
"operationSlots": [
{{"orderIndex": index, "isFrozen": False}}
for index, _entry in enumerate(request["entries"])
],
}},
}},
}}
canonical = json.dumps(
response, ensure_ascii=False, allow_nan=False,
sort_keys=True, separators=(",", ":")
)
response["responseDigest"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
sys.stdout.write(json.dumps(response))
""",
)
entries = [
{"orderNo": "SO-1", "quantity": 1},
{"orderNo": "SO-2", "quantity": 2},
]
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
{"sentinel": True},
entries,
EngineParams(engineType="CP"),
pipeline_label="W66-SEMANTIC",
timeout_seconds=3,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
def test_feasible_response_requires_operation_slot_coverage(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_set_child(
monkeypatch,
tmp_path,
"""
import hashlib, json, sys
request = json.loads(sys.stdin.read())
response = {
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {"safe": True, "implementation": "CPython"},
"result": {
"entries": request["entries"],
"solverMeta": {
"pipeline": request["pipelineLabel"],
"status": "OPTIMAL",
"objective": 0.0,
"gap": 0.0,
"operationSlots": [{"orderIndex": 0, "isFrozen": False}],
},
},
}
canonical = json.dumps(
response, ensure_ascii=False, allow_nan=False,
sort_keys=True, separators=(",", ":")
)
response["responseDigest"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
sys.stdout.write(json.dumps(response))
""",
)
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
{"sentinel": True},
[{"orderNo": "SO-1"}, {"orderNo": "SO-2"}],
EngineParams(engineType="CP"),
pipeline_label="W66-SLOTS",
timeout_seconds=3,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
@pytest.mark.parametrize("mode", ["extra-slot", "wrong-order", "wrong-product"])
def test_operation_slots_reject_out_of_range_or_misbound_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
mode: str,
) -> None:
_set_semantic_child(monkeypatch, tmp_path, mode)
entries = [
{"so": {"orderNo": "SO-1"}, "item": {"productId": 101}},
{"so": {"orderNo": "SO-2"}, "item": {"productId": 102}},
]
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
{"sentinel": True},
entries,
EngineParams(engineType="CP"),
pipeline_label="W66-SLOT-IDENTITY",
timeout_seconds=3,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
@pytest.mark.parametrize("mode", ["missing", "duplicate", "unknown-routing-step"])
def test_operation_slots_require_strict_logical_operation_identity_bijection(mode: str) -> None:
world, entries, ordered, meta = _valid_operation_slot_payload()
slots = meta["operationSlots"]
if mode == "missing":
slots.pop()
elif mode == "duplicate":
slots.append(copy.deepcopy(slots[0]))
else:
slot = slots[0]
slot["routingStepId"] = 999_999
slot["logicalOperationKey"] = (
f"{slot['salesOrderId']}:{slot['salesOrderItemId']}:999999"
)
with pytest.raises(SolverProcessError) as caught:
solver_process._validate_operation_slots(
world, ordered, meta, expected_entries=entries,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
@pytest.mark.parametrize(
"field,delta",
[
("segmentDuration", 1),
("processingMinutes", 1),
("elapsedSpanMinutes", 1),
("pauseMinutes", 1),
("segmentCount", 1),
],
)
def test_operation_slots_reject_segment_arithmetic_drift(field: str, delta: int) -> None:
world, entries, ordered, meta = _valid_operation_slot_payload()
slot = meta["operationSlots"][0]
if field == "segmentDuration":
slot["segments"][0]["durationMin"] += delta
else:
slot[field] += delta
with pytest.raises(SolverProcessError) as caught:
solver_process._validate_operation_slots(
world, ordered, meta, expected_entries=entries,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
@pytest.mark.parametrize(
"field",
["sourceSchedulingVersionId", "startMin", "endMin"],
)
def test_frozen_operation_slots_reject_source_version_or_clipped_time_tampering(
field: str,
) -> None:
world, entries, ordered, meta = _valid_frozen_operation_slot_payload()
frozen = next(slot for slot in meta["operationSlots"] if slot.get("isFrozen"))
if field == "sourceSchedulingVersionId":
frozen[field] += 1
elif field == "startMin":
_set_single_segment_interval(
frozen, start=frozen["startMin"] + 1, end=frozen["endMin"],
)
else:
_set_single_segment_interval(
frozen, start=frozen["startMin"], end=frozen["endMin"] - 1,
)
with pytest.raises(SolverProcessError) as caught:
solver_process._validate_operation_slots(
world, ordered, meta, expected_entries=entries,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
assert "\u51bb\u7ed3 operationSlot" in caught.value.message
def test_operation_slots_reject_new_segment_overlapping_frozen_workstation() -> None:
world, entries, ordered, meta = _valid_frozen_operation_slot_payload()
frozen = next(slot for slot in meta["operationSlots"] if slot.get("isFrozen"))
active = next(slot for slot in meta["operationSlots"] if not slot.get("isFrozen"))
overlap_start = frozen["startMin"] + 1
overlap_end = overlap_start + active["processingMinutes"]
assert overlap_end < frozen["endMin"]
_set_single_segment_interval(active, start=overlap_start, end=overlap_end)
with pytest.raises(SolverProcessError) as caught:
solver_process._validate_operation_slots(
world, ordered, meta, expected_entries=entries,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
assert "workstation/team/tooling" in caught.value.message
@pytest.mark.parametrize(
("mode", "entries"),
[
("wrong-pipeline", [{"orderNo": "SO-1"}]),
("unknown-status", [{"orderNo": "SO-1"}]),
("nonempty-trivial", [{"orderNo": "SO-1"}]),
("empty-optimal", []),
],
)
def test_pipeline_and_status_inconsistency_is_rejected(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
mode: str,
entries: list[dict],
) -> None:
_set_semantic_child(monkeypatch, tmp_path, mode)
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
{"sentinel": True},
entries,
EngineParams(engineType="CP"),
pipeline_label="W66-STATUS",
timeout_seconds=3,
)
assert caught.value.code == "SOLVER_RESPONSE_INVALID"
def test_frozen_runtime_uses_its_own_solver_child_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(ENV_CHILD_COMMAND, raising=False)
monkeypatch.delenv(solver_process.ENV_CHILD_EXECUTABLE, raising=False)
monkeypatch.setattr(sys, "frozen", True, raising=False)
command, command_kind = solver_process._child_command()
assert command == [str(Path(sys.executable).resolve()), "--solver-child"]
assert command_kind == "frozen-self"
def test_nonzero_child_exit_is_structured(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import sys
sys.stdin.read()
sys.stderr.write("plain child failure")
raise SystemExit(7)
""",
)
error = _assert_error("SOLVER_PROCESS_EXITED")
assert error.details["returnCode"] == 7
assert "plain child failure" in error.details["stderr"]
def test_zero_exit_with_fatal_marker_is_never_accepted(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
response = {
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {"safe": True, "implementation": "CPython"},
"result": {"entries": [], "solverMeta": {}},
}
sys.stdout.write(json.dumps(response))
sys.stderr.write("Windows fatal exception: code 0xc0000139")
""",
)
error = _assert_error("SOLVER_NATIVE_FATAL")
assert error.details["returnCode"] == 0
assert error.details["fatalMarker"] in {"windows fatal exception", "0xc0000139"}
def test_timeout_calls_tree_termination_and_fails_closed(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import sys, time
sys.stdin.read()
time.sleep(60)
""",
)
terminated: list[int] = []
original = solver_process._terminate_process_tree
def tracking_terminate(process: subprocess.Popen[str]) -> None:
terminated.append(process.pid)
original(process)
monkeypatch.setattr(solver_process, "_terminate_process_tree", tracking_terminate)
error = _assert_error("SOLVER_PROCESS_TIMEOUT", timeout_seconds=0.1)
assert terminated == [error.details["pid"]]
assert error.details["timeoutSeconds"] == pytest.approx(0.1)
def test_timeout_is_bounded_when_child_never_reads_large_stdin(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import time
_set_child(
monkeypatch,
tmp_path,
"""
import time
time.sleep(60)
""",
name="never_reads_stdin.py",
)
large_world = {"payload": "x" * (2 * 1024 * 1024)}
started = time.monotonic()
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment(
large_world,
[],
EngineParams(engineType="CP", timeLimitSeconds=0.5),
pipeline_label="W66-NO-STDIN-READ",
timeout_seconds=0.2,
)
elapsed = time.monotonic() - started
assert caught.value.code == "SOLVER_PROCESS_TIMEOUT"
assert elapsed < 12.0, f"timeout supervision was blocked for {elapsed:.3f}s"
assert caught.value.details["timeoutSeconds"] == 0.2
def test_windows_tree_termination_uses_taskkill_t_f(monkeypatch: pytest.MonkeyPatch):
calls: list[list[str]] = []
class FakeProcess:
pid = 43210
alive = True
def poll(self):
return None if self.alive else 1
def kill(self):
self.alive = False
def wait(self, timeout):
self.alive = False
return 1
def fake_run(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(solver_process, "_is_windows", lambda: True)
monkeypatch.setattr(solver_process.subprocess, "run", fake_run)
solver_process._terminate_process_tree(FakeProcess()) # type: ignore[arg-type]
assert calls == [["taskkill", "/PID", "43210", "/T", "/F"]]
@pytest.mark.parametrize("payload", ["{", "not-json", "[]"])
def test_invalid_or_truncated_json_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, payload: str
):
_set_child(
monkeypatch,
tmp_path,
f"""
import sys
sys.stdin.read()
sys.stdout.write({payload!r})
""",
)
_assert_error("SOLVER_RESPONSE_INVALID")
def test_missing_success_marker_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
sys.stdout.write(json.dumps({
"protocolVersion": request["protocolVersion"],
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {"safe": True, "implementation": "CPython"},
"result": {"entries": [], "solverMeta": {}},
}))
""",
)
error = _assert_error("SOLVER_RESPONSE_INVALID")
assert error.details["marker"] is None
def test_protocol_mismatch_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
sys.stdout.write(json.dumps({
"protocolVersion": "aps.solver-process.v999",
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {"safe": True, "implementation": "CPython"},
"result": {"entries": [], "solverMeta": {}},
}))
""",
)
error = _assert_error("SOLVER_PROTOCOL_MISMATCH")
assert error.details["expected"] == PROTOCOL_VERSION
def test_request_id_mismatch_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
sys.stdout.write(json.dumps({
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": "0" * 64,
"runtimeIdentity": {"safe": True, "implementation": "CPython"},
"result": {"entries": [], "solverMeta": {}},
}))
""",
)
_assert_error("SOLVER_PROTOCOL_MISMATCH")
def test_unsafe_runtime_identity_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
sys.stdout.write(json.dumps({
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_SUCCESS_V1",
"ok": True,
"requestId": request["requestId"],
"runtimeIdentity": {
"safe": False,
"implementation": "CPython",
"reasons": ["ortools:user-site"],
},
"result": {"entries": [], "solverMeta": {}},
}))
""",
)
error = _assert_error("SOLVER_RUNTIME_UNSAFE")
assert "ortools:user-site" in error.details["runtimeIdentity"]["reasons"]
def test_structured_worker_error_is_propagated(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
_set_child(
monkeypatch,
tmp_path,
"""
import json, sys
request = json.loads(sys.stdin.read())
sys.stdout.write(json.dumps({
"protocolVersion": request["protocolVersion"],
"marker": "APS_SOLVER_ERROR_V1",
"ok": False,
"requestId": request["requestId"],
"error": {
"code": "SOLVER_EXECUTION_FAILED",
"message": "fixture execution failed",
"details": {"exceptionType": "RuntimeError"},
},
}))
""",
)
error = _assert_error("SOLVER_EXECUTION_FAILED")
assert error.message == "fixture execution failed"
assert error.details["exceptionType"] == "RuntimeError"
def test_invalid_command_override_fails_before_spawn(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(ENV_CHILD_COMMAND, "not-json")
error = _assert_error("SOLVER_COMMAND_INVALID")
assert ENV_CHILD_COMMAND in error.message
def test_request_validation_is_fail_closed():
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment({}, [], {}, pipeline_label="", timeout_seconds=1)
assert caught.value.code == "SOLVER_REQUEST_INVALID"
with pytest.raises(SolverProcessError) as caught:
run_cp_assignment({}, [], {}, pipeline_label="CP", timeout_seconds=0)
assert caught.value.code == "SOLVER_REQUEST_INVALID"
@pytest.mark.skipif(sys.platform != "win32", reason="Windows runtime provenance probe")
def test_default_path_anaconda_mix_is_rejected_when_present(
monkeypatch: pytest.MonkeyPatch,
):
default_python = shutil.which("python")
if default_python is None:
pytest.skip("PATH has no alternate python")
assert default_python is not None
if Path(default_python).resolve() == Path(sys.executable).resolve():
pytest.skip("PATH python is the current approved runtime")
worker = Path(solver_process.__file__).with_name("solver_worker.py").resolve()
command = [
str(Path(default_python).resolve()),
"-I",
"-B",
"-X",
"utf8",
"-X",
"faulthandler",
str(worker),
]
monkeypatch.setenv(ENV_CHILD_COMMAND, json.dumps(command))
with pytest.raises(SolverProcessError) as caught:
_call(timeout_seconds=15)
assert caught.value.code in {
"SOLVER_RUNTIME_UNSAFE",
"SOLVER_NATIVE_FATAL",
"SOLVER_PROCESS_EXITED",
}