review(Driver.FOCAS): add byte-level wire-protocol test coverage
Re-review at 7286d320. -013 (Medium, testing): the managed FOCAS/2 wire-decode layer
(BuildPdu/ParseResponseBlocks, incl. cnc_getfigure stride) had zero byte-level tests; added
15 (no decode bug found). -014 (spindle-load truncation heuristic) deferred bench-gated.
Note: runtime read path is now pure-managed TCP (no P/Invoke except the probe handshake).
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
|---|---|
|
||||
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `04e0877b` (re-review; prior `76d35d1`) |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 0 |
|
||||
|
||||
@@ -328,3 +328,117 @@ three opt-in sections and assert the options reach the driver; add a
|
||||
fake client mid-session and asserts recovery.
|
||||
|
||||
**Resolution:** Resolved 2026-05-22 — Added `FocasDriverMediumFindingsTests.cs` covering: unknown-DeviceHostAddress init throw (003), ViewOnly enforcement for all tags (004), Volatile `_health` under concurrent reads (005), reconnect-after-external-dispose recovery (006), and a factory full-round-trip test for all three opt-in config sections (012).
|
||||
|
||||
## Re-review 2026-06-19 (commit 04e0877b)
|
||||
|
||||
Re-review of the FOCAS driver at HEAD (`04e0877b`, which descends from `7286d320`).
|
||||
Since the prior review at `76d35d1` the driver gained ~1,200 lines, most notably the
|
||||
pure-managed FOCAS/2 Ethernet **wire backend** (`Wire/FocasWireClient.cs`,
|
||||
`Wire/FocasWireProtocol.cs`, `Wire/WireFocasClient.cs`, `Wire/FocasConstants.cs`) and
|
||||
the `cnc_getfigure` per-axis position auto-scale path (`ReadPositionFiguresAsync` +
|
||||
`AxisFactor`). NB: there is **no P/Invoke** in the runtime read path — the wire backend
|
||||
speaks the Fanuc binary protocol directly over TCP. The only P/Invoke is the
|
||||
`cnc_allclibhndl3` / `cnc_freelibhndl` Test-Connect handshake in `FocasDriverProbe.cs`
|
||||
(marshalling reviewed below).
|
||||
|
||||
All 12 prior findings remain Resolved. Two new findings recorded below.
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Driver.FOCAS-014 (deferred) — otherwise no issues found |
|
||||
| 2 | OtOpcUa conventions | No issues found |
|
||||
| 3 | Concurrency & thread safety | No new issues found (see note) |
|
||||
| 4 | Error handling & resilience | No issues found |
|
||||
| 5 | Security | No issues found |
|
||||
| 6 | Performance & resource management | No issues found |
|
||||
| 7 | Design-document adherence | No issues found |
|
||||
| 8 | Code organization & conventions | No issues found |
|
||||
| 9 | Testing coverage | Driver.FOCAS-013 |
|
||||
| 10 | Documentation & comments | No issues found |
|
||||
|
||||
**Interop / marshalling note (P/Invoke probe).** `FocasDriverProbe.NativeFwlib` declares
|
||||
`cnc_allclibhndl3([MarshalAs(LPStr)] string ipaddr, ushort port, int timeout, out ushort handle)`
|
||||
with `CallingConvention.Cdecl` + `CharSet.Ansi`. This matches the published FWLIB
|
||||
signature (`short cnc_allclibhndl3(const char*, unsigned short, long, unsigned short*)`).
|
||||
The `out ushort handle` is freed best-effort in a `finally` even on a timeout race, and the
|
||||
whole call is wrapped so a `DllNotFoundException` / load failure degrades to TCP-only — sound.
|
||||
The one residual subtlety (FWLIB `long` is 32-bit on Win32 → `int` is correct on the
|
||||
typical x86 worker; on an LP64 Linux `libfwlib32.so` the C `long` is 64-bit and `int`
|
||||
would mis-marshal) is **bench-gated** (no FWLIB on this dev/CI host) and is recorded as
|
||||
context only, not a separate finding, because the project ships no Linux-FWLIB path today.
|
||||
|
||||
**Concurrency note (not a new finding).** `FocasDriver.DeviceState.Client` is a plain
|
||||
auto-property read/written without a lock from `EnsureConnectedAsync` (Read/Write/probe/
|
||||
fixed-tree threads) and `RecycleLoopAsync`/`DisposeClient`. A `HandleRecycle`-enabled
|
||||
config can therefore race a dispose against an in-flight read's captured client reference.
|
||||
This is the same lifecycle class as the already-Resolved Driver.FOCAS-006; `HandleRecycle`
|
||||
is disabled by default and the wire client's own `_lifetimeGate`/`_requestGate` plus the
|
||||
"reconnect on next call" recovery make the window self-correcting. Left as-is (no new
|
||||
finding) — hardening it to a per-device lock is a larger change with no offline-observable
|
||||
defect.
|
||||
|
||||
#### Driver.FOCAS-013
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Testing coverage |
|
||||
| Location | `Wire/FocasWireProtocol.cs`, `Wire/FocasWireClient.cs` (`ReadPositionFiguresAsync`, `ParseAlarms`, name/sysinfo decode) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The entire managed wire-protocol decode layer — the byte-offset-fragile
|
||||
code that is this driver's analogue of P/Invoke struct marshalling — has **zero** unit
|
||||
tests. No test in `Driver.FOCAS.Tests` references `FocasWireProtocol`, `FocasWireClient`,
|
||||
`ParseResponseBlocks`, `BuildPdu`/`BuildRequestBody`, `ReadAscii`/`ReadNameRecord`, or the
|
||||
new `cnc_getfigure` figure decode (`ReadPositionFiguresAsync`, added since the prior review).
|
||||
Every existing test drives the `IFocasClient` seam through `FakeFocasClient`, which bypasses
|
||||
all wire framing and big-endian decode. A regression in a block-envelope offset, a PDU
|
||||
length field, the ASCII NUL/space trimming, or the figure `short` stride would not be caught
|
||||
by any test — exactly the corruption class the review brief calls out for marshalling code.
|
||||
|
||||
**Recommendation:** Add offline (no-socket) unit tests for the public/internal static decode
|
||||
primitives: `BuildPdu` header layout + `ReadPduAsync` round-trip; `BuildRequestBody` block
|
||||
count/stride; `ParseResponseBlocks` (command/RC/payload extraction, truncation guards);
|
||||
`ReadAscii` (NUL stop + trailing-space/NUL trim) and `ReadNameRecord`; and a
|
||||
`ResponseBlock`-shaped payload that exercises the `cnc_getfigure` figure `short` stride.
|
||||
|
||||
**Resolution:** Resolved 2026-06-19 — Added `FocasWireProtocolTests.cs` (15 tests) covering
|
||||
`BuildPdu` header layout + magic/version/length, the `BuildPdu`→`ReadPdu` round-trip,
|
||||
`BuildRequestBody` block framing, `ParseResponseBlocks` (multi-block command/RC/payload
|
||||
extraction + the truncated-length and bad-block-length guards), `ReadAscii` NUL-stop and
|
||||
trailing space/NUL trimming, `ReadNameRecord` 2-byte extraction, and a response-block payload
|
||||
decoded into the per-axis `short` figure sequence that the `cnc_getfigure` path relies on.
|
||||
|
||||
#### Driver.FOCAS-014
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `Wire/WireFocasClient.cs:318-325` (`ReadSpindleMetricAsync` trailing-zero truncation) |
|
||||
| Status | Deferred |
|
||||
|
||||
**Description:** `WireFocasClient.ReadSpindleMetricAsync` (the shared decode for
|
||||
`GetSpindleLoadsAsync` / `GetSpindleMaxRpmsAsync`) stops accumulating at the first zero value
|
||||
after a non-zero one: `if (m.Value == 0 && list.Count > 0) break;`. The intent (per the inline
|
||||
comment) is to drop Fanuc's trailing zero-padding of unused spindle slots. But a spindle that
|
||||
is legitimately at **0% load** (stopped) sitting *between* two running spindles truncates the
|
||||
list at that point, dropping every subsequent spindle. Because the consumer in
|
||||
`FocasDriver.FixedTreeLoopAsync` index-aligns the returned list to the spindle order
|
||||
(`state.LastSpindleLoads[i] = loads[i]`) and the read path looks up `Load` by that index, a
|
||||
dropped middle element **misaligns all later spindles' Load values**. The truncation is correct
|
||||
only if the CNC always returns a contiguous run of active spindles followed by zero padding —
|
||||
which holds for single-spindle and trailing-stopped layouts but not for a stopped middle
|
||||
spindle.
|
||||
|
||||
**Recommendation:** Distinguish "trailing padding" from "a real zero in the middle". Either read
|
||||
the actual spindle count from `cnc_rdspdlname` / sysinfo and decode exactly that many fixed-width
|
||||
slots (no zero-based truncation), or keep zeros and trim only a trailing run of zeros after
|
||||
decoding the full payload.
|
||||
|
||||
**Resolution:** Deferred — the correct slot-count/padding semantics of `cnc_rdspload` /
|
||||
`cnc_rdspmaxrpm` depend on the real Fanuc wire response shape, which is bench-CNC-gated (this
|
||||
managed backend's binary shapes are validated only against the in-tree `focas_mock` sim, per the
|
||||
`Wire/FocasWireClient.cs` remarks). Fixing the truncation heuristic without a real CNC's padding
|
||||
behaviour risks substituting one wrong assumption for another. Waiting on a bench-CNC verification
|
||||
pass (the same gate that covers the `cnc_getfigure` binary shape).
|
||||
|
||||
Reference in New Issue
Block a user