944 lines
30 KiB
Python
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",
|
|
}
|