9876dd9b5a
tests / build-and-test (push) Successful in 2m59s
tests / thread-sanitizer (push) Successful in 3m36s
tests / tshark-dissector (push) Successful in 2m25s
tests / secs4j-interop (push) Successful in 59s
tests / python-interop (push) Successful in 3m20s
tests / libfuzzer (push) Successful in 3m40s
Completes the daemon's GEM300 surface and adds two new test tiers. D10 — E87 carriers: CarrierStore gains the HandlerSlot observer pattern (add_id/slot_map/access_handler). The daemon's id-observer forwards host S3F17 decisions onto the Subscribe stream as CarrierAction (PROCEED on a Confirmed transition, CANCEL on CancelCarrier); ReportCarrier drives the flow tool-side: WAITING creates the carrier + records the slot map, IN_ACCESS/COMPLETE advance the access FSM (INVALID_OBJECT on unknown, CANNOT_DO_NOW on an illegal transition). E16 — operations RPCs: Describe (full name inventory: variables/events/ alarms/commands/constants + device header), FlushSpool (purge or drain), SendTerminalMessage (S10F1 tool->host, honest CANNOT_DO_NOW when no host and stream 10 isn't spoolable). Stream responsiveness: Subscribe/WatchHealth poll at 100ms (was 500ms) so a cancelled stream frees its sync-server worker thread promptly — this was found by the new stress test, which hung under Subscribe churn at 500ms. Tests: - A randomized concurrent RPC stress case: 4 threads x 250 seeded ops (set/get/fire/alarm/control-state/describe + Subscribe churn), asserts no failed RPC and a still-responsive engine afterward; prints its seed; a strong TSan target. - A virtual fab (interop/virtual_fab.py + the `fab` compose service / tools/spawn_fab.sh): N daemons, each with a secsgem-py host AND a secsgem_client tool, driven by seeded random traffic with end-to-end invariant checks (set/get round-trips, event->S6F11 and alarm->S5F1 delivery, command->tool->completion). Verified green at N=3 (~150 ops/eq, all commands round-tripped, 0 violations). Wired into run_interop.sh (now 13 steps). Also fixes the CI break from the previous commit: the Python-client lane's test_values.py step lacked PYTHONPATH=clients/python (now step-level env). Two bugs found and fixed while building this, both mine from this batch: 1. carrier test hung on a CancelCarrier of a still-NotConfirmed carrier — a self-transition the FSM doesn't signal, so the observer never fired and the stream Read blocked forever. Fixed to cancel a Confirmed carrier; the NotConfirmed edge is documented as a known E87 limitation. 2. the 500ms stream poll above. Daemon suite 7 cases / 214 assertions; core 475 / 3097; virtual fab green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
"""A randomized virtual fab.
|
|
|
|
N secs_gemd equipment instances (the `fab` compose service) each get TWO
|
|
independent actors attached:
|
|
|
|
- a secsgem-py GemHostHandler playing the fab host over HSMS, and
|
|
- a secsgem_client Equipment playing the tool software over gRPC,
|
|
|
|
then a seeded random scenario drives all of them concurrently: variable
|
|
writes with read-back verification, event fires that must arrive at the
|
|
host as S6F11, alarm set/clear that must arrive as S5F1, host S1F3 polls,
|
|
and host S2F41 commands that must come back HCACK=4 and reach the tool's
|
|
handler. Every violated invariant is recorded; the seed is printed so any
|
|
failure is reproducible.
|
|
|
|
python3 virtual_fab.py --host fab --n 3 --seconds 20 [--seed 1234]
|
|
|
|
Exit 0 = every invariant held on every equipment.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import random
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
sys.path.insert(0, "/app/clients/python")
|
|
|
|
import secsgem.common
|
|
import secsgem.gem
|
|
import secsgem.hsms
|
|
import secsgem.secs
|
|
|
|
from secsgem_client import Equipment, SecsGemError
|
|
|
|
LOG = logging.getLogger("fab")
|
|
F = secsgem.secs.functions
|
|
|
|
|
|
class EquipmentActor:
|
|
"""One equipment: its host, its tool, its tallies, its verdicts."""
|
|
|
|
def __init__(self, idx: int, host: str, hsms_port: int, grpc_port: int,
|
|
rng: random.Random):
|
|
self.idx = idx
|
|
self.rng = rng
|
|
self.violations: list[str] = []
|
|
self.s6f11_seen = 0
|
|
self.s5f1_seen = 0
|
|
self.events_fired = 0
|
|
self.alarm_ops = 0
|
|
self.commands_sent = 0
|
|
self.commands_received = 0
|
|
self.ops = 0
|
|
|
|
self.tool = Equipment(f"{host}:{grpc_port}")
|
|
|
|
@self.tool.on("*")
|
|
def _any(cmd): # noqa: ANN001
|
|
self.commands_received += 1
|
|
self.tool.fire("ProcessStarted") # completion signal
|
|
|
|
self.tool.listen(background=True)
|
|
|
|
settings = secsgem.hsms.HsmsSettings(
|
|
address=host, port=hsms_port, session_id=0,
|
|
connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE,
|
|
device_type=secsgem.common.DeviceType.HOST)
|
|
self.host = secsgem.gem.GemHostHandler(settings)
|
|
self.host.register_stream_function(6, 11, self._on_s6f11)
|
|
self.host.register_stream_function(5, 1, self._on_s5f1)
|
|
self.host.enable()
|
|
|
|
def _on_s6f11(self, _h, message):
|
|
self.s6f11_seen += 1
|
|
self.host.send_response(F.SecsS06F12(0), message.header.system)
|
|
|
|
def _on_s5f1(self, _h, message):
|
|
self.s5f1_seen += 1
|
|
self.host.send_response(F.SecsS05F02(0), message.header.system)
|
|
|
|
def violate(self, what: str) -> None:
|
|
self.violations.append(what)
|
|
LOG.error("[eq%d] VIOLATION: %s", self.idx, what)
|
|
|
|
def setup(self) -> bool:
|
|
if not self.host.waitfor_communicating(timeout=20):
|
|
self.violate("HSMS establish-communications failed")
|
|
return False
|
|
self.host.send_and_waitfor_response(F.SecsS01F17())
|
|
self.host.send_and_waitfor_response(
|
|
F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [101]}]}))
|
|
self.host.send_and_waitfor_response(
|
|
F.SecsS02F35({"DATAID": 1, "DATA": [{"CEID": 300, "RPTID": [1]}]}))
|
|
self.host.send_and_waitfor_response(
|
|
F.SecsS02F37({"CEED": True, "CEID": [300]}))
|
|
self.host.send_and_waitfor_response(
|
|
F.SecsS05F03({"ALED": 0x80, "ALID": 1}))
|
|
return True
|
|
|
|
def step(self) -> None:
|
|
"""One random action with its invariant."""
|
|
self.ops += 1
|
|
roll = self.rng.random()
|
|
try:
|
|
if roll < 0.30:
|
|
v = round(self.rng.uniform(0.1, 9.9), 3)
|
|
self.tool.set(ChamberPressure=v)
|
|
got = self.tool.get("ChamberPressure")["ChamberPressure"]
|
|
if abs(got - v) > 1e-3:
|
|
self.violate(f"round-trip: set {v} got {got}")
|
|
elif roll < 0.45:
|
|
self.tool.fire("ProcessStarted")
|
|
self.events_fired += 1
|
|
elif roll < 0.60:
|
|
if self.rng.random() < 0.5:
|
|
self.tool.alarm("chiller_temp_high")
|
|
else:
|
|
self.tool.clear("chiller_temp_high")
|
|
self.alarm_ops += 1
|
|
elif roll < 0.80:
|
|
rsp = self.host.send_and_waitfor_response(F.SecsS01F03([]))
|
|
body = self.host.settings.streams_functions.decode(rsp).get()
|
|
if not isinstance(body, list) or len(body) < 1:
|
|
self.violate(f"S1F3 poll returned {body!r}")
|
|
else:
|
|
rsp = self.host.send_and_waitfor_response(
|
|
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
|
body = self.host.settings.streams_functions.decode(rsp).get()
|
|
hcack = int(body.get("HCACK", -1)) if isinstance(body, dict) else -1
|
|
self.commands_sent += 1
|
|
# A tool is subscribed for the whole run: must be HCACK 4.
|
|
if hcack != 4:
|
|
self.violate(f"S2F41 with subscriber -> HCACK {hcack} (want 4)")
|
|
except SecsGemError as e:
|
|
self.violate(f"client raised: {e}")
|
|
except Exception as e: # noqa: BLE001
|
|
self.violate(f"actor raised: {type(e).__name__}: {e}")
|
|
|
|
def finish(self) -> None:
|
|
time.sleep(2.0) # let in-flight S6F11/S5F1 land
|
|
if self.events_fired + self.commands_received > 0 and self.s6f11_seen == 0:
|
|
self.violate("events fired but host never received S6F11")
|
|
if self.alarm_ops > 0 and self.s5f1_seen == 0:
|
|
self.violate("alarms toggled but host never received S5F1")
|
|
if self.commands_sent > 0 and self.commands_received == 0:
|
|
self.violate("commands sent but tool handler never ran")
|
|
try:
|
|
self.host.disable()
|
|
self.tool.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def run(host: str, n: int, seconds: float, seed: int) -> int:
|
|
LOG.info("virtual fab: %d equipment, %.0fs, seed=%d", n, seconds, seed)
|
|
actors = [EquipmentActor(i, host, 5100 + i, 51000 + i,
|
|
random.Random(seed + i)) for i in range(n)]
|
|
if not all(a.setup() for a in actors):
|
|
return 1
|
|
|
|
deadline = time.monotonic() + seconds
|
|
|
|
def drive(a: EquipmentActor) -> None:
|
|
while time.monotonic() < deadline and len(a.violations) < 5:
|
|
a.step()
|
|
time.sleep(a.rng.uniform(0.01, 0.10))
|
|
|
|
threads = [threading.Thread(target=drive, args=(a,)) for a in actors]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
for a in actors:
|
|
a.finish()
|
|
|
|
bad = 0
|
|
for a in actors:
|
|
LOG.info("[eq%d] ops=%d cmds=%d/%d s6f11=%d s5f1=%d violations=%d",
|
|
a.idx, a.ops, a.commands_received, a.commands_sent,
|
|
a.s6f11_seen, a.s5f1_seen, len(a.violations))
|
|
bad += len(a.violations)
|
|
if bad:
|
|
LOG.error("FAB FAILED: %d violations (seed=%d to reproduce)", bad, seed)
|
|
return 1
|
|
LOG.info("virtual fab: all invariants held on all %d equipment (seed=%d)",
|
|
n, seed)
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host", default="fab")
|
|
ap.add_argument("--n", type=int, default=3)
|
|
ap.add_argument("--seconds", type=float, default=20.0)
|
|
ap.add_argument("--seed", type=int, default=None)
|
|
args = ap.parse_args()
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
for noisy in ("communication", "hsms_connection", "secsgem"):
|
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
seed = args.seed if args.seed is not None else random.SystemRandom().randrange(1 << 31)
|
|
return run(args.host, args.n, args.seconds, seed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|