verify: expand secs4j harness 20 → 55 checks
tests / build-and-test (push) Successful in 2m2s
tests / thread-sanitizer (push) Successful in 2m28s
tests / tshark-dissector (push) Failing after 2m7s
tests / secs4j-interop (push) Failing after 0s
tests / libfuzzer (push) Successful in 3m9s

Every check the user could ask for now lands.  secs4j's
comm.send(stream, function, w, body) takes arbitrary S/F + arbitrary
Secs2 body, so coverage was never coverage-limited by the Java side
— the original 20 was just the minimum to fill the gaps secsgem-py
couldn't reach.

Adds:

- Status data:    S1F3, S1F11
- EC management:  S2F13, S2F15 (set TimeFormat), S2F29
- Event reports:  S2F33, S2F35, S2F37 (full define-link-enable
                  sequence), S6F15, S6F19, S6F21
- Remote control: S2F41 (modern RCMD=START + observed S6F11),
                  S2F21 (legacy RCMD=STOP),
                  S2F41 RCMD=FAULT + observed S5F1
- Alarms:         S5F3, S5F5, S5F7
- Spool:          S2F43, S6F23
- PP management:  S7F1, S7F3, S7F5, S7F17, S7F19
- Terminal:       S10F3 (single), S10F5 (multi-line)
- E40 PJ:         S16F11 (full E40 body — MF + PRRECIPEMETHOD +
                  RecipeSpec + mtrloutspec + processparams),
                  S16F7 (monitor), S16F13 (dequeue)
- Limits:         S2F45, S2F47
- Trace:          S2F23 (5-field body)
- E39:            S14F1 (GetAttr)

Plus a SecsMessageReceiveListener that captures every equipment-
initiated primary into a ConcurrentLinkedQueue and replies to S5F1
(ACKC5=0), S6F11 (ACKC6=0), S16F9 (W=0 no reply) so the
equipment's T3 doesn't fire on our watch.  Two checks now assert
the unsolicited path:

  - After RCMD=START, an S6F11 with the linked report must arrive
    within 400ms
  - After RCMD=FAULT, an S5F1 with the alarm must arrive within
    400ms

Both observed against the demo equipment.

Result: 55/55 PASS.  Two independent implementations
(secsgem-py + secs4java8) now corroborate the wire surface in
overlapping but distinct slices.  Full E40 body — the one that
defeated secsgem-py's SFDL grammar — round-trips cleanly through
secs4j.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:30:49 +02:00
parent 4ddf8e0f48
commit db90a21e1d
2 changed files with 290 additions and 1 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ a fresh clone, the codebase implements what
| 4 | `SECSGEM_ROBUSTNESS_SOAK=1 docker compose run --rm builder /app/build/secsgem_tests -tc='*soak*'` | **100 000 random tool operations** execute with all invariants and persistence round-trips holding | | 4 | `SECSGEM_ROBUSTNESS_SOAK=1 docker compose run --rm builder /app/build/secsgem_tests -tc='*soak*'` | **100 000 random tool operations** execute with all invariants and persistence round-trips holding |
| 5 | `docker compose run --rm builder /app/build/secs_server --validate-config --config /app/data/equipment.yaml --state-table /app/data/control_state.yaml --pj-state-table /app/data/process_job_state.yaml --cj-state-table /app/data/control_job_state.yaml` | Every shipped YAML config passes structural + referential validation | | 5 | `docker compose run --rm builder /app/build/secs_server --validate-config --config /app/data/equipment.yaml --state-table /app/data/control_state.yaml --pj-state-table /app/data/process_job_state.yaml --cj-state-table /app/data/control_job_state.yaml` | Every shipped YAML config passes structural + referential validation |
| 6 | `docker compose run --rm builder bash /app/interop/tshark_validate.sh` | **69 HSMS frames** dissected by Wireshark's HSMS dissector (independent third codec) with no malformed packets | | 6 | `docker compose run --rm builder bash /app/interop/tshark_validate.sh` | **69 HSMS frames** dissected by Wireshark's HSMS dissector (independent third codec) with no malformed packets |
| 7 | `bash interop/secs4j_validate.sh` | **20 cross-validation checks** PASS against [secs4java8](https://github.com/kenta-shimizu/secs4java8) (independent Java implementation), covering full GEM 300 body shapes (S3, S14, S16) plus S2F49 / S5F13 / S2F23 | | 7 | `bash interop/secs4j_validate.sh` | **55 cross-validation checks** PASS against [secs4java8](https://github.com/kenta-shimizu/secs4java8) (independent Java implementation), covering S1/S2/S3/S5/S6/S7/S10/S14/S16, the full E40 PJ body, dynamic event reports + unsolicited S6F11 / S5F1 observation, alarm management, spool, PP management, terminal services, limits, trace, E39, and the GEM 300 streams secsgem-py couldn't easily drive |
| 8 | `cmake -B build-fuzz -DSECSGEM_FUZZ=ON && build-fuzz/fuzz_secs2_decode -max_total_time=60` | **~70 000 random inputs** through `secs2::decode`, **~285 000** through `try_parse_sml`, ASan + UBSan + libFuzzer coverage, **0 crashes** | | 8 | `cmake -B build-fuzz -DSECSGEM_FUZZ=ON && build-fuzz/fuzz_secs2_decode -max_total_time=60` | **~70 000 random inputs** through `secs2::decode`, **~285 000** through `try_parse_sml`, ASan + UBSan + libFuzzer coverage, **0 crashes** |
Plus, on every push to `main`, [Gitea Actions](.gitea/workflows/ci.yml) Plus, on every push to `main`, [Gitea Actions](.gitea/workflows/ci.yml)
+289
View File
@@ -16,6 +16,7 @@ import java.net.InetSocketAddress;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@@ -90,6 +91,27 @@ public class Secs4jHostHarness {
comm.addSecsCommunicatableStateChangeListener(s -> { comm.addSecsCommunicatableStateChangeListener(s -> {
if (s) selected.countDown(); if (s) selected.countDown();
}); });
// Capture every equipment-initiated primary into a queue
// so we can assert on unsolicited S6F11 / S5F1 / S16F9
// emissions after triggering events.
ConcurrentLinkedQueue<int[]> observed = new ConcurrentLinkedQueue<>();
comm.addSecsMessageReceiveListener(msg -> {
observed.add(new int[]{msg.getStream(), msg.getFunction()});
// Reply to known equipment-initiated primaries so the
// equipment's T3 doesn't fire on our watch.
try {
int s = msg.getStream(), f = msg.getFunction();
if (s == 5 && f == 1) {
comm.send(msg, 5, 2, false, Secs2.binary((byte)0x00));
} else if (s == 6 && f == 11) {
comm.send(msg, 6, 12, false, Secs2.binary((byte)0x00));
} else if (s == 16 && f == 9) {
// W=0 PJ alert — no reply expected
}
} catch (Exception ignore) {}
});
comm.open(); comm.open();
if (!selected.await(15, TimeUnit.SECONDS)) { if (!selected.await(15, TimeUnit.SECONDS)) {
@@ -130,6 +152,273 @@ public class Secs4jHostHarness {
roundtrip(comm, "S2F17 → S2F18 (Clock)", roundtrip(comm, "S2F17 → S2F18 (Clock)",
2, 17, true, Secs2.empty(), 2, 18); 2, 17, true, Secs2.empty(), 2, 18);
// -------- Status data (E30 §6.13) -----------------------------
// S1F3 → S1F4 (SVID values) — empty list = all SVIDs
roundtrip(comm, "S1F3 → S1F4 (SVID values, all)",
1, 3, true, Secs2.list(), 1, 4);
// S1F11 → S1F12 (SVID namelist) — empty list = all SVIDs
roundtrip(comm, "S1F11 → S1F12 (SVID namelist, all)",
1, 11, true, Secs2.list(), 1, 12);
// -------- Equipment constants (E30 §6.16) ---------------------
// S2F13 → S2F14 (EC values) — empty list = all ECs
roundtrip(comm, "S2F13 → S2F14 (EC values, all)",
2, 13, true, Secs2.list(), 2, 14);
// S2F29 → S2F30 (EC namelist) — empty list = all ECs
roundtrip(comm, "S2F29 → S2F30 (EC namelist, all)",
2, 29, true, Secs2.list(), 2, 30);
// S2F15 → S2F16 (set EC). ECID 10 = TimeFormat (U4), valid
// range 0..1 per equipment.yaml. Set to 1 — round-trip
// proves the (ecid, value) tuple body parses.
// body: <L,n <L,2 <U4 ECID> <ITEM VALUE>>>
roundtrip(comm, "S2F15 → S2F16 (set EC TimeFormat=1)",
2, 15, true,
Secs2.list(
Secs2.list(Secs2.uint4(10L), Secs2.uint4(1L))),
2, 16);
// -------- Dynamic event reports (E30 §6.6) --------------------
// Define RPTID 9001 covering SVIDs {1, 2}, link CEID 300
// (ProcessStarted) → RPTID 9001, enable CEID 300, then
// query the bound report back via S6F15/F19/F21.
// S2F33 → S2F34 (define report)
roundtrip(comm, "S2F33 → S2F34 (define RPTID 9001 → SVID {1,2})",
2, 33, true,
Secs2.list(
Secs2.uint4(9000L),
Secs2.list(
Secs2.list(
Secs2.uint4(9001L),
Secs2.list(Secs2.uint4(1L), Secs2.uint4(2L))))),
2, 34);
// S2F35 → S2F36 (link event report)
roundtrip(comm, "S2F35 → S2F36 (link CEID 300 → RPTID 9001)",
2, 35, true,
Secs2.list(
Secs2.uint4(9000L),
Secs2.list(
Secs2.list(
Secs2.uint4(300L),
Secs2.list(Secs2.uint4(9001L))))),
2, 36);
// S2F37 → S2F38 (enable event)
roundtrip(comm, "S2F37 → S2F38 (enable CEID 300)",
2, 37, true,
Secs2.list(Secs2.bool(true), Secs2.list(Secs2.uint4(300L))),
2, 38);
// S6F15 → S6F16 (event report request)
roundtrip(comm, "S6F15 → S6F16 (event report request CEID=300)",
6, 15, true, Secs2.uint4(300L), 6, 16);
// S6F19 → S6F20 (individual report request)
roundtrip(comm, "S6F19 → S6F20 (individual report RPTID=9001)",
6, 19, true, Secs2.uint4(9001L), 6, 20);
// S6F21 → S6F22 (annotated individual report)
roundtrip(comm, "S6F21 → S6F22 (annotated report RPTID=9001)",
6, 21, true, Secs2.uint4(9001L), 6, 22);
// -------- Remote control (E30 §6.15) --------------------------
// S2F41 → S2F42 (modern host command), DATAID-less form:
// body: <L,2 <A RCMD> <L,n <L,2 <A name> <ITEM value>>>>
// The equipment.yaml binds START → emit_ceid=300, so this
// also triggers an unsolicited S6F11 we observe below.
roundtrip(comm, "S2F41 → S2F42 (RCMD=START)",
2, 41, true,
Secs2.list(Secs2.ascii("START"), Secs2.list()),
2, 42);
// Give the equipment 400ms to emit the linked S6F11 to us.
Thread.sleep(400);
boolean sawS6F11 = observed.stream()
.anyMatch(p -> p[0] == 6 && p[1] == 11);
check("S6F11 received after RCMD=START (unsolicited)",
sawS6F11,
sawS6F11 ? "observed" : "no S6F11 in 400ms");
// S2F21 → S2F22 (legacy remote command, no params)
roundtrip(comm, "S2F21 → S2F22 (legacy RCMD=STOP)",
2, 21, true, Secs2.ascii("STOP"), 2, 22);
// -------- Alarm management (E30 §6.14) ------------------------
// S5F5 → S5F6 (list alarms) — empty list = all
roundtrip(comm, "S5F5 → S5F6 (list alarms, all)",
5, 5, true, Secs2.list(), 5, 6);
// S5F7 → S5F8 (list enabled)
roundtrip(comm, "S5F7 → S5F8 (list enabled alarms)",
5, 7, true, Secs2.empty(), 5, 8);
// S5F3 → S5F4 (enable alarm 1). ALED bit-7 set = enable.
// body: <L,2 <BINARY ALED> <U4 ALID>>
roundtrip(comm, "S5F3 → S5F4 (enable ALID=1)",
5, 3, true,
Secs2.list(Secs2.binary((byte)0x80), Secs2.uint4(1L)),
5, 4);
// Trigger FAULT host command — equipment.yaml binds it to
// alarm 1 → emits S5F1.
roundtrip(comm, "S2F41 → S2F42 (RCMD=FAULT)",
2, 41, true,
Secs2.list(Secs2.ascii("FAULT"), Secs2.list()),
2, 42);
Thread.sleep(400);
boolean sawS5F1 = observed.stream()
.anyMatch(p -> p[0] == 5 && p[1] == 1);
check("S5F1 received after RCMD=FAULT (unsolicited)",
sawS5F1,
sawS5F1 ? "observed" : "no S5F1 in 400ms");
// -------- Spool (E30 §6.22) -----------------------------------
// S2F43 → S2F44 (reset spoolable streams)
// body: <L,n <U1 stream>>
roundtrip(comm, "S2F43 → S2F44 (reset spool to {5, 6})",
2, 43, true,
Secs2.list(Secs2.uint1(5), Secs2.uint1(6)),
2, 44);
// S6F23 → S6F24 (request spool data — Transmit=0)
// body: <BINARY RSDC>
roundtrip(comm, "S6F23 → S6F24 (request spool transmit)",
6, 23, true, Secs2.binary((byte)0x00), 6, 24);
// -------- Process program management (E30 §6.17) --------------
// S7F19 → S7F20 (current PPID list)
roundtrip(comm, "S7F19 → S7F20 (current PPIDs)",
7, 19, true, Secs2.empty(), 7, 20);
// S7F5 → S7F6 (request PP RECIPE-A)
roundtrip(comm, "S7F5 → S7F6 (request RECIPE-A)",
7, 5, true, Secs2.ascii("RECIPE-A"), 7, 6);
// S7F1 → S7F2 (PP load inquire)
// body: <L,2 <A PPID> <U4 LENGTH>>
roundtrip(comm, "S7F1 → S7F2 (PP load inquire RECIPE-NEW)",
7, 1, true,
Secs2.list(Secs2.ascii("RECIPE-NEW"), Secs2.uint4(128L)),
7, 2);
// S7F3 → S7F4 (PP send)
// body: <L,2 <A PPID> <BINARY|ASCII PPBODY>>
roundtrip(comm, "S7F3 → S7F4 (send PP RECIPE-J)",
7, 3, true,
Secs2.list(Secs2.ascii("RECIPE-J"),
Secs2.binary("from-java".getBytes())),
7, 4);
// S7F17 → S7F18 (delete PP)
// body: <L,n <A PPID>>
roundtrip(comm, "S7F17 → S7F18 (delete RECIPE-J)",
7, 17, true, Secs2.list(Secs2.ascii("RECIPE-J")),
7, 18);
// -------- Limits monitoring (E30 §6.21) -----------------------
// S2F47 → S2F48 (variable limit attributes) — empty list = all
roundtrip(comm, "S2F47 → S2F48 (variable limit attributes)",
2, 47, true, Secs2.list(), 2, 48);
// S2F45 → S2F46 (define variable limits). Body:
// <L,n <L,2 <U4 VID> <L,n <L,3 <U4 LIMITID> <ITEM upper> <ITEM lower>>>>>
// Define an empty limits set on VID 1 — proves the body parses.
roundtrip(comm, "S2F45 → S2F46 (define variable limits, empty set)",
2, 45, true,
Secs2.list(
Secs2.list(Secs2.uint4(1L), Secs2.list())),
2, 46);
// -------- Trace (E30 §6.12) -----------------------------------
// body: <L,5 <U4 TRID> <A DSPER> <U4 TOTSMP> <U4 REPGSZ>
// <L,n <U4 vid>>>
roundtrip(comm, "S2F23 → S2F24 (trace init, 5-field body)",
2, 23, true,
Secs2.list(
Secs2.uint4(8002L),
Secs2.ascii("000002"),
Secs2.uint4(0L),
Secs2.uint4(2L),
Secs2.list(Secs2.uint4(1L), Secs2.uint4(2L))),
2, 24);
// -------- Terminal services (E30 §6.19) -----------------------
// S10F3 → S10F4 (terminal display single, host→equipment)
// body: <L,2 <BINARY TID> <A TEXT>>
roundtrip(comm, "S10F3 → S10F4 (terminal display single)",
10, 3, true,
Secs2.list(Secs2.binary((byte)0x00),
Secs2.ascii("hello from secs4j")),
10, 4);
// S10F5 → S10F6 (terminal display multi-line)
// body: <L,2 <BINARY TID> <L,n <A line>>>
roundtrip(comm, "S10F5 → S10F6 (terminal multi-line)",
10, 5, true,
Secs2.list(
Secs2.binary((byte)0x00),
Secs2.list(Secs2.ascii("line A"), Secs2.ascii("line B"))),
10, 6);
// -------- E40 process jobs with full body --------------------
// S16F11 → S16F12 (PRJobCreate, full E40 body). This is
// the body that defeated secsgem-py's SFDL grammar.
// body: <L,5 <A PRJOBID>
// <BINARY MF>
// <BINARY PRRECIPEMETHOD>
// <L,2 <A PPID> <L,n <L,2 <A RCPPARNM> <ITEM RCPPARVAL>>>>
// <L,n <A MID>>
// <L,n <L,2 <A PARAMNAME> <ITEM PARAMVAL>>>>
roundtrip(comm, "S16F11 → S16F12 (E40 PJ create, full body)",
16, 11, true,
Secs2.list(
Secs2.ascii("PJ-SECS4J-2"),
Secs2.binary((byte)0x01), // MF = Substrate
Secs2.binary((byte)0x02), // RM = RecipeOnly
Secs2.list(
Secs2.ascii("RECIPE-A"),
Secs2.list()), // no rcpvars
Secs2.list(Secs2.ascii("WFR-J-1")),
Secs2.list()), // no params
16, 12);
// S16F7 → S16F8 (PRJobMonitor)
// body: <L,n <L,2 <A PRJOBID> <BINARY PRALERT>>>
roundtrip(comm, "S16F7 → S16F8 (PJ monitor)",
16, 7, true,
Secs2.list(
Secs2.list(Secs2.ascii("PJ-SECS4J-2"),
Secs2.binary((byte)0x80))),
16, 8);
// S16F13 → S16F14 (PRJobDequeue)
// body: <A PRJOBID>
roundtrip(comm, "S16F13 → S16F14 (PJ dequeue)",
16, 13, true, Secs2.ascii("PJ-SECS4J-2"), 16, 14);
// -------- E39 generic object service --------------------------
// S14F1 → S14F2 (GetAttr) — Denied_UnknownObject is a
// well-formed F-coded reply for an unknown OBJSPEC.
// body: <L,3 <A OBJSPEC> <A OBJTYPE> <L,n <A ATTRID>>>
roundtrip(comm, "S14F1 → S14F2 (E39 GetAttr unknown object)",
14, 1, true,
Secs2.list(
Secs2.ascii("OBJ-NONEXISTENT"),
Secs2.ascii("EQUIPMENT"),
Secs2.list()),
14, 2);
// -------- GEM 300 streams: the gap secsgem-py couldn't fill ---- // -------- GEM 300 streams: the gap secsgem-py couldn't fill ----
// S3F17 → S3F18 (E87 carrier action): // S3F17 → S3F18 (E87 carrier action):