Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3baa332d6b | |||
| d66269a99b | |||
| d978573fb2 | |||
| 159f30dcd0 | |||
| 3803df0433 | |||
| e2663643dd | |||
| 85f003272c | |||
| fa7efb3b62 | |||
| 167a48bffa | |||
| 15dd75379d | |||
| 129b84fd69 | |||
| e8453eef6c | |||
| b36f5644d3 | |||
| 390c8269a0 | |||
| fe92ff4c34 | |||
| 277cd6ee3e | |||
| fe2b0614c8 | |||
| 75e559f6d8 | |||
| e5fe2e5547 | |||
| fe94a3bdb8 | |||
| 3edff3304b | |||
| 35152fcaf7 | |||
| 2b945672e9 | |||
| 501f6ea4e5 | |||
| 007174d28a | |||
| 4e6eed7454 | |||
| 8c685bcbba | |||
| 1ab961c226 | |||
| 7105ef3eb5 | |||
| bafbcbee7e | |||
| 4ba6cbab3a | |||
| be421a4cb0 | |||
| 29cd25dec8 | |||
| 341417f193 | |||
| 3d081ad29c | |||
| 13a6727033 | |||
| 7892eb13a8 | |||
| bd2bebeda6 | |||
| 9641cefe2e | |||
| e588443ced | |||
| 04b6563286 |
@@ -859,6 +859,15 @@ Two things to know before relying on any of this:
|
||||
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
|
||||
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
|
||||
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
|
||||
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
|
||||
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
|
||||
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
|
||||
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
|
||||
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
|
||||
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
|
||||
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
|
||||
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
|
||||
deployment carries it — the flag-on path always behaved this way.
|
||||
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
|
||||
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
|
||||
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
|
||||
@@ -910,6 +919,39 @@ Two things to know before relying on any of this:
|
||||
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
|
||||
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
|
||||
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
|
||||
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
|
||||
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
|
||||
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
|
||||
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
|
||||
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
|
||||
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
|
||||
**Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The
|
||||
`SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`);
|
||||
each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one
|
||||
Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the
|
||||
condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The
|
||||
root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the
|
||||
ungated path, and it is the only one that served an item created before the alarm was deployed (the
|
||||
SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is
|
||||
**implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no
|
||||
pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep
|
||||
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
|
||||
because conditions report their Part 9 method audit events through themselves.
|
||||
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
|
||||
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
|
||||
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
|
||||
protected `SubscribeToEvents` override that no-ops all-events items is what
|
||||
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
|
||||
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
|
||||
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
|
||||
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
|
||||
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
|
||||
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
|
||||
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
|
||||
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
|
||||
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
|
||||
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
|
||||
event type fails closed. Auditing is off by default (never configured in this repo).
|
||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
||||
@@ -932,8 +974,9 @@ Two things to know before relying on any of this:
|
||||
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
|
||||
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
|
||||
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
|
||||
`BadUserAccessDenied`, not closed**, and recovers on re-activation). ⚠️ Event/alarm monitored items are
|
||||
revalidated by neither trigger — pre-existing, and a named residual.
|
||||
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
|
||||
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
|
||||
two role gates (#563) but not reads or subscriptions.
|
||||
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
|
||||
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
|
||||
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
|
||||
@@ -1058,6 +1101,21 @@ See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` t
|
||||
|
||||
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
|
||||
|
||||
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
|
||||
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
|
||||
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
|
||||
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
|
||||
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
|
||||
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
|
||||
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
|
||||
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
|
||||
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
|
||||
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
|
||||
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
|
||||
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
|
||||
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
|
||||
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
||||
|
||||
## Historian / HistoryRead
|
||||
|
||||
**Backend: HistorianGateway (sole historian backend).** As of the gateway-integration cutover, the
|
||||
|
||||
+78
-9
@@ -36,9 +36,12 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
`EnsureFolderIsEventNotifier(equipFolder)`.
|
||||
|
||||
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
||||
every referencing equipment folder, and up to the Server object. A subscriber at any one
|
||||
every referencing equipment folder, and to the Server object. A subscriber at any one
|
||||
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
||||
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
||||
The Server-object (`i=2253`) copy is delivered by the condition itself, not by the folders'
|
||||
forward to the core Server object, so it can be filtered per subscriber by the data-plane ACL
|
||||
(Gitea #586 — see `docs/security.md`).
|
||||
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
||||
Server-object dedup + Part 9 ack correlation).
|
||||
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
||||
@@ -311,9 +314,30 @@ from the scripted `AlarmCommandRouter`:
|
||||
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
|
||||
`AlarmConditionState` SDK update already committed at step 1).
|
||||
|
||||
Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment`
|
||||
/ `Shelve` operations on a native condition stay on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only).
|
||||
Only the **Acknowledge** is routed to the driver. **A native condition
|
||||
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
||||
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
|
||||
`BadNotSupported` and report no event. There is no upstream path for them and
|
||||
no pair replication of a local state, and the driver projection
|
||||
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
|
||||
one at the next transition. `AddComment` stays on the scripted
|
||||
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
|
||||
applied locally.
|
||||
|
||||
A native condition is **never Unconfirmed**: it starts Confirmed, and because
|
||||
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
|
||||
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
|
||||
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
|
||||
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
|
||||
is client-visible.
|
||||
|
||||
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
|
||||
> an OPC UA Acknowledge is applied to the local condition and reported (one
|
||||
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
|
||||
> The driver never acknowledges, so its next transition for that alarm is
|
||||
> projected with `Acked = false` and the node reverts — with its own event. A
|
||||
> client acking against the Secondary sees acked → unacked. Ack against the
|
||||
> Primary (the higher ServiceLevel) for the ack to stick.
|
||||
|
||||
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
||||
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
|
||||
@@ -368,11 +392,56 @@ hosted service.
|
||||
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
|
||||
operator gate — it is system-initiated.
|
||||
|
||||
`WriteAlarmCondition` fires the Part 9 condition event only when the
|
||||
incoming state differs from the node's current live state (delta-gate),
|
||||
preventing the double-emit that would otherwise occur when the SDK
|
||||
auto-applies the acked state and the engine re-projection fires a
|
||||
duplicate event immediately after.
|
||||
### One condition event per method call (Gitea #587)
|
||||
|
||||
Every accepted Part 9 method call — Acknowledge, Confirm, AddComment,
|
||||
OneShotShelve, TimedShelve, Unshelve, Enable, Disable, on a native or a
|
||||
scripted condition — reports **exactly one** condition event carrying the
|
||||
new state, to folder subscribers and (through the ACL) Server-object
|
||||
subscribers alike. A refused call (bad status, e.g. a denied gate, an
|
||||
already-acknowledged condition, Enable/Disable on a native condition)
|
||||
reports nothing.
|
||||
|
||||
- **The SDK does not report it.** `ConditionState.ReportStateChange` is
|
||||
gated on `AutoReportStateChanges`, which this repo leaves `false` — so
|
||||
before #587 a client Acknowledge produced no condition event at all (an
|
||||
older comment claimed "the SDK auto-fires its own event"; it never did).
|
||||
Instead the node manager wraps each method's SDK handler
|
||||
(`WireMethodTransitionReporting`) and, once the handler returns Good,
|
||||
reports the condition itself. That report does not depend on the SDK's
|
||||
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
|
||||
materialised under an already-subscribed folder.
|
||||
- **The SDK's shelve methods never apply shelving state** (they only call
|
||||
`OnShelve`), so the wrapper applies it — with the same
|
||||
`(shelved, oneShot, 0)` mapping the projection uses — before reporting.
|
||||
- **The re-projection does not duplicate it.** The engine's (or driver's)
|
||||
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
|
||||
which compares against the node's current state — already the new state —
|
||||
and suppresses. The whole method call runs under the node-manager lock, so
|
||||
a fast re-projection cannot slip in between the SDK applying the state and
|
||||
the report.
|
||||
- **Only a real change is reported.** The condition's state is compared
|
||||
before and after the SDK handler; a call that changed nothing reports
|
||||
nothing. The SDK accepts acknowledging an acknowledged condition and sets
|
||||
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
|
||||
and Retain — silent, and a scripted node never drifts from its engine.
|
||||
AddComment always reports (the comment is the change).
|
||||
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
|
||||
acknowledge → AVEVA" above. The other methods are refused
|
||||
(`BadNotSupported`, no event), and a native ack keeps the condition
|
||||
Confirmed.
|
||||
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
|
||||
the SDK rejects any shelving time above it, so every client TimedShelve
|
||||
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
|
||||
days). The node applies the shelve with no SDK timer of its own; the engine
|
||||
owns the expiry and its Unshelved re-projection reports the one expiry
|
||||
event.
|
||||
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
|
||||
the SDK's, delivered only while the server is auditing over an encrypted
|
||||
channel.
|
||||
|
||||
Engine-driven transitions are unaffected: `WriteAlarmCondition` still fires
|
||||
whenever the incoming state differs from the node's current state.
|
||||
|
||||
### AdminUI path
|
||||
|
||||
|
||||
+3
-1
@@ -116,6 +116,8 @@ Source Time: 2026-03-30T19:58:38.0961252Z
|
||||
Server Time: 2026-03-30T19:58:38.0971257Z
|
||||
```
|
||||
|
||||
A Bad node status (unknown node, access denied, ...) is reported, not thrown: the command prints `Node:`, an empty `Value:` and `Status: 0x80340000 (BadNodeIdUnknown)`. Session-level failures (closed connection / channel) still fail the command.
|
||||
|
||||
### write
|
||||
|
||||
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
|
||||
@@ -284,7 +286,7 @@ Confirm successful: ns=1;s=TestMachine_001
|
||||
|
||||
### shelve
|
||||
|
||||
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`.
|
||||
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`. The `--node` is the condition node itself (the `SourceNode`/condition id an `alarms` notification reports): `ack`, `confirm`, `shelve`, `enable` and `disable` call the Part 9 method ids directly on that node — no `.Condition` / `.ShelvingState` suffix is appended (those child nodes do not exist on the OtOpcUa server).
|
||||
|
||||
```bash
|
||||
# One-shot shelve until the next clear
|
||||
|
||||
@@ -352,13 +352,13 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
|
||||
|
||||
**Scripted vs native conditions — Enable/Disable and Acknowledge:**
|
||||
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface.
|
||||
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
|
||||
|
||||
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
|
||||
|
||||
### Delta-gate de-duplication
|
||||
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.
|
||||
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
|
||||
|
||||
### AdminUI path
|
||||
|
||||
|
||||
+81
-8
@@ -20,6 +20,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||||
|
||||
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
|
||||
@@ -75,9 +76,10 @@ the driver's `DriverConfig` JSON. Key fields:
|
||||
|
||||
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
|
||||
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver.
|
||||
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
|
||||
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)).
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||
|
||||
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
|
||||
@@ -157,8 +159,29 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
|
||||
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
|
||||
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
|
||||
**Packing budgets the expected reply as well as the request (#582):** each queued request declares an
|
||||
upper bound on its reply (`expectedReplyBytes`; reads: elements x element width, STRING = 88 bytes per
|
||||
element, BOOL counted as 4) and a batch is closed when the summed MSP reply (`6 + 2n + sum`) would exceed
|
||||
the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent
|
||||
alone; writes and status-only replies count their 4-byte header. Pinned by
|
||||
`RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget`.
|
||||
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
|
||||
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
|
||||
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
|
||||
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
|
||||
two types into one value.
|
||||
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
|
||||
open budget.
|
||||
- **A target-closed CIP connection is recycled at once (#566).** When a connected reply says the controller
|
||||
no longer holds the class-3 connection while TCP stays up — general status `0x01` with extended `0x0107`
|
||||
(target connection not found) or `0x0203` (connection timed out), e.g. after a controller restart or a
|
||||
connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close)
|
||||
and the next request reopens through the throttle. The request that got the reply fails
|
||||
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
|
||||
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
|
||||
the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten
|
||||
connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct
|
||||
whichever it does — a silent target still faults the session through the exchange timeout.
|
||||
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
|
||||
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
|
||||
node (its connection carries the probe timeout). A later opener with different options is ignored and
|
||||
@@ -181,8 +204,33 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
|
||||
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
|
||||
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
|
||||
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
|
||||
alignment pad (84, not 82) (#570).
|
||||
The same holds when the controller refuses the **existence read itself** with a type error (CIP
|
||||
`0xFF`/`0x2107`): initialization completes with the status instead of throwing, so the runtime is not
|
||||
disposed and re-created (nor, for a sole lease holder, re-Forward-Opened) on every poll, and the device's
|
||||
connect backoff is not opened (#570). Micro800 `BYTE`/`WORD`/`LWORD` (`0xD1`/`0xD2`/`0xD4`) are checked as
|
||||
integers of their width: a `REAL`/`LREAL` or a narrower integer authored over an `LWORD` is
|
||||
`BadTypeMismatch`, not decoded garbage (#570).
|
||||
- **A malformed tag name is a per-tag configuration error** (#570). A `TagPath` that does not parse, or a
|
||||
name the CIP symbolic encoder refuses (non-ASCII, an empty member such as `A..B`, a member longer than
|
||||
255 characters, an unterminated or non-numeric subscript), reads and writes **`BadConfigurationError`**
|
||||
with one Warning per tag. Nothing reaches the device, so it never opens the device's connect backoff and
|
||||
never degrades driver health — healthy tags on the same device keep reading. (libplctag still reports its
|
||||
own `ErrorBadParam` for a name it rejects.) A malformed **probe** tag does not feed the backoff either.
|
||||
- **STRING capacity:** the built-in Logix `STRING` is 88 bytes on the wire — `LEN` + `DATA[82]` + 2 bytes of
|
||||
alignment padding — so a write holds at most **82** characters (it used to allow 84) and a read never
|
||||
returns the padding as characters. The write still sends the whole 88-byte structure, padding zeroed, as
|
||||
libplctag does. A user string type of any other size keeps the buffer-derived capacity (`size − 4`): the
|
||||
read alone cannot tell its `DATA` length from up to 3 bytes of padding, so a write may accept up to 3
|
||||
characters more than the type holds (#570).
|
||||
- **Releasing a tag never blocks** (#579). Disposing a native tag runtime — the driver does it under the
|
||||
tag's lock on an evict, a reinitialize or a shutdown — releases its connection lease in the background
|
||||
(`ConnectionLease.ReleaseInBackground`, the same shape the native AbLegacy runtime uses). It used to wait
|
||||
up to 2 s for the last lease's Forward Close. Nothing is lost by not waiting: the pool records the close
|
||||
synchronously, so a re-create the next instant still does not open a second session until that Forward
|
||||
Close + UnRegisterSession have finished, and the close is bounded by the connection's own budgets (the
|
||||
open budget for an open still in flight, 1 s for the Forward Close), so a connection is always released.
|
||||
Offline soak: `NativeAbCipLeaseReleaseTests` (25 create/release cycles, no leaked connection); a soak
|
||||
against `ab_server` has not been run.
|
||||
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
|
||||
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
|
||||
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
|
||||
@@ -194,9 +242,13 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
(2026-10-08) and nothing else. Results:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
- **libplctag cannot be deleted** until a bench controller allows write verification.
|
||||
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
|
||||
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
|
||||
call (#572).
|
||||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
|
||||
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
|
||||
bench controller exists.
|
||||
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
|
||||
are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
|
||||
lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` has no AdminUI form field; author it in
|
||||
the driver-config JSON (the form preserves keys it does not model, #576).
|
||||
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
|
||||
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
|
||||
- Follow-up issues for this workstream: #564 to #577.
|
||||
@@ -241,6 +293,27 @@ integration fixture is down during normal dev (Docker host fixture currently off
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
|
||||
## Native browse: paging, pacing and template leases
|
||||
|
||||
Native symbol browse (`transport` = `Native`; libplctag pages inside its own library and is unaffected):
|
||||
|
||||
- **Paging continues past the highest instance seen**, not the last entry of a page. The Symbol Object
|
||||
listing is requested from `start`; the next request starts at `max(instance in the reply) + 1`, so the
|
||||
order a controller lists a page in does not matter. A "more" reply whose highest instance is still below
|
||||
its `start` (or equals `uint.MaxValue`) made no progress and fails with `BadReply` ("made no progress")
|
||||
rather than looping against the controller.
|
||||
- **Pages are paced.** `browsePageDelayMs` (driver config, default **5**, `0` = back-to-back, negative =
|
||||
`0`) pauses before every page after the first. 5 ms is small against a page's own round-trip, so a
|
||||
50k-symbol browse gains a few seconds, but it breaks the back-to-back burst (one request in flight, roughly
|
||||
a Studio 5000 online browse) into slices the controller's message processing can interleave with. Raise it
|
||||
for a controller that is tight on CPU; the pause applies per listing (the controller listing and each
|
||||
program listing).
|
||||
- **Template reads in a browse share one lease.** `DiscoverAsync` reads the UDT templates (and nested
|
||||
templates) of a device through a single batch reader (`IAbCipTemplateReaderFactory.CreateBatch`); the
|
||||
native batch reader holds one device lease from its first read until the browse of that device ends,
|
||||
instead of leasing per template. `FetchUdtShapeAsync` outside a discovery run is unchanged: one
|
||||
self-contained lease per call.
|
||||
|
||||
## Controller-discovered UDT member variables
|
||||
|
||||
When `EnableControllerBrowse` is set, the driver walks the controller's `@tags` symbol table and
|
||||
@@ -312,7 +385,7 @@ UDT-typed tags found in the walk.
|
||||
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
|
||||
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
|
||||
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
|
||||
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
|
||||
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
|
||||
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
|
||||
|
||||
@@ -150,6 +150,9 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected
|
||||
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
|
||||
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
|
||||
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
|
||||
- Releasing a native tag (evict, reinitialize, shutdown) never blocks on the connection close: the lease
|
||||
is released in the background (`ConnectionLease.ReleaseInBackground`), the same shape as native AbCip
|
||||
(#579). A re-create cannot overlap the close, because the pool holds the successor connection behind it.
|
||||
|
||||
### Wire evidence (native PCCC)
|
||||
|
||||
|
||||
+99
-3
@@ -406,6 +406,27 @@ Both gates fail closed — a session with no identity or no matching role gets
|
||||
the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
|
||||
(see [Role grant source](#role-grant-source-data-plane)).
|
||||
|
||||
**Both gates also re-check the session's API key on every call, flag off or on** (Gitea #563). A
|
||||
session's roles are resolved once, at activation, so before this an open session kept `WriteOperate` /
|
||||
`AlarmAck` after its key's `ExpiresUtc` passed — or after a deployment disabled or removed the key —
|
||||
until the client chose to disconnect, which on a machine-to-machine data plane can be never. Now each
|
||||
write and each Part 9 method call first asks `DataPlaneAuthorizer.AuthorizeKeyUsable`: the same
|
||||
`FindUsableKey` probe, against the same deployed snapshot and the same clock, that the ACL path makes
|
||||
(one dictionary lookup, no I/O). An unusable key gets `BadUserAccessDenied` (logged as
|
||||
`AclGate: denied … key … is no longer enabled or has expired`), checked *before* the "no gateway ⇒
|
||||
`BadNotWritable`" availability answer. Things to know:
|
||||
|
||||
- **Only these two gates.** Read, Browse, Subscribe and HistoryRead stay ungated with the flag off —
|
||||
that is the flag-off posture, and an expired key's session can still read and keep its subscriptions.
|
||||
Turn `Security:Acl:Enabled` on to have expiry reach those too.
|
||||
- **Anonymous is unaffected.** It carries no key id, so there is nothing to expire; its roles stay what
|
||||
activation resolved from the `anonymous` principal's grants.
|
||||
- **No flag.** An expired or disabled key keeping write authority is a defect, not a rollout choice.
|
||||
- ⚠️ **On a fused admin+driver node a key minted after the last deployment authenticates (the ConfigDb
|
||||
store is read at activation) but its writes and acks are refused until a deployment carries it** — the
|
||||
check reads the deployed snapshot, which is also what the flag-on path has always done. Deploy after
|
||||
minting a key.
|
||||
|
||||
**What has no authorization check at all:**
|
||||
|
||||
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
|
||||
@@ -496,9 +517,84 @@ Behaviours worth knowing before enabling it:
|
||||
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
|
||||
machine consumer in the fleet at once when a shared key expired.
|
||||
|
||||
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
|
||||
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
|
||||
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
|
||||
**Event/alarm monitored items are revalidated by both triggers too** (Gitea #562). A direct event
|
||||
subscription — one on an equipment or device folder's event notifier — is tracked in a second map
|
||||
alongside the data-change items and re-asked the creation gate's question (`Subscribe` on the
|
||||
notifier's scope, for the identity of the session that created it). A denied item is **detached** from
|
||||
the notifier's event fan-out through the SDK's own unsubscribe — exactly what the client's delete would
|
||||
do — never set `MonitoringMode.Disabled`, so restoring its authority re-attaches it and alarms resume
|
||||
with no client action. Events raised while it was stood down are not replayed. Two differences from a
|
||||
data-change item:
|
||||
- **The client is not told.** An event item has no value slot to carry `BadUserAccessDenied`, and a
|
||||
fabricated "access denied" event would land in every alarm consumer's stream, so the feed simply goes
|
||||
quiet. The operator-facing signal is the `AclGate: RevalidateEventSubscriptions withheld N of M` log line.
|
||||
- **A client may delete a stood-down item** normally; the delete is absorbed (the item is already off
|
||||
the fan-out) and a later restoring deployment does not resurrect it.
|
||||
|
||||
**Server-object (`i=2253`, all-events) event subscriptions are filtered per event at delivery** (Gitea
|
||||
#586). They used to be outside the ACL entirely: the SDK attached such an item to the fan-out of every
|
||||
one of our root notifiers through `SubscribeToAllEvents`, and an equipment folder with no direct
|
||||
subscriber forwarded its events to the core node manager's Server object, which our node manager never
|
||||
sees — so a session holding no grant received every alarm in the server. Now:
|
||||
- The node manager attaches a Server-object item to **nothing**. It records it, and each condition hands
|
||||
its events to the recorded items itself (the condition's own `OnReportEvent`, which fires exactly once
|
||||
per event, so the Server object still sees one copy). The root-notifier forward to the core Server
|
||||
object is dropped.
|
||||
- Each event is delivered to an item only if the item's session holds `Subscribe` on **any** of: the
|
||||
condition itself (`ConditionId` = RawPath, raw realm — so a grant on the alarm's raw tag admits it), a
|
||||
root-notifier folder the condition bubbles through (its raw device folder; a scripted alarm's
|
||||
equipment folder), or an equipment folder wired as its extra notifier. That is the per-folder path's
|
||||
question — a session that may subscribe to any notifier the alarm reports through already receives it
|
||||
there — asked with the same authorizer and the same operation as the per-folder creation gate. The
|
||||
folders must be asked as well as the condition because the trie inherits downward only within a realm:
|
||||
an `Equipment` grant covers the UNS folder, not the condition at its RawPath.
|
||||
- **Revalidation is implicit, not trigger-driven.** Every event re-asks the authorizer, whose decision
|
||||
cache is keyed per generation and which re-checks key revocation and expiry on every call — so a
|
||||
deployment takes effect on the next event, and an expired key's feed stops on the next event, without
|
||||
waiting for the `AclRevalidationTimer` tick. Nothing is detached, so restoring a grant needs no
|
||||
re-attach: the next event simply passes. Events raised while withheld are not replayed, and the client
|
||||
is not told (the same reasoning as a stood-down direct item).
|
||||
- Creation is **not** refused — an all-events item is legitimate for a partially-granted session; what
|
||||
is withheld is each event it may not see.
|
||||
- **`ConditionRefresh` on a Server-object item** replays only the conditions the session passes the same
|
||||
check on (once per condition, not once per notifier it is wired to). Without that, a refresh would
|
||||
replay every condition's current state to any session.
|
||||
- Inert with the flag off: every Server-object item receives every condition, as before.
|
||||
- The replacement delivery keeps the two behaviours of the SDK path it replaced. **Audit events** a
|
||||
condition reports through itself (`AuditConditionAcknowledgeEventType`, Comment, Enable, Shelving)
|
||||
reach a Server-object item only while the server is auditing, over SignAndEncrypt or https-binary,
|
||||
which is exactly the SDK's own guard. And while any Server-object item exists, every condition counts as
|
||||
**monitored** (`ConditionState.EventsMonitored()`), which gates the SDK's method **audit** events on a
|
||||
server whose only subscribers are Server-object items. Both were caught in review: the
|
||||
first cut queued `i=8944` to every Server-object item with auditing off, and left conditions
|
||||
unmonitored. The second also needed the SDK's `AddRootNotifier` neutralised: its "subscribe existing
|
||||
all-events items with `unsubscribe: true`" bug would otherwise take the unit back from every
|
||||
condition rebuilt under a live item. (Method-driven **state-change** events no longer depend on this
|
||||
bookkeeping at all — the node manager reports them itself, Gitea #587; see `docs/AlarmTracking.md`.
|
||||
The SDK's counter stays imprecise in one known way — a folder subscriber's unit never reaches a
|
||||
condition materialised under that folder later, yet its unsubscribe still lowers it — which can now
|
||||
only cost an audit event, and only while auditing is on.)
|
||||
- Materialising a condition raises only **that** condition's monitored unit; the full reconcile (every
|
||||
condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a
|
||||
live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock.
|
||||
|
||||
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
|
||||
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
|
||||
`AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where
|
||||
the core Server object queued them to every Server-object item ungated: a session with no grant learned
|
||||
the RawPath / UNS path of every node added, deleted or retyped, and (with auditing on) the written and
|
||||
prior values of a rejected write. With the flag **on**, the node manager now delivers them itself, item by
|
||||
item, to the Server-object items it already holds for #586 (the same audience — only Server-object items
|
||||
ever received them):
|
||||
- A **model-change** event keeps only the changes whose `Affected` node the session may **Browse**
|
||||
(ancestors of a granted node included, as for Browse itself). Every event the server raises today carries
|
||||
one change, so in practice it is delivered whole or not at all; a multi-change event would be delivered
|
||||
as a copy holding only the visible changes, with a neutral `Message` (the original names the nodes).
|
||||
- An **audit write** event needs `Read` on its source node — it carries the node's prior value and the
|
||||
attempted one — on top of the SDK's own guard (server auditing on, SignAndEncrypt or https-binary).
|
||||
Auditing stays **off by default**: this repo never sets `ServerConfiguration.AuditingEnabled`.
|
||||
- Any other server-wide event type is withheld (fail closed); the node manager raises none today.
|
||||
- With the flag **off**, these events go to `Server.ReportEvent` exactly as before.
|
||||
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
|
||||
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
|
||||
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
|
||||
|
||||
@@ -303,9 +303,9 @@ Same library as AB CIP — libplctag handles both protocols via the `plc` attrib
|
||||
| I (Input) | 1 | 2 bytes | INT16/bit | `I:0`, `I:0/3` |
|
||||
| S (Status) | 2 | 2 bytes | INT16/bit | `S:0`, `S:1/0` |
|
||||
| B (Binary) | 3 | 2 bytes | INT16/bit | `B3:0`, `B3:0/5` |
|
||||
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
|
||||
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0/DN`, `C5:0/CU` |
|
||||
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0/DN` |
|
||||
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
|
||||
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0.DN`, `C5:0.CU` |
|
||||
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0.DN` |
|
||||
| N (Integer) | 7 | 2 bytes | INT16 | `N7:0`, `N7:0/5`, `N10:50` |
|
||||
| F (Float) | 8 | 4 bytes | FLOAT32 | `F8:0`, `F20:10` |
|
||||
| ST (String) | 9 | 84 bytes | STRING | `ST9:0` (82 char max) |
|
||||
|
||||
@@ -245,8 +245,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
|
||||
| Int32 | — (decompose in driver from two N words) | partial |
|
||||
| Float32 | `F8:n` | ✔ |
|
||||
| String | `ST9:n` | ✔ |
|
||||
| Timer struct | `T4:n.ACC` / `.PRE` / `/DN` | ✔ |
|
||||
| Counter struct | `C5:n.ACC` / `.PRE` / `/DN` | ✔ |
|
||||
| Timer struct | `T4:n.ACC` / `.PRE` / `.DN` | ✔ |
|
||||
| Counter struct | `C5:n.ACC` / `.PRE` / `.DN` | ✔ |
|
||||
|
||||
### Standard Scenario Mapping
|
||||
|
||||
@@ -260,8 +260,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
|
||||
| Ramp (Float32) | `F8:1` sine wave |
|
||||
| Write-read-back (Bool / Int16 / Float32 / String) | `B3:1/0` / `N7:100` / `F8:100` / `ST9:100` |
|
||||
| Array element write (Int16[10]) | `N7:200..209` |
|
||||
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
|
||||
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0/DN` |
|
||||
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
|
||||
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0.DN` |
|
||||
| Connection-limit refusal | Driver harness toggle to simulate 4-conn limit |
|
||||
| Bad on demand | Connection-refused toggle |
|
||||
|
||||
|
||||
@@ -45,7 +45,23 @@ public class ReadCommand : CommandBase
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
||||
|
||||
var value = await service.ReadValueAsync(nodeId, ct);
|
||||
DataValue value;
|
||||
try
|
||||
{
|
||||
value = await service.ReadValueAsync(nodeId, ct);
|
||||
}
|
||||
catch (ServiceResultException ex) when (ex.StatusCode != StatusCodes.BadSessionIdInvalid
|
||||
&& ex.StatusCode != StatusCodes.BadConnectionClosed
|
||||
&& ex.StatusCode != StatusCodes.BadSecureChannelClosed)
|
||||
{
|
||||
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
|
||||
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
|
||||
// not crash with a stack trace (#577) — session-level failures still propagate.
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: ");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||
return;
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||
|
||||
@@ -370,10 +370,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// The Acknowledge method lives on the .Condition child node, not the source node itself
|
||||
var conditionObjId = conditionNodeId.EndsWith(".Condition")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".Condition");
|
||||
// OtOpcUa conditions are their own event source (SourceNode == ConditionId), so the Part 9 methods are
|
||||
// invoked on that node directly. There is no ".Condition" child to append.
|
||||
var conditionObjId = NodeId.Parse(conditionNodeId);
|
||||
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
|
||||
|
||||
// CallMethodAsync throws ServiceResultException on a bad call result;
|
||||
@@ -406,10 +405,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// Confirm lives on the same .Condition child node as Acknowledge
|
||||
var conditionObjId = conditionNodeId.EndsWith(".Condition")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".Condition");
|
||||
// Confirm is invoked on the condition node itself, like Acknowledge.
|
||||
var conditionObjId = NodeId.Parse(conditionNodeId);
|
||||
var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;
|
||||
|
||||
try
|
||||
@@ -438,11 +435,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// The shelve methods live on the ShelvingState child of the condition node.
|
||||
// conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
|
||||
var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".ShelvingState");
|
||||
// The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized
|
||||
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
|
||||
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
|
||||
// to the condition's ShelvingState methods.
|
||||
var shelvingStateNodeId = NodeId.Parse(conditionNodeId);
|
||||
|
||||
NodeId methodId;
|
||||
object[] inputArgs;
|
||||
@@ -495,8 +492,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
/// <summary>
|
||||
/// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType
|
||||
/// <c>Enable</c>/<c>Disable</c> method (no input arguments) on the condition object node, mirroring
|
||||
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods live on the <c>.Condition</c>
|
||||
/// child node, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
|
||||
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods are invoked on the condition
|
||||
/// node itself, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
|
||||
/// escaping as an uncaught <see cref="ServiceResultException"/>.
|
||||
/// </summary>
|
||||
/// <param name="conditionNodeId">The condition node associated with the alarm.</param>
|
||||
@@ -507,10 +504,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
// Enable/Disable live on the same .Condition child node as Acknowledge/Confirm.
|
||||
var conditionObjId = conditionNodeId.EndsWith(".Condition")
|
||||
? NodeId.Parse(conditionNodeId)
|
||||
: NodeId.Parse(conditionNodeId + ".Condition");
|
||||
// Enable/Disable are invoked on the condition node itself, like Acknowledge/Confirm.
|
||||
var conditionObjId = NodeId.Parse(conditionNodeId);
|
||||
var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable;
|
||||
|
||||
try
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
// RoleLeaderChanged event landed last would silently overwrite the other's leader — turning two
|
||||
// real, independent facts Akka tracks separately into one, wrong, merged one. Pinned by
|
||||
// ClusterRoleInfoRoleLeaderCaseTests.RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries.
|
||||
private readonly Dictionary<string, Member?> _roleLeaders = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, Akka.Actor.Address?> _roleLeaders = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.OrdinalIgnoreCase);
|
||||
private IActorRef? _subscriber;
|
||||
|
||||
@@ -113,7 +113,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
lock (_lock)
|
||||
{
|
||||
return _roleLeaders.TryGetValue(role, out var leader) && leader is not null
|
||||
? ToNodeId(leader.Address)
|
||||
? ToNodeId(leader)
|
||||
: (CommonsNodeId?)null;
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
foreach (var role in snapshot.Members.SelectMany(m => m.Roles).Distinct())
|
||||
{
|
||||
var leaderAddr = snapshot.RoleLeader(role);
|
||||
_roleLeaders[role] = leaderAddr is not null
|
||||
? snapshot.Members.FirstOrDefault(m => m.Address == leaderAddr)
|
||||
: null;
|
||||
_roleLeaders[role] = leaderAddr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,17 +177,17 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_roleLeaders.TryGetValue(evt.Role, out var prevMember);
|
||||
if (prevMember is not null)
|
||||
previous = ToNodeId(prevMember.Address);
|
||||
_roleLeaders.TryGetValue(evt.Role, out var prevLeader);
|
||||
if (prevLeader is not null)
|
||||
previous = ToNodeId(prevLeader);
|
||||
|
||||
var nextMember = evt.Leader is null
|
||||
? null
|
||||
: _cluster.State.Members.FirstOrDefault(m => m.Address == evt.Leader);
|
||||
|
||||
_roleLeaders[evt.Role] = nextMember;
|
||||
if (nextMember is not null)
|
||||
next = ToNodeId(nextMember.Address);
|
||||
// Record the event's own address. The leader used to be resolved by looking the address up
|
||||
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
|
||||
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
|
||||
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
|
||||
_roleLeaders[evt.Role] = evt.Leader;
|
||||
if (evt.Leader is not null)
|
||||
next = ToNodeId(evt.Leader);
|
||||
|
||||
raise = !Nullable.Equals(previous, next);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Cluster.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.Hosting"/>
|
||||
<PackageReference Include="Akka.Cluster"/>
|
||||
|
||||
+16
-8
@@ -3,7 +3,7 @@ using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
@@ -24,7 +24,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
|
||||
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// compile this file in (<c><Compile Include></c>), so each assembly has its own internal copy and its own
|
||||
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
|
||||
/// references only in such files.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
@@ -43,10 +47,7 @@ internal static class LibplctagOrphanRelease
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
internal static bool CanReadHandles => HandleField is not null;
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
@@ -113,12 +114,19 @@ internal static class LibplctagOrphanRelease
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
|
||||
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
|
||||
/// <see cref="CanReadHandles"/>.
|
||||
/// </summary>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
if (!canReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
|
||||
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
|
||||
/// </summary>
|
||||
public static class AbOpcUaStatusMap
|
||||
{
|
||||
/// <summary>OPC UA <c>Good</c>.</summary>
|
||||
public const uint Good = 0u;
|
||||
/// <summary>OPC UA <c>GoodMoreData</c>.</summary>
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
/// <summary>OPC UA <c>BadNodeIdUnknown</c>.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA <c>BadNotWritable</c>.</summary>
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
/// <summary>OPC UA <c>BadOutOfRange</c>.</summary>
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
/// <summary>OPC UA <c>BadNotSupported</c>.</summary>
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA <c>BadDeviceFailure</c>.</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
/// <summary>OPC UA <c>BadCommunicationError</c>.</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
/// <summary>OPC UA <c>BadTimeout</c>.</summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
/// <summary>OPC UA <c>BadTypeMismatch</c>.</summary>
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Maps a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a transport exception maps through its
|
||||
/// <see cref="AbStatus"/>; anything else is <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
|
||||
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
|
||||
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||||
public class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||||
: Exception(message, inner)
|
||||
{
|
||||
public AbStatus Status { get; } = status;
|
||||
|
||||
@@ -10,7 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// <remarks>
|
||||
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
|
||||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||||
/// faults the session (B2), so it never causes a reconnect.</para>
|
||||
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
|
||||
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</para>
|
||||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||||
@@ -21,70 +23,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// </remarks>
|
||||
public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
|
||||
internal static int ForwardOpenCount;
|
||||
|
||||
/// <summary>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesSent;
|
||||
|
||||
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesAcked;
|
||||
|
||||
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
|
||||
internal static int RequestsSent;
|
||||
|
||||
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
|
||||
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
|
||||
private static long _exchangeCount;
|
||||
private static long _exchangeTotalTicks;
|
||||
private static long _exchangeMinTicks = long.MaxValue;
|
||||
private static long _exchangeMaxTicks;
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
|
||||
|
||||
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static void ResetExchangeStats()
|
||||
{
|
||||
Interlocked.Exchange(ref _exchangeCount, 0);
|
||||
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
|
||||
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
|
||||
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
|
||||
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
||||
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
|
||||
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
|
||||
}
|
||||
|
||||
private static void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
Interlocked.Add(ref _exchangeTotalTicks, ticks);
|
||||
long seen;
|
||||
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
|
||||
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
|
||||
}
|
||||
|
||||
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
|
||||
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
|
||||
internal static int OpenAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on <see cref="OpenAttempts"/>; <c>-1</c> (the default) is unlimited. An attempt beyond
|
||||
/// the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect, no Forward Open. Only the
|
||||
/// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the
|
||||
/// connection sees exactly one open attempt instead of one per remaining operation.
|
||||
/// </summary>
|
||||
internal static int OpenAttemptBudget = -1;
|
||||
|
||||
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
|
||||
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
|
||||
|
||||
@@ -98,6 +36,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private readonly ForwardOpenThrottle _throttle;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _predecessorClosed;
|
||||
private readonly ConnectionDiagnostics _diagnostics;
|
||||
private readonly byte[] _route;
|
||||
|
||||
private readonly object _sync = new();
|
||||
@@ -119,9 +58,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
|
||||
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
|
||||
/// finished closing, so two connections to one device never overlap.</param>
|
||||
/// <param name="diagnostics">The owning pool's counters and open-attempt limits; a private set when null.</param>
|
||||
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
|
||||
Task? predecessorClosed = null)
|
||||
Task? predecessorClosed = null, ConnectionDiagnostics? diagnostics = null)
|
||||
{
|
||||
_diagnostics = diagnostics ?? new ConnectionDiagnostics();
|
||||
_key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
_o = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||
@@ -141,6 +82,12 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
|
||||
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
|
||||
|
||||
/// <summary>This connection's counters (its pool's, when it has one).</summary>
|
||||
internal ConnectionDiagnostics Diagnostics => _diagnostics;
|
||||
|
||||
/// <summary>Test hook: true while a shared open is in progress.</summary>
|
||||
internal bool OpenInProgress { get { lock (_sync) return _pendingOpen is not null; } }
|
||||
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
@@ -210,6 +157,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
if (stale is not null) await stale.Client.DisposeAsync().ConfigureAwait(false); // faulted: no UnRegister
|
||||
if (!_predecessorClosed.IsCompleted) _diagnostics.CountPredecessorWait();
|
||||
await _predecessorClosed.WaitAsync(beforeAttempt.Token).ConfigureAwait(false);
|
||||
opened = await _throttle.RunAsync(_ => OpenAsync(deadline.Token), beforeAttempt.Token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -235,11 +183,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private async Task<Open> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
|
||||
var attempt = Interlocked.Increment(ref OpenAttempts);
|
||||
var budget = Volatile.Read(ref OpenAttemptBudget);
|
||||
if (budget >= 0 && attempt > budget)
|
||||
throw new AbTransportException(AbStatus.Abort,
|
||||
$"Open attempt {attempt} to {_key.Host}:{_key.Port} refused before any I/O: the process-wide open-attempt budget of {budget} is spent.");
|
||||
if (_diagnostics.AdmitOpenAttempt(_key) is { } refused) throw new AbTransportException(AbStatus.Abort, refused);
|
||||
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
@@ -276,7 +220,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier,
|
||||
_o.RpiMicros, size, _route);
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
Interlocked.Increment(ref ForwardOpenCount);
|
||||
_diagnostics.CountForwardOpen();
|
||||
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
@@ -289,7 +233,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref RequestsSent);
|
||||
_diagnostics.CountRequest();
|
||||
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
|
||||
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
@@ -302,7 +246,14 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
};
|
||||
RecordExchange(Stopwatch.GetTimestamp() - started);
|
||||
_diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started);
|
||||
if (_o.Routing == CipRouting.Connected && ConnectionManagerServices.IsConnectionGone(reply.Span) is { } extended)
|
||||
{
|
||||
await RetireClosedByTargetAsync(open).ConfigureAwait(false);
|
||||
throw new AbTransportException(AbStatus.BadConnection,
|
||||
$"{_key.Host}:{_key.Port} no longer holds the CIP connection (general status 0x01, extended 0x{extended:X4}); " +
|
||||
"the session was closed and the next request reopens it.");
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||
@@ -312,6 +263,24 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
|
||||
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
|
||||
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
|
||||
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
|
||||
/// No Forward Close — the target has just said there is nothing to close.
|
||||
/// </summary>
|
||||
private async Task RetireClosedByTargetAsync(Open open)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!ReferenceEquals(_open, open)) return; // a concurrent exchange (keep-alive) already retired it
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
_diagnostics.CountClosedByTarget();
|
||||
await open.Client.DisposeAsync().ConfigureAwait(false); // UnRegisterSession, then close the socket
|
||||
}
|
||||
|
||||
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
|
||||
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
|
||||
private void OnKeepAliveTimer()
|
||||
@@ -396,11 +365,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
{
|
||||
var budget = TimeSpan.FromSeconds(1);
|
||||
using var cts = new CancellationTokenSource(budget, _time);
|
||||
Interlocked.Increment(ref ForwardClosesSent);
|
||||
_diagnostics.CountForwardCloseSent();
|
||||
await open.Client.SendRRDataAsync(
|
||||
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
|
||||
budget, cts.Token).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref ForwardClosesAcked);
|
||||
_diagnostics.CountForwardCloseAcked();
|
||||
}
|
||||
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -15,24 +15,34 @@ public static class CipPath
|
||||
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.", nameof(cipPath));
|
||||
for (var i = 0; i < parts.Length; i += 2)
|
||||
{
|
||||
var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
|
||||
if (!ushort.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out var port))
|
||||
throw new ArgumentException($"CIP path '{cipPath}' has a port '{parts[i]}' that is not 0..65535.", nameof(cipPath));
|
||||
var link = parts[i + 1];
|
||||
// A link is a slot byte or an IP address/hostname; neither contains whitespace. Without this a
|
||||
// typo like "1,2 3" would be sent as an ASCII link "2 3" instead of failing here.
|
||||
if (link.Length == 0 || link.Any(char.IsWhiteSpace))
|
||||
throw new ArgumentException($"CIP path '{cipPath}' has an empty or malformed link '{link}'.", nameof(cipPath));
|
||||
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
|
||||
byte[] a = [];
|
||||
if (!isByte)
|
||||
{
|
||||
// The link-address length rides in one byte, and a non-ASCII char would be sent as '?'.
|
||||
a = EncodeAscii(link, cipPath, nameof(cipPath), "link");
|
||||
}
|
||||
if (port < 15)
|
||||
{
|
||||
if (isByte) w.U8((byte)port).U8(linkByte);
|
||||
else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
|
||||
else w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
|
||||
else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
|
||||
if (isByte) w.U8(0x0F).U16(port).U8(linkByte);
|
||||
else w.U8(0x1F).U8((byte)a.Length).U16(port).Bytes(a).PadToEven();
|
||||
}
|
||||
}
|
||||
// The route size is carried in words in one byte (Unconnected Send / Forward Open paths).
|
||||
if (w.Length > MaxPathBytes)
|
||||
throw new ArgumentException($"CIP path '{cipPath}' encodes to {w.Length} bytes; the maximum is {MaxPathBytes}.", nameof(cipPath));
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
@@ -41,12 +51,20 @@ public static class CipPath
|
||||
{
|
||||
var dot = tagName.LastIndexOf('.');
|
||||
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
|
||||
return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
|
||||
{
|
||||
if (!int.TryParse(tagName[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var bit))
|
||||
throw new ArgumentException($"Bit selector in '{tagName}' is out of range.", nameof(tagName));
|
||||
return (tagName[..dot], bit);
|
||||
}
|
||||
return (tagName, null);
|
||||
}
|
||||
|
||||
/// <exception cref="ArgumentException">The name is empty, has an empty member, an over-long (> 255) or
|
||||
/// non-ASCII member, an unterminated / non-numeric subscript, or ends in a bit selector.</exception>
|
||||
public static byte[] EncodeSymbolic(string tagName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tagName))
|
||||
throw new ArgumentException("Tag name is empty.", nameof(tagName));
|
||||
if (SplitBitSelector(tagName).Bit is not null)
|
||||
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.", nameof(tagName));
|
||||
var w = new CipWriter();
|
||||
@@ -54,13 +72,19 @@ public static class CipPath
|
||||
{
|
||||
var bracket = part.IndexOf('[');
|
||||
var name = bracket < 0 ? part : part[..bracket];
|
||||
var a = Encoding.ASCII.GetBytes(name);
|
||||
if (name.Length == 0)
|
||||
throw new ArgumentException($"Tag name '{tagName}' has an empty member.", nameof(tagName));
|
||||
var a = EncodeAscii(name, tagName, nameof(tagName), "member");
|
||||
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
|
||||
if (bracket >= 0)
|
||||
{
|
||||
foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
|
||||
var close = part.IndexOf(']', bracket);
|
||||
if (close != part.Length - 1)
|
||||
throw new ArgumentException($"Tag name '{tagName}' has an unterminated or misplaced subscript in '{part}'.", nameof(tagName));
|
||||
foreach (var idxText in part[(bracket + 1)..close].Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
|
||||
if (!uint.TryParse(idxText, NumberStyles.None, CultureInfo.InvariantCulture, out var idx))
|
||||
throw new ArgumentException($"Tag name '{tagName}' has a subscript '{idxText}' that is not an unsigned integer.", nameof(tagName));
|
||||
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
|
||||
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
|
||||
else w.U8(0x2A).U8(0).U32(idx);
|
||||
@@ -70,6 +94,17 @@ public static class CipPath
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>ASCII bytes of a name / link whose length rides in a single byte.</summary>
|
||||
private static byte[] EncodeAscii(string text, string whole, string paramName, string what)
|
||||
{
|
||||
if (text.Length > byte.MaxValue)
|
||||
throw new ArgumentException($"'{whole}' has a {what} longer than 255 characters.", paramName);
|
||||
foreach (var c in text)
|
||||
if (!char.IsAscii(c))
|
||||
throw new ArgumentException($"'{whole}' has a {what} with a non-ASCII character.", paramName);
|
||||
return Encoding.ASCII.GetBytes(text);
|
||||
}
|
||||
|
||||
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
|
||||
private static IEnumerable<string> SplitMembers(string name)
|
||||
{
|
||||
@@ -94,16 +129,25 @@ public static class CipPath
|
||||
}
|
||||
|
||||
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
|
||||
/// <exception cref="ArgumentException">The segments are not a whole number of 16-bit words, or exceed
|
||||
/// the 255-word maximum the one-byte size field can carry.</exception>
|
||||
public static byte[] WithPathSize(byte[] segments)
|
||||
{
|
||||
if ((segments.Length & 1) != 0)
|
||||
throw new ArgumentException($"A CIP path is whole 16-bit words; got {segments.Length} bytes.", nameof(segments));
|
||||
if (segments.Length > MaxPathBytes)
|
||||
throw new ArgumentException($"A CIP path is at most {MaxPathBytes} bytes (255 words); got {segments.Length}.", nameof(segments));
|
||||
var w = new CipWriter();
|
||||
w.U8((byte)(segments.Length / 2)).Bytes(segments);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>The path-size field is one byte of 16-bit words.</summary>
|
||||
private const int MaxPathBytes = 255 * 2;
|
||||
|
||||
/// <summary>Message Router (class 2, instance 1).</summary>
|
||||
public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
|
||||
public static ReadOnlySpan<byte> MessageRouter => [0x20, 0x02, 0x24, 0x01];
|
||||
|
||||
/// <summary>Connection Manager (class 6, instance 1).</summary>
|
||||
public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
|
||||
public static ReadOnlySpan<byte> ConnectionManager => [0x20, 0x06, 0x24, 0x01];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// status, extended status words, and the reply data.</summary>
|
||||
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
|
||||
{
|
||||
// Record equality over an array / ReadOnlyMemory is reference equality; two parses of the same wire
|
||||
// bytes must compare equal, so compare the extended-status words and the data by content.
|
||||
public bool Equals(CipReply? other) =>
|
||||
other is not null
|
||||
&& Service == other.Service
|
||||
&& GeneralStatus == other.GeneralStatus
|
||||
&& ExtendedStatus.AsSpan().SequenceEqual(other.ExtendedStatus)
|
||||
&& Data.Span.SequenceEqual(other.Data.Span);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var h = new HashCode();
|
||||
h.Add(Service);
|
||||
h.Add(GeneralStatus);
|
||||
foreach (var e in ExtendedStatus) h.Add(e);
|
||||
h.AddBytes(Data.Span);
|
||||
return h.ToHashCode();
|
||||
}
|
||||
|
||||
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
|
||||
|
||||
public static CipReply Parse(ReadOnlyMemory<byte> raw)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Diagnostics;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Counters and the open-attempt limit for the <see cref="CipConnection"/>s one <see cref="DeviceSessionPool"/>
|
||||
/// owns. Gate and harness diagnostics: nothing in production reads them, and a limit is only ever set by the
|
||||
/// read-only differential harness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Instance-scoped (Gitea #581): every pool — and every connection a test builds on its own — has its own, so
|
||||
/// no process-wide mutable state ships in the transport, and two tests can never move each other's counts. The
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/> (Gitea #566), so limiting the press under test
|
||||
/// can never refuse an open to any other device in the process.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionDiagnostics
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<DeviceKey, OpenAttemptLimit> _limits = new();
|
||||
|
||||
private int _forwardOpens;
|
||||
private int _forwardClosesSent;
|
||||
private int _forwardClosesAcked;
|
||||
private int _requestsSent;
|
||||
private int _openAttempts;
|
||||
private int _predecessorWaits;
|
||||
private int _connectionsClosedByTarget;
|
||||
|
||||
// Round-trip statistics of completed exchanges (T21 latency attribution). Two Stopwatch reads and four
|
||||
// interlocked ops per request; reset by the harness between timed passes.
|
||||
private long _exchangeCount;
|
||||
private long _exchangeTotalTicks;
|
||||
private long _exchangeMinTicks = long.MaxValue;
|
||||
private long _exchangeMaxTicks;
|
||||
|
||||
/// <summary>Forward Open frames sent (fallback included).</summary>
|
||||
public int ForwardOpens => Volatile.Read(ref _forwardOpens);
|
||||
|
||||
/// <summary>Forward Close requests put on the wire.</summary>
|
||||
public int ForwardClosesSent => Volatile.Read(ref _forwardClosesSent);
|
||||
|
||||
/// <summary>Forward Close requests that got a reply within their budget.</summary>
|
||||
public int ForwardClosesAcked => Volatile.Read(ref _forwardClosesAcked);
|
||||
|
||||
/// <summary>Channel sends (keep-alives included).</summary>
|
||||
public int RequestsSent => Volatile.Read(ref _requestsSent);
|
||||
|
||||
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included), counted
|
||||
/// before any I/O, so an attempt refused by a limit counts too.</summary>
|
||||
public int OpenAttempts => Volatile.Read(ref _openAttempts);
|
||||
|
||||
/// <summary>Opens that found an earlier connection for the same device still closing and parked behind it.</summary>
|
||||
public int PredecessorWaits => Volatile.Read(ref _predecessorWaits);
|
||||
|
||||
/// <summary>Connections retired because the target reported the CIP connection itself gone (TCP still up).</summary>
|
||||
public int ConnectionsClosedByTarget => Volatile.Read(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Zeroes the Forward Open / Forward Close counters (the harness, before its native phase).</summary>
|
||||
public void ResetForwardCounts()
|
||||
{
|
||||
Interlocked.Exchange(ref _forwardOpens, 0);
|
||||
Interlocked.Exchange(ref _forwardClosesSent, 0);
|
||||
Interlocked.Exchange(ref _forwardClosesAcked, 0);
|
||||
}
|
||||
|
||||
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
|
||||
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
|
||||
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
|
||||
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
|
||||
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
|
||||
internal void CountClosedByTarget() => Interlocked.Increment(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
|
||||
|
||||
/// <summary>Clears the round-trip statistics.</summary>
|
||||
public void ResetExchangeStats()
|
||||
{
|
||||
Interlocked.Exchange(ref _exchangeCount, 0);
|
||||
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
|
||||
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
|
||||
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the round-trip statistics.</summary>
|
||||
public ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
|
||||
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
||||
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
|
||||
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
|
||||
}
|
||||
|
||||
internal void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
Interlocked.Add(ref _exchangeTotalTicks, ticks);
|
||||
long seen;
|
||||
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
|
||||
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caps the open attempts to <paramref name="key"/> at <paramref name="attempts"/> until the returned scope is
|
||||
/// disposed. An attempt beyond the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect,
|
||||
/// no Forward Open — so a press that refuses or drops the connection sees exactly the budgeted attempts instead
|
||||
/// of one per remaining operation. Only the read-only differential harness (Task 18/21) sets one; one limit per
|
||||
/// key at a time.
|
||||
/// </summary>
|
||||
public OpenAttemptLimit LimitOpenAttempts(DeviceKey key, int attempts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(attempts);
|
||||
var limit = new OpenAttemptLimit(this, key, attempts);
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_limits.TryAdd(key, limit))
|
||||
throw new InvalidOperationException($"An open-attempt limit for {key.Host}:{key.Port} is already in force.");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
/// <summary>True while a limit is in force for <paramref name="key"/>.</summary>
|
||||
public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); }
|
||||
|
||||
/// <summary>Counts one open attempt to <paramref name="key"/>; null when it may proceed, else why it may not.</summary>
|
||||
internal string? AdmitOpenAttempt(DeviceKey key)
|
||||
{
|
||||
var attempt = Interlocked.Increment(ref _openAttempts);
|
||||
OpenAttemptLimit? limit;
|
||||
lock (_lock) _limits.TryGetValue(key, out limit);
|
||||
return limit is null || limit.TryTake()
|
||||
? null
|
||||
: $"Open attempt {attempt} to {key.Host}:{key.Port} refused before any I/O: the open-attempt budget of {limit.Budget} for this device is spent.";
|
||||
}
|
||||
|
||||
private void Remove(OpenAttemptLimit limit)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_limits.TryGetValue(limit.Key, out var current) && current == limit) _limits.Remove(limit.Key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An open-attempt cap on one device; disposing it lifts the cap.</summary>
|
||||
internal sealed class OpenAttemptLimit(ConnectionDiagnostics owner, DeviceKey key, int budget) : IDisposable
|
||||
{
|
||||
private int _attempts;
|
||||
|
||||
public DeviceKey Key { get; } = key;
|
||||
|
||||
public int Budget { get; } = budget;
|
||||
|
||||
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
|
||||
|
||||
public void Dispose() => owner.Remove(this);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,24 @@ public sealed record ForwardOpenParams(
|
||||
|
||||
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
|
||||
|
||||
/// <summary>A target refused a Forward Open. The refusal is data, not just message text: callers (and
|
||||
/// diagnostics) read <see cref="GeneralStatus"/> / <see cref="ExtendedStatus"/> directly.</summary>
|
||||
public sealed class ForwardOpenRefusedException : AbTransportException
|
||||
{
|
||||
public ForwardOpenRefusedException(AbStatus status, byte generalStatus, IReadOnlyList<ushort> extendedStatus)
|
||||
: base(status, $"Forward Open refused: general 0x{generalStatus:X2}, extended {string.Join(",", extendedStatus.Select(e => $"0x{e:X4}"))}.")
|
||||
{
|
||||
GeneralStatus = generalStatus;
|
||||
ExtendedStatus = [.. extendedStatus]; // defensive copy: the exception is immutable
|
||||
}
|
||||
|
||||
/// <summary>The CIP general status of the refusal.</summary>
|
||||
public byte GeneralStatus { get; }
|
||||
|
||||
/// <summary>The CIP extended-status words of the refusal (may be empty).</summary>
|
||||
public IReadOnlyList<ushort> ExtendedStatus { get; }
|
||||
}
|
||||
|
||||
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
|
||||
public static class ConnectionManagerServices
|
||||
{
|
||||
@@ -52,8 +70,8 @@ public static class ConnectionManagerServices
|
||||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
|
||||
$"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
|
||||
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
|
||||
reply.GeneralStatus, reply.ExtendedStatus);
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
|
||||
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
|
||||
@@ -65,6 +83,26 @@ public static class ConnectionManagerServices
|
||||
r.GeneralStatus == 0x08
|
||||
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
||||
|
||||
/// <summary>Connection Manager extended status: target connection not found.</summary>
|
||||
public const ushort TargetConnectionNotFound = 0x0107;
|
||||
|
||||
/// <summary>Connection Manager extended status: the connection timed out.</summary>
|
||||
public const ushort ConnectionTimedOut = 0x0203;
|
||||
|
||||
/// <summary>
|
||||
/// For a reply received over a class-3 connection: the extended status when the target says the connection
|
||||
/// itself no longer exists — general 0x01 (Connection Failure) with extended 0x0107 (target connection not
|
||||
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
|
||||
/// the request and leaves the connection alone.
|
||||
/// </summary>
|
||||
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
|
||||
{
|
||||
// Reply header: service | reserved | general status | extended-status word count | words...
|
||||
if (reply.Length < 6 || reply[2] != 0x01 || reply[3] == 0) return null;
|
||||
var extended = (ushort)(reply[4] | (reply[5] << 8));
|
||||
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
|
||||
}
|
||||
|
||||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class ForwardOpenThrottle
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly TimeProvider _time;
|
||||
private long? _lastStartTimestamp; // monotonic (TimeProvider.GetTimestamp); only touched while holding _inFlight
|
||||
private int _queued;
|
||||
private Task? _intervalWait; // the pending interval wait (test hook only)
|
||||
|
||||
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
|
||||
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
|
||||
@@ -24,6 +26,13 @@ public sealed class ForwardOpenThrottle
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
}
|
||||
|
||||
/// <summary>Test hook: callers waiting behind an in-flight attempt.</summary>
|
||||
internal int Queued => Volatile.Read(ref _queued);
|
||||
|
||||
/// <summary>Test hook: true while the next attempt is parked on the minimum interval and that wait has not
|
||||
/// yet elapsed — no attempt is then in flight, and none can start until the clock moves.</summary>
|
||||
internal bool IsWaitingOutInterval => Volatile.Read(ref _intervalWait) is { IsCompleted: false };
|
||||
|
||||
/// <summary>Run one open attempt once the throttle allows it. <paramref name="ct"/> bounds only the wait
|
||||
/// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this
|
||||
/// throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>, "Forward Open
|
||||
@@ -33,9 +42,11 @@ public sealed class ForwardOpenThrottle
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
Interlocked.Increment(ref _queued);
|
||||
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("another attempt is in flight"); }
|
||||
finally { Interlocked.Decrement(ref _queued); }
|
||||
|
||||
try
|
||||
{
|
||||
@@ -45,9 +56,12 @@ public sealed class ForwardOpenThrottle
|
||||
var wait = _minInterval - _time.GetElapsedTime(last);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(wait, _time, ct).ConfigureAwait(false); }
|
||||
var delay = Task.Delay(wait, _time, ct);
|
||||
Volatile.Write(ref _intervalWait, delay);
|
||||
try { await delay.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("the minimum interval had not elapsed"); }
|
||||
finally { Volatile.Write(ref _intervalWait, null); }
|
||||
}
|
||||
}
|
||||
_lastStartTimestamp = _time.GetTimestamp();
|
||||
|
||||
@@ -19,8 +19,21 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
private const byte MultipleServiceCode = 0x0A;
|
||||
private const byte EmbeddedServiceError = 0x1E;
|
||||
|
||||
private sealed class Pending(byte[] request, bool exclusive)
|
||||
/// <summary>Smallest possible CIP reply: service, reserved, general status, additional-status size.</summary>
|
||||
public const int ReplyHeaderBytes = 4;
|
||||
|
||||
/// <summary>The <c>expectedReplyBytes</c> default: the reply carries no data beyond its header (a
|
||||
/// write, a status-only service). Every caller whose reply carries data must pass its real bound.</summary>
|
||||
public const int StatusOnlyReply = ReplyHeaderBytes;
|
||||
|
||||
/// <summary>Pass as <c>expectedReplyBytes</c> when the reply size cannot be bounded: the request is
|
||||
/// then sent alone, never packed, because its reply could fill the whole connection.</summary>
|
||||
public const int UnknownReply = -1;
|
||||
|
||||
private sealed class Pending(byte[] request, bool exclusive, int expectedReplyBytes)
|
||||
{
|
||||
// Private lock object: lock(this) would let any holder of the Pending reference contend.
|
||||
private readonly object _gate = new();
|
||||
private bool _completed;
|
||||
private CancellationTokenRegistration _registration;
|
||||
private bool _hasRegistration;
|
||||
@@ -29,13 +42,16 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
|
||||
public bool Exclusive { get; } = exclusive;
|
||||
|
||||
/// <summary>Expected whole-reply size (header included), or <see cref="UnknownReply"/>.</summary>
|
||||
public int ExpectedReplyBytes { get; } = expectedReplyBytes;
|
||||
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public LinkedListNode<Pending>? Node { get; set; }
|
||||
|
||||
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
|
||||
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
|
||||
{
|
||||
lock (this)
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_completed)
|
||||
{
|
||||
@@ -54,7 +70,7 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
{
|
||||
CancellationTokenRegistration registration;
|
||||
bool hasRegistration;
|
||||
lock (this)
|
||||
lock (_gate)
|
||||
{
|
||||
if (_completed) return false;
|
||||
_completed = true;
|
||||
@@ -125,13 +141,19 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
/// <param name="request">The CIP request.</param>
|
||||
/// <param name="ct">Cancels this waiter only.</param>
|
||||
/// <param name="exclusive">When true the request is sent alone, never packed into a Multiple Service
|
||||
/// Packet: for requests whose reply can fill the whole connection (the packer budgets request bytes only).</param>
|
||||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false)
|
||||
/// Packet: for requests whose reply can fill the whole connection.</param>
|
||||
/// <param name="expectedReplyBytes">Upper bound on this request's whole reply (header included) as
|
||||
/// it would sit inside a Multiple Service Packet reply. The packer budgets this against the
|
||||
/// connection size as well as the request bytes (#582), so a batch of small requests with large
|
||||
/// replies (STRINGs, arrays) is split instead of overflowing. Defaults to <see cref="StatusOnlyReply"/>;
|
||||
/// <see cref="UnknownReply"/> sends the request alone.</param>
|
||||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false,
|
||||
int expectedReplyBytes = StatusOnlyReply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (ct.IsCancellationRequested) return Task.FromCanceled<CipReply>(ct);
|
||||
|
||||
var p = new Pending(request, exclusive);
|
||||
var p = new Pending(request, exclusive || expectedReplyBytes < 0, Math.Max(expectedReplyBytes, UnknownReply));
|
||||
lock (_lock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -168,19 +190,27 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
lock (_lock)
|
||||
{
|
||||
var sizes = new List<int>();
|
||||
var replySizes = new List<int>();
|
||||
var replyBudget = _channel.MaxRequestBytes; // connections are symmetric: one size bounds both directions
|
||||
while (_queue.First is { } n)
|
||||
{
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
if (!_allowPacking || n.Value.Exclusive) break;
|
||||
sizes.Add(n.Value.Request.Length);
|
||||
if (MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
|
||||
replySizes.Add(n.Value.ExpectedReplyBytes);
|
||||
if (MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes
|
||||
|| ReplyEnvelopeSize(replySizes) > replyBudget)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The first request always goes, even when it alone exceeds the limit: it is sent
|
||||
// bare, and callers that must fit (LogixTagClient) fragment before they get here.
|
||||
sizes.Add(n.Value.Request.Length);
|
||||
replySizes.Add(n.Value.ExpectedReplyBytes);
|
||||
}
|
||||
batch.Add(n.Value);
|
||||
_queue.RemoveFirst();
|
||||
@@ -189,6 +219,15 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Expected size of the MSP reply for the given embedded reply sizes: reply header, count,
|
||||
/// one offset per reply, then the replies themselves.</summary>
|
||||
internal static int ReplyEnvelopeSize(IReadOnlyList<int> replySizes)
|
||||
{
|
||||
var total = ReplyHeaderBytes + 2 + 2 * replySizes.Count;
|
||||
foreach (var r in replySizes) total += r;
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task PumpAsync()
|
||||
{
|
||||
while (true)
|
||||
|
||||
@@ -52,6 +52,9 @@ public sealed class DeviceSessionPool
|
||||
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
|
||||
internal ILogger Logger => _logger;
|
||||
|
||||
/// <summary>Counters and open-attempt limits for this pool's connections (gate and harness diagnostics).</summary>
|
||||
internal ConnectionDiagnostics Diagnostics { get; } = new();
|
||||
|
||||
/// <summary>Test hook: keys with a live entry.</summary>
|
||||
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
|
||||
|
||||
@@ -69,7 +72,7 @@ public sealed class DeviceSessionPool
|
||||
if (!_throttles.TryGetValue(key, out var throttle))
|
||||
_throttles[key] = throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, _time);
|
||||
var predecessor = _closing.GetValueOrDefault(key) ?? Task.CompletedTask;
|
||||
var connection = new CipConnection(key, options, throttle, _time, predecessor);
|
||||
var connection = new CipConnection(key, options, throttle, _time, predecessor, Diagnostics);
|
||||
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor);
|
||||
_entries[key] = entry;
|
||||
}
|
||||
@@ -109,28 +112,36 @@ public sealed class DeviceSessionPool
|
||||
}
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
|
||||
// Outside the lock: scheduler first (fails its waiters, stops sending), then Forward Close +
|
||||
// UnRegisterSession via the connection. The connection is disposed even if the scheduler's
|
||||
// dispose throws: otherwise its keep-alive would hold the press connection open forever.
|
||||
// Outside the lock.
|
||||
try
|
||||
{
|
||||
try { await entry.Scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.",
|
||||
key.Host, key.Port);
|
||||
}
|
||||
try { await entry.Connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
|
||||
}
|
||||
await CloseAsync(key, entry.Scheduler, entry.Connection, _logger).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
closed.TrySetResult(); // never faults: a successor only needs to know the close is over
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler first (fails its waiters, stops sending), then Forward Close + UnRegisterSession via the
|
||||
/// connection. The connection is disposed even if the scheduler's dispose throws: otherwise its keep-alive
|
||||
/// would hold the press connection open forever. Never throws; each failure is logged.
|
||||
/// </summary>
|
||||
internal static async Task CloseAsync(DeviceKey key, IAsyncDisposable scheduler, IAsyncDisposable connection, ILogger logger)
|
||||
{
|
||||
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.",
|
||||
key.Host, key.Port);
|
||||
}
|
||||
try { await connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops).</summary>
|
||||
@@ -158,4 +169,29 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
|
||||
public ValueTask DisposeAsync() =>
|
||||
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
|
||||
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the
|
||||
/// last lease, the entry's removal and the registration of its close in the pool's closing chain — runs
|
||||
/// synchronously, before this returns. So an acquire made the very next instant builds a connection that
|
||||
/// does not open until this one's Forward Close + UnRegisterSession have finished (the guarantee the old
|
||||
/// blocking 2 s wait in the AbCip runtime existed for), and the close itself is bounded by
|
||||
/// <see cref="CipConnection"/>'s own budgets (the open budget for an in-flight open, 1 s for the Forward
|
||||
/// Close), so it always completes and the socket is always released.</para>
|
||||
/// <para>The returned task is the teardown; it never faults (the pool logs its own close failures), so
|
||||
/// a caller may drop it.</para>
|
||||
/// </remarks>
|
||||
/// <returns>The teardown, for tests and callers that want to observe it.</returns>
|
||||
public Task ReleaseInBackground() => Observe(DisposeAsync());
|
||||
|
||||
private static async Task Observe(ValueTask release)
|
||||
{
|
||||
try { await release.ConfigureAwait(false); }
|
||||
catch (Exception) { /* ReleaseAsync logs and swallows its own failures; never fault the observer */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
/// (<see cref="AbStatus.BadReply"/>) closes the socket; <see cref="IsConnected"/> turns false and
|
||||
/// every later call throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>).
|
||||
/// The owner reconnects with a fresh client.</para>
|
||||
/// <para><b>No half-written frames.</b> A frame write never observes a cancellation token.</para>
|
||||
/// <para><b>No half-written frames.</b> A frame write never observes a cancellation token. It is
|
||||
/// nevertheless bounded: the exchange timeout also covers the write, and if it elapses first the
|
||||
/// connection is <i>faulted</i> (socket closed, which fails the pending write) rather than the write
|
||||
/// being cancelled — so a peer that stops reading cannot hold the single-flight gate forever, and a
|
||||
/// torn frame can only ever sit on a socket that is already dead.</para>
|
||||
/// <para><b>Timeouts are finite.</b> Every entry point rejects a non-positive or infinite timeout
|
||||
/// (<see cref="ArgumentOutOfRangeException"/>): the abandoned-exchange drain is bounded by it, so an
|
||||
/// infinite one would let a cancelled caller's drain hold the gate forever. The gate wait itself is
|
||||
/// bounded by the same timeout (<see cref="AbStatus.Timeout"/>, nothing written, connection intact).</para>
|
||||
/// <para><b>RegisterSession.</b> It goes out with a zero sender context (ab_server refuses a non-zero
|
||||
/// one) and therefore relies on the unconnected "reply context == 0 is tolerated" rule in
|
||||
/// <c>TryMatch</c> to match its reply; both ends of that dependency are pinned by tests.</para>
|
||||
/// </remarks>
|
||||
public sealed class EncapsulationClient : IAsyncDisposable
|
||||
{
|
||||
@@ -55,6 +66,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
|
||||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
{
|
||||
@@ -96,6 +108,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
|
||||
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
|
||||
var (_, reply) = await ExchangeAsync(connected: false,
|
||||
(ctx, _) => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
|
||||
@@ -107,12 +120,19 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
|
||||
TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
var (_, reply) = await ExchangeAsync(connected: true,
|
||||
(ctx, seq) => EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, seq, cip.Span), timeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static void ValidateTimeout(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan)
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be finite and positive.");
|
||||
}
|
||||
|
||||
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
|
||||
/// 65535 → 1 (0 is never put on the wire).</summary>
|
||||
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
|
||||
@@ -123,7 +143,9 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
bool connected, Func<ulong, ushort, byte[]> build, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ThrowIfUnusable();
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
if (!await _gate.WaitAsync(timeout, ct).ConfigureAwait(false))
|
||||
throw new AbTransportException(AbStatus.Timeout,
|
||||
$"Timed out after {timeout.TotalMilliseconds:F0} ms waiting for the session's single-flight gate.");
|
||||
|
||||
Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> exchange;
|
||||
try
|
||||
@@ -168,7 +190,13 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
|
||||
// The write ignores cancellation (a half-written frame would desync the stream) but is
|
||||
// bounded: on expiry the catch below faults the connection, closing the socket, which
|
||||
// is what fails the still-pending write.
|
||||
var write = _stream.WriteAsync(request, CancellationToken.None).AsTask();
|
||||
_ = write.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
await write.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
while (true)
|
||||
{
|
||||
var frame = await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
@@ -212,6 +240,8 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
// "== 0" is load-bearing, not just ab_server tolerance: RegisterSession is sent with a ZERO
|
||||
// sender context (ab_server refuses non-zero), so its reply echoes 0 and matches ONLY here.
|
||||
if (h.SenderContext != ctx && h.SenderContext != 0) return false;
|
||||
var p = EncapsulationCodec.ParseReply(frame, expectConnected: false);
|
||||
reply = (p.Header, p.Cip);
|
||||
|
||||
@@ -38,11 +38,18 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// </summary>
|
||||
/// <remarks>Packing bounds only the request size, so a packed batch of large reads can come back
|
||||
/// with 0x06 per service; that is expected, and this loop recovers it.</remarks>
|
||||
/// <param name="path">The encoded symbolic path.</param>
|
||||
/// <param name="elements">Element count to read.</param>
|
||||
/// <param name="ct">Cancels this read.</param>
|
||||
/// <param name="elementBytes">Upper bound on one element's data bytes (<see cref="LogixTypeCode.StringElementBytes"/>
|
||||
/// for a STRING), or 0 when unknown. The scheduler budgets the expected reply against the connection
|
||||
/// size when packing (#582); unknown sends the read alone.</param>
|
||||
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
|
||||
byte[] path, ushort elements, CancellationToken ct)
|
||||
byte[] path, ushort elements, CancellationToken ct, int elementBytes = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct).ConfigureAwait(false);
|
||||
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct,
|
||||
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, 0)).ConfigureAwait(false);
|
||||
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
|
||||
var data = new List<byte>(first.Length);
|
||||
data.AddRange(first.Span);
|
||||
@@ -53,11 +60,17 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
if (lastChunk == 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
|
||||
reply = await _scheduler.SendAsync(
|
||||
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct).ConfigureAwait(false);
|
||||
var (fragmentType, _, chunk) = LogixServices.ParseReadReply(reply);
|
||||
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct,
|
||||
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, data.Count)).ConfigureAwait(false);
|
||||
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
|
||||
if (fragmentType != type)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
|
||||
// A mid-read change of the structure handle means the tag's type changed under us (a
|
||||
// download / online edit); stitching the halves would yield a value of neither type.
|
||||
if (fragmentHandle != handle)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
|
||||
data.AddRange(chunk.Span);
|
||||
lastChunk = chunk.Length;
|
||||
if (data.Count > MaxReadBytes)
|
||||
@@ -68,6 +81,21 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
return (type, handle, data.ToArray());
|
||||
}
|
||||
|
||||
private static string FormatHandle(ushort? handle) => handle is { } h ? $"0x{h:X4}" : "(none)";
|
||||
|
||||
/// <summary>Whole-reply bound for a Read Tag Fragmented at <paramref name="alreadyRead"/> bytes in:
|
||||
/// reply header, type (+ struct handle), then the remaining data. Unknown element size, or an
|
||||
/// unrepresentable total, is <see cref="RequestScheduler.UnknownReply"/>.</summary>
|
||||
internal static int ExpectedReadReplyBytes(ushort elements, int elementBytes, int alreadyRead)
|
||||
{
|
||||
if (elementBytes <= 0) return RequestScheduler.UnknownReply;
|
||||
var remaining = (long)elements * elementBytes - alreadyRead;
|
||||
if (remaining < 0) remaining = 0;
|
||||
// 4 header + 2 type + 2 struct handle (counted for every type: a safe over-estimate).
|
||||
var total = RequestScheduler.ReplyHeaderBytes + 4 + remaining;
|
||||
return total > int.MaxValue ? RequestScheduler.UnknownReply : (int)total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write <paramref name="data"/> to the tag at <paramref name="path"/>. One Write Tag when the
|
||||
/// request fits <see cref="ICipChannel.MaxRequestBytes"/>; otherwise Write Tag Fragmented chunks of
|
||||
|
||||
@@ -5,6 +5,9 @@ public static class LogixTypeCode
|
||||
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
|
||||
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
|
||||
|
||||
/// <summary>Bytes of one Logix STRING element on the wire: 4-byte length + 82 characters + 2 pad.</summary>
|
||||
public const int StringElementBytes = 88;
|
||||
|
||||
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
|
||||
public static int ByteWidth(ushort code) => code switch
|
||||
{
|
||||
|
||||
@@ -26,18 +26,19 @@ public static class SymbolObjectServices
|
||||
}
|
||||
|
||||
/// <summary>Entries are <c>u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name</c>.
|
||||
/// <c>LastInstance</c> is the last entry's instance id (next start = LastInstance + 1).
|
||||
/// <c>MaxInstance</c> is the HIGHEST instance id in the page, not the last entry's: the next start is
|
||||
/// MaxInstance + 1, which does not rely on the controller listing a page in ascending order.
|
||||
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
|
||||
public static (byte[] Entries, uint LastInstance, bool More) ParseListingReply(CipReply reply)
|
||||
public static (byte[] Entries, uint MaxInstance, bool More) ParseListingReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
var span = reply.Data.Span;
|
||||
var r = new CipReader(span);
|
||||
uint last = 0;
|
||||
uint max = 0;
|
||||
while (r.Remaining > 0)
|
||||
{
|
||||
last = r.U32();
|
||||
max = Math.Max(max, r.U32());
|
||||
r.Bytes(2 + 2 + 12);
|
||||
var nameLen = r.U16();
|
||||
r.Bytes(nameLen);
|
||||
@@ -45,6 +46,6 @@ public static class SymbolObjectServices
|
||||
var more = reply.GeneralStatus == 0x06;
|
||||
if (more && span.IsEmpty)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Symbol listing reported more data but returned no entries.");
|
||||
return (span.ToArray(), last, more);
|
||||
return (span.ToArray(), max, more);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,14 @@ public sealed class CipWriter
|
||||
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
|
||||
|
||||
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
|
||||
public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||||
/// <exception cref="ArgumentOutOfRangeException">The two bytes at <paramref name="offset"/> are not already
|
||||
/// written (an offset in the spare buffer capacity would otherwise be patched silently and lost).</exception>
|
||||
public void PatchU16(int offset, ushort v)
|
||||
{
|
||||
if (offset < 0 || offset > Length - 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, $"Cannot patch 2 bytes at {offset}: only {Length} bytes written.");
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||||
}
|
||||
|
||||
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ public sealed class AbCipDriverOptions
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pause between the pages of a symbol listing, NATIVE transport only (libplctag pages inside its
|
||||
/// own library). A large controller's listing is hundreds of pages; unpaced they are a back-to-back
|
||||
/// burst on the controller's message-processing slice. Default 5 ms — small next to a page's own
|
||||
/// round-trip, but it breaks up the burst. <see cref="TimeSpan.Zero"/> restores back-to-back paging.
|
||||
/// Config key <c>browsePageDelayMs</c>; a negative value is treated as zero.
|
||||
/// </summary>
|
||||
public TimeSpan BrowsePageDelay { get; init; } = TimeSpan.FromMilliseconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <c>IAlarmSource</c>; the driver polls each subscribed
|
||||
|
||||
@@ -40,6 +40,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
||||
|
||||
/// <summary>
|
||||
/// The batch template reader of the discovery run in progress on this async flow, if any. While set,
|
||||
/// <see cref="FetchUdtShapeAsync"/> reads through it so a browse that fetches many templates holds ONE
|
||||
/// device lease across them instead of opening a connection per template (Gitea #572).
|
||||
/// </summary>
|
||||
private readonly AsyncLocal<IAbCipTemplateReader?> _batchTemplateReader = new();
|
||||
private readonly AbCipTemplateCache _templateCache = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -158,7 +165,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
|
||||
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
|
||||
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
|
||||
var defaults = AbCipTransportFactories.For(_transport);
|
||||
var defaults = AbCipTransportFactories.For(_transport, options.BrowsePageDelay);
|
||||
_tagFactory = tagFactory ?? defaults.Tags;
|
||||
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
|
||||
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
|
||||
@@ -207,6 +214,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Logs the Warning for a tag whose own configuration cannot become a request
|
||||
/// (<see cref="AbCipTagConfigurationException"/>), the first time <paramref name="tagName"/> reports
|
||||
/// one on this instance.
|
||||
/// </summary>
|
||||
private void WarnConfigurationErrorOnce(string tagName, string reference, string device, AbCipTagConfigurationException ex)
|
||||
{
|
||||
if (_configErrorWarned.TryAdd(tagName, 0))
|
||||
_logger.LogWarning(
|
||||
"AbCip tag {Tag} on device {Device} is misconfigured: {Reason}; reporting BadConfigurationError " +
|
||||
"until it is corrected — the device and its other tags are unaffected (logged once per tag)",
|
||||
reference, device, ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -247,7 +272,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = _templateReaderFactory.Create();
|
||||
var batch = _batchTemplateReader.Value;
|
||||
using var own = batch is null ? _templateReaderFactory.Create() : null;
|
||||
var reader = batch ?? own!;
|
||||
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
||||
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
||||
if (shape is not null)
|
||||
@@ -576,7 +603,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
backoffVerdict = false; // 05/STAB-8: keep the data-path window open
|
||||
// 05/STAB-8: keep the data-path window open — unless the probe tag's own name is malformed
|
||||
// (Gitea #570): that never reached the device, so it is no evidence either way.
|
||||
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
|
||||
}
|
||||
|
||||
// ConnectionBackoff is caller-serialized, and the data path now records under the
|
||||
@@ -892,6 +921,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: this tag's name/path cannot become a request — nothing reached the device, so
|
||||
// no evict (nothing was cached), no health signal, no backoff; siblings keep reading.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadConfigurationError, null, now);
|
||||
WarnConfigurationErrorOnce(def.Name, reference, def.DeviceHostAddress, ace);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
|
||||
@@ -989,6 +1026,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: the parent's name cannot become a request — per-group config error, device untouched.
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(parent.Name, group.ParentName, parent.DeviceHostAddress, ace);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
@@ -1150,6 +1194,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: a config error on this tag — no evict, no health signal, no backoff.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(def.Name, w.FullReference, def.DeviceHostAddress, ace);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
@@ -1266,7 +1316,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||||
?? throw new InvalidOperationException(
|
||||
?? throw new AbCipTagConfigurationException(
|
||||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||||
|
||||
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
@@ -1295,6 +1345,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Gitea #570: the controller ANSWERED with a type refusal — the tag exists and the session
|
||||
// works. Keep the handle (disposing it re-creates it, and for a sole lease holder
|
||||
// re-Forward-Opens, on every poll) and let the caller report BadTypeMismatch. The native
|
||||
// runtime completes its initialization instead of throwing this; the cache keeps any runtime
|
||||
// that does throw it from churning.
|
||||
device.Backoff.RecordSuccess();
|
||||
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (AbCipTagConfigurationException)
|
||||
{
|
||||
// Gitea #570: the tag's own name cannot become a request — no I/O happened, so it says nothing
|
||||
// about the device: leave the backoff window exactly as it was.
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
runtime.Dispose();
|
||||
@@ -1411,6 +1479,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
using var enumerator = _enumeratorFactory.Create();
|
||||
var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);
|
||||
// One template-reader lease across every UDT shape this device's browse fetches.
|
||||
using var batchReader = _templateReaderFactory.CreateBatch();
|
||||
_batchTemplateReader.Value = batchReader;
|
||||
using var clearBatch = new ClearOnDispose(_batchTemplateReader);
|
||||
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
// Dedup discovered member full-names within this device's Discovered folder.
|
||||
@@ -1488,6 +1560,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears an <see cref="AsyncLocal{T}"/> when the scope that set it ends.</summary>
|
||||
private sealed class ClearOnDispose(AsyncLocal<IAbCipTemplateReader?> slot) : IDisposable
|
||||
{
|
||||
public void Dispose() => slot.Value = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
|
||||
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
|
||||
|
||||
@@ -84,7 +84,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
||||
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, tagName: null, driverInstanceId, "PlcFamily",
|
||||
fallback: AbCipPlcFamily.ControlLogix),
|
||||
DeviceName: d.DeviceName,
|
||||
AllowPacking: d.AllowPacking,
|
||||
@@ -109,6 +109,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
? null
|
||||
: ParseEnum<AbCipTransport>(dto.Transport, tagName: null, driverInstanceId, "Transport"),
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
BrowsePageDelay = TimeSpan.FromMilliseconds(Math.Max(0, dto.BrowsePageDelayMs ?? 5)),
|
||||
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||
EnableDeclarationOnlyUdtGrouping = dto.EnableDeclarationOnlyUdtGrouping ?? false,
|
||||
@@ -130,7 +131,8 @@ public static class AbCipDriverFactoryExtensions
|
||||
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
|
||||
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
|
||||
|
||||
// tagName null = a driver-level field: the error text names the driver config, not a tag.
|
||||
// tagName null = a driver-level (or device-level) config field: the error text names the driver config,
|
||||
// not a tag. Never pass a placeholder like "device" as tagName — it renders as "tag 'device'" (#574).
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
@@ -176,6 +178,11 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// </summary>
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pause in milliseconds between symbol-listing pages (native transport); absent means 5.
|
||||
/// </summary>
|
||||
public int? BrowsePageDelayMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether alarm projection is enabled.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,35 +3,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
|
||||
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
|
||||
/// uniform across drivers.
|
||||
/// Maps transport <see cref="AbStatus"/> values to OPC UA StatusCodes through the table shared with AbLegacy
|
||||
/// (<see cref="AbOpcUaStatusMap"/>): timeout, not-found, not-allowed, out-of-bounds, unsupported, type-mismatch
|
||||
/// and device-state-conflict get their specific codes; the remaining transport errors fold into
|
||||
/// <c>BadCommunicationError</c>. (The raw CIP general-status mapper that used to live here had no callers — the
|
||||
/// transports surface an <see cref="AbStatus"/>, not the status byte — and was removed, #580.)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
|
||||
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
|
||||
/// ones that move the driver's status needle:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
|
||||
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
|
||||
/// (tag doesn't exist).</item>
|
||||
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
|
||||
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
|
||||
/// partition tag from a non-safety task).</item>
|
||||
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
|
||||
/// (type mismatch or truncated buffer).</item>
|
||||
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
|
||||
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
|
||||
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
|
||||
/// writes during download / test-mode transitions).</item>
|
||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||
/// <item>Transport <see cref="AbStatus"/> errors — mapped per-member by
|
||||
/// <see cref="MapStatus(AbStatus)"/>: timeout, not-found, not-allowed, and
|
||||
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
|
||||
/// fold into <c>BadCommunicationError</c>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
@@ -45,23 +22,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x04 or 0x05 => BadNodeIdUnknown,
|
||||
0x06 => GoodMoreData,
|
||||
0x08 => BadNotSupported,
|
||||
0x0A or 0x13 => BadOutOfRange,
|
||||
0x0B => Good,
|
||||
0x0E => BadNotWritable,
|
||||
0x10 => BadDeviceFailure,
|
||||
0x16 => BadNodeIdUnknown,
|
||||
_ => BadInternalError,
|
||||
};
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a transport status code to an OPC UA StatusCode. The integer is
|
||||
@@ -78,19 +39,7 @@ public static class AbCipStatusMapper
|
||||
/// </summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
|
||||
@@ -101,5 +50,5 @@ public static class AbCipStatusMapper
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
AbOpcUaStatusMap.MapException(ex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A tag's own configuration cannot be turned into a request at all — a malformed tag path, or a
|
||||
/// symbolic name the native CIP encoder refuses (Gitea #570). Raised before any I/O, so it says nothing
|
||||
/// about the device: the driver reports it per tag as <c>BadConfigurationError</c> and never counts it
|
||||
/// against the device's connect backoff or its health, so healthy tags on the same device keep reading.
|
||||
/// </summary>
|
||||
internal sealed class AbCipTagConfigurationException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception.</summary>
|
||||
/// <param name="message">What is wrong with the tag's configuration.</param>
|
||||
/// <param name="innerException">The encoder's own exception, if any.</param>
|
||||
public AbCipTagConfigurationException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
internal static class AbCipTransportFactories
|
||||
{
|
||||
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
|
||||
/// <param name="transport">The transport.</param>
|
||||
/// <param name="browsePageDelay">Pause between native symbol-listing pages; null is the compiled default.</param>
|
||||
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
|
||||
For(AbCipTransport transport) => transport switch
|
||||
For(AbCipTransport transport, TimeSpan? browsePageDelay = null) => transport switch
|
||||
{
|
||||
AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(),
|
||||
AbCipTransport.Native => (new NativeAbCipTagFactory(),
|
||||
new NativeAbCipTagEnumeratorFactory(browsePageDelay ?? NativeListingReader.DefaultPageDelay),
|
||||
new NativeAbCipTemplateReaderFactory()),
|
||||
_ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
|
||||
};
|
||||
|
||||
@@ -29,4 +29,13 @@ public interface IAbCipTemplateReaderFactory
|
||||
/// <summary>Creates a new template reader instance.</summary>
|
||||
/// <returns>A new <see cref="IAbCipTemplateReader"/> instance.</returns>
|
||||
IAbCipTemplateReader Create();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a reader meant to serve MANY <see cref="IAbCipTemplateReader.ReadAsync"/> calls: the native
|
||||
/// reader then holds one device lease from its first read until it is disposed, so a caller fetching
|
||||
/// N templates opens one connection rather than one per template. Dispose it when the batch ends.
|
||||
/// The default is <see cref="Create"/> (a transport whose reader has no per-read connection cost).
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="IAbCipTemplateReader"/> instance.</returns>
|
||||
IAbCipTemplateReader CreateBatch() => Create();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -17,27 +17,34 @@ internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> _acquire;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _pageDelay;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTagEnumerator(DeviceSessionPool pool)
|
||||
/// <param name="pageDelay">Pause between listing pages (see <see cref="NativeListingReader.DefaultPageDelay"/>).</param>
|
||||
public NativeAbCipTagEnumerator(DeviceSessionPool pool, TimeSpan pageDelay = default)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.DisposeAsync);
|
||||
})
|
||||
}, pageDelay: pageDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler and its release directly.</summary>
|
||||
/// <param name="acquire">Acquires the scheduler for a device, with the callback that releases it.</param>
|
||||
/// <param name="logger">Receives release failures; null discards them.</param>
|
||||
/// <param name="pageDelay">Pause between listing pages; zero is back-to-back.</param>
|
||||
/// <param name="time">Clock for the pause; null is the system clock.</param>
|
||||
internal NativeAbCipTagEnumerator(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> acquire,
|
||||
ILogger? logger = null)
|
||||
ILogger? logger = null, TimeSpan pageDelay = default, TimeProvider? time = null)
|
||||
{
|
||||
_acquire = acquire;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
_pageDelay = pageDelay;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -49,7 +56,7 @@ internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
try
|
||||
{
|
||||
using var inner = new SymbolListingEnumerator(
|
||||
(_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct));
|
||||
(_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct, _pageDelay, _time));
|
||||
await foreach (var tag in inner.EnumerateAsync(deviceParams, cancellationToken).ConfigureAwait(false))
|
||||
yield return tag;
|
||||
}
|
||||
@@ -75,13 +82,25 @@ internal static class NativeListingReader
|
||||
/// <summary>Byte cap for one listing (64 MiB).</summary>
|
||||
internal const long MaxListingBytes = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Default pause between listing pages. A big controller's listing is hundreds of pages and, unpaced,
|
||||
/// a back-to-back burst (one request in flight, roughly a Studio 5000 online browse). 5 ms is small
|
||||
/// against a page's own round-trip (tens of ms on a real network), so a 50k-symbol browse gains a few
|
||||
/// seconds, yet it yields the controller's message-processing slice between pages. Zero disables it.
|
||||
/// Operator knob: <c>browsePageDelayMs</c> on the driver config.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan DefaultPageDelay = TimeSpan.FromMilliseconds(5);
|
||||
|
||||
/// <summary>Reads <c>@tags</c> or <c>Program:X.@tags</c> in full.</summary>
|
||||
/// <param name="scheduler">The device's shared scheduler.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <param name="pageDelay">Pause before each page after the first; zero is back-to-back.</param>
|
||||
/// <param name="time">Clock for the pause; null is the system clock.</param>
|
||||
/// <returns>The raw entries, byte for byte what <see cref="CipSymbolObjectDecoder"/> expects.</returns>
|
||||
public static async Task<byte[]> ReadAsync(
|
||||
RequestScheduler scheduler, string listingName, CancellationToken cancellationToken)
|
||||
RequestScheduler scheduler, string listingName, CancellationToken cancellationToken,
|
||||
TimeSpan pageDelay = default, TimeProvider? time = null)
|
||||
{
|
||||
string? program = null;
|
||||
if (listingName != "@tags")
|
||||
@@ -96,21 +115,24 @@ internal static class NativeListingReader
|
||||
uint start = 0;
|
||||
for (var page = 0; page < MaxPages; page++)
|
||||
{
|
||||
if (page > 0 && pageDelay > TimeSpan.Zero)
|
||||
await Task.Delay(pageDelay, time ?? TimeProvider.System, cancellationToken).ConfigureAwait(false);
|
||||
var request = SymbolObjectServices.GetInstanceAttributeList(program, start);
|
||||
// Exclusive: the reply can fill the connection, and the packer budgets request bytes only.
|
||||
var reply = await scheduler.SendAsync(request, cancellationToken, exclusive: true).ConfigureAwait(false);
|
||||
var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
|
||||
var (entries, pageMax, more) = SymbolObjectServices.ParseListingReply(reply);
|
||||
all.Write(entries);
|
||||
if (all.Length > MaxListingBytes)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' exceeded {MaxListingBytes} bytes.");
|
||||
if (!more) return all.ToArray();
|
||||
// Instances ascend; a page that does not advance past its start, or a last id that would
|
||||
// wrap, is a misbehaving reply and would otherwise loop forever against the controller.
|
||||
if (last < start || last == uint.MaxValue)
|
||||
// Continue past the HIGHEST instance seen so far, so the order a controller lists a page in does
|
||||
// not matter. A page whose highest instance is still below its start made no progress, and a
|
||||
// highest id that would wrap cannot be continued: both would loop forever against the controller.
|
||||
if (pageMax < start || pageMax == uint.MaxValue)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' made no progress (start {start}, last {last}).");
|
||||
start = last + 1;
|
||||
$"Symbol listing '{listingName}' made no progress (start {start}, highest instance {pageMax}).");
|
||||
start = pageMax + 1;
|
||||
}
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' exceeded {MaxPages} pages.");
|
||||
@@ -120,8 +142,17 @@ internal static class NativeListingReader
|
||||
/// <summary>Factory for <see cref="NativeAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
private readonly TimeSpan _pageDelay;
|
||||
|
||||
/// <summary>Factory with the compiled default pacing.</summary>
|
||||
public NativeAbCipTagEnumeratorFactory() : this(NativeListingReader.DefaultPageDelay) { }
|
||||
|
||||
/// <summary>Factory pacing listing pages by <paramref name="pageDelay"/>.</summary>
|
||||
/// <param name="pageDelay">Pause between pages; zero is back-to-back.</param>
|
||||
public NativeAbCipTagEnumeratorFactory(TimeSpan pageDelay) => _pageDelay = pageDelay;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared);
|
||||
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared, _pageDelay);
|
||||
}
|
||||
|
||||
/// <summary>Bounded lease release shared by the native enumerator and template reader.</summary>
|
||||
|
||||
@@ -20,12 +20,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </remarks>
|
||||
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
/// <summary>Upper bound on waiting for the lease release (and its Forward Close) in <see cref="Dispose"/>.</summary>
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Bytes of a Logix STRING's length word.</summary>
|
||||
private const int StringLengthBytes = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Read size of the built-in Logix STRING: <c>LEN</c> (4) + <c>DATA</c> SINT[82] + 2 bytes of
|
||||
/// alignment padding to the structure's 4-byte boundary.
|
||||
/// </summary>
|
||||
private const int BuiltInStringBytes = 88;
|
||||
|
||||
/// <summary>Characters the built-in Logix STRING's <c>DATA</c> member holds.</summary>
|
||||
private const int BuiltInStringCapacity = 82;
|
||||
|
||||
private readonly AbCipTagCreateParams _p;
|
||||
private readonly DeviceSessionPool? _pool;
|
||||
private readonly ushort _elements;
|
||||
@@ -90,9 +96,16 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// <para>Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
|
||||
/// <see cref="AbStatus.NotFound"/>.
|
||||
/// <see cref="AbStatus.NotFound"/>.</para>
|
||||
/// <para>A name the encoder refuses throws <see cref="AbCipTagConfigurationException"/> before any
|
||||
/// lease (Gitea #570): it is this tag's configuration, not the device, and must not black out the
|
||||
/// device's other tags. A read the controller answers with a deterministic refusal
|
||||
/// (<see cref="AbStatus.TypeMismatch"/> / <see cref="AbStatus.Unsupported"/>) proves the tag exists,
|
||||
/// so it completes the initialization with that status rather than throwing: a throw here makes the
|
||||
/// driver dispose the runtime and re-create it — and, for a sole lease holder, re-Forward-Open — on
|
||||
/// every poll (the mistype churn of #570). The driver's own read reports the refusal.</para>
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -100,9 +113,19 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (_path is null)
|
||||
{
|
||||
// Before any lease: a malformed name costs no connection.
|
||||
var (name, bit) = CipPath.SplitBitSelector(_p.TagName);
|
||||
var path = CipPath.EncodeSymbolic(name);
|
||||
_implicitBit = bit;
|
||||
(string Name, int? Bit) split;
|
||||
byte[] path;
|
||||
try
|
||||
{
|
||||
split = CipPath.SplitBitSelector(_p.TagName);
|
||||
path = CipPath.EncodeSymbolic(split.Name);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
|
||||
{
|
||||
throw new AbCipTagConfigurationException(
|
||||
$"AbCip tag name '{_p.TagName}' cannot be encoded as a CIP symbolic path: {ex.Message}", ex);
|
||||
}
|
||||
_implicitBit = split.Bit;
|
||||
_path = path;
|
||||
}
|
||||
if (_client is null)
|
||||
@@ -112,7 +135,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
|
||||
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
|
||||
}
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
|
||||
{
|
||||
// The controller answered — the tag exists. _status already holds the refusal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -122,7 +152,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
var path = Path();
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path,_elements, cancellationToken).ConfigureAwait(false);
|
||||
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
|
||||
_data = data;
|
||||
_typeCode = type;
|
||||
_structHandle = handle;
|
||||
@@ -136,6 +166,21 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on one element's wire bytes for the scheduler's reply budget (#582); 0 when the
|
||||
/// authored type is unknown or a structure, which sends the read alone rather than guess.
|
||||
/// BOOL counts 4 (a packed BOOL array reads back as DWORDs).
|
||||
/// </summary>
|
||||
internal static int ExpectedElementBytes(AbCipDataType? type) => type switch
|
||||
{
|
||||
AbCipDataType.Bool or AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
|
||||
AbCipDataType.SInt or AbCipDataType.USInt => 1,
|
||||
AbCipDataType.Int or AbCipDataType.UInt => 2,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
|
||||
AbCipDataType.String => LogixTypeCode.StringElementBytes,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -339,29 +384,32 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the connection lease, waiting at most 2 s so an immediate re-init does not open a
|
||||
/// second session to the device while this one's Forward Close is still in flight (review
|
||||
/// finding 22). Never disposes the shared scheduler; the pool does that on the last release.
|
||||
/// Releases the connection lease without blocking (Gitea #579) — the driver calls this under the
|
||||
/// tag's lock, which used to be held for up to 2 s while the last release's Forward Close ran. An
|
||||
/// immediate re-init still cannot open a second session while that close is in flight (review
|
||||
/// finding 22): the pool registers the close synchronously and holds any successor connection
|
||||
/// behind it — see <see cref="ConnectionLease.ReleaseInBackground"/>. Never disposes the shared
|
||||
/// scheduler; the pool does that on the last release. Same release shape as the native AbLegacy runtime.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_lease is { } lease)
|
||||
lease.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
else if (_privateScheduler is { } scheduler)
|
||||
scheduler.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// A faulted close is the pool's problem to log; Dispose must not throw.
|
||||
}
|
||||
TeardownTask = _lease?.ReleaseInBackground() ?? DisposePrivateScheduler(_privateScheduler);
|
||||
_lease = null;
|
||||
_privateScheduler = null;
|
||||
}
|
||||
|
||||
/// <summary>The background teardown <see cref="Dispose"/> started, for tests; null before Dispose.</summary>
|
||||
internal Task? TeardownTask { get; private set; }
|
||||
|
||||
private static async Task DisposePrivateScheduler(RequestScheduler? scheduler)
|
||||
{
|
||||
if (scheduler is null) return;
|
||||
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception) { /* ForTest only; Dispose must not throw */ }
|
||||
}
|
||||
|
||||
private byte[] Path() =>
|
||||
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
|
||||
|
||||
@@ -383,7 +431,12 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private bool IsTypeMismatch(ushort returned)
|
||||
{
|
||||
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 || returned == expected) return false;
|
||||
if (returned == expected) return false;
|
||||
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's width table but are
|
||||
// integers of a known width: they are checked like any integer, so a REAL / LREAL / narrower
|
||||
// integer authored over an LWORD is a mismatch rather than garbage decoded as Good (Gitea #570).
|
||||
// Any other code without a known width (DATE, DATE_AND_TIME, structures) stays exempt.
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 && IntegerWidth(returned) is null) return false;
|
||||
if (_p.AuthoredType == AbCipDataType.Bool)
|
||||
{
|
||||
if (returned == LogixTypeCode.BoolArray) return false;
|
||||
@@ -458,27 +511,40 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
return _data.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Characters a STRING of <paramref name="structureBytes"/> holds. The built-in 88-byte STRING holds
|
||||
/// 82 — its last 2 bytes are alignment padding, not DATA (Gitea #570). Any other size is a user
|
||||
/// string type whose DATA length the read alone cannot separate from its padding (up to 3 bytes),
|
||||
/// so it keeps the buffer-derived <c>size − 4</c>.
|
||||
/// </summary>
|
||||
private static int StringCapacity(int structureBytes) => structureBytes == BuiltInStringBytes
|
||||
? BuiltInStringCapacity
|
||||
: structureBytes - StringLengthBytes;
|
||||
|
||||
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
|
||||
private string DecodeString(int offset)
|
||||
{
|
||||
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
|
||||
var available = _data.Length - offset - StringLengthBytes;
|
||||
// A scalar built-in STRING never yields its alignment padding as characters.
|
||||
if (offset == 0 && _data.Length == BuiltInStringBytes) available = BuiltInStringCapacity;
|
||||
var count = (int)Math.Min(len, (uint)available);
|
||||
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>u32 len</c> + DATA padded to the capacity learned from the last read (data length − 4), so the
|
||||
/// write carries the whole structure as the controller sized it.
|
||||
/// <c>u32 len</c> + DATA, zero-padded to the size learned from the last read, so the write carries the
|
||||
/// whole structure as the controller sized it (padding included, as libplctag sends it). The text
|
||||
/// must fit the DATA capacity, which excludes the padding (<see cref="StringCapacity"/>).
|
||||
/// </summary>
|
||||
private byte[] EncodeStringPayload(string text)
|
||||
{
|
||||
var capacity = _data.Length - StringLengthBytes;
|
||||
var capacity = StringCapacity(_data.Length);
|
||||
var bytes = Encoding.ASCII.GetBytes(text);
|
||||
if (capacity < 0 || bytes.Length > capacity)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-byte capacity.");
|
||||
var payload = new byte[StringLengthBytes + capacity];
|
||||
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-character capacity.");
|
||||
var payload = new byte[_data.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
|
||||
bytes.CopyTo(payload, StringLengthBytes);
|
||||
return payload;
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
|
||||
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses. The lease is held for one read and released when it completes.
|
||||
/// parses. By default the lease is held for one read and released when it completes; a reader built for a
|
||||
/// batch (<see cref="IAbCipTemplateReaderFactory.CreateBatch"/>) instead keeps one lease per device from its
|
||||
/// first read until it is disposed.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
@@ -24,27 +26,52 @@ internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
|
||||
private readonly ILogger _logger;
|
||||
private readonly bool _holdLeases;
|
||||
private readonly object _heldLock = new();
|
||||
private readonly Dictionary<DeviceKey, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _held = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTemplateReader(DeviceSessionPool pool)
|
||||
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
|
||||
public NativeAbCipTemplateReader(DeviceSessionPool pool, bool holdLeases = false)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
|
||||
})
|
||||
}, holdLeases: holdLeases)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
|
||||
/// <param name="acquire">Acquires the connection parts for a device.</param>
|
||||
/// <param name="logger">Receives release failures; null discards them.</param>
|
||||
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
|
||||
internal NativeAbCipTemplateReader(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire,
|
||||
ILogger? logger = null)
|
||||
ILogger? logger = null, bool holdLeases = false)
|
||||
{
|
||||
_acquire = acquire;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
_holdLeases = holdLeases;
|
||||
}
|
||||
|
||||
/// <summary>The connection parts for one read, and whether this read owns (must release) them.</summary>
|
||||
private (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release, bool Owned) Lease(AbCipTagCreateParams p)
|
||||
{
|
||||
if (!_holdLeases)
|
||||
{
|
||||
var one = _acquire(p);
|
||||
return (one.Scheduler, one.Channel, one.Release, true);
|
||||
}
|
||||
var key = NativeAbCipDevice.Key(p);
|
||||
lock (_heldLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_held.TryGetValue(key, out var held))
|
||||
_held[key] = held = _acquire(p);
|
||||
return (held.Scheduler, held.Channel, held.Release, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -55,7 +82,7 @@ internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
|
||||
var id = (ushort)templateInstanceId;
|
||||
|
||||
var (scheduler, channel, release) = _acquire(deviceParams);
|
||||
var (scheduler, channel, release, owned) = Lease(deviceParams);
|
||||
try
|
||||
{
|
||||
var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
|
||||
@@ -91,12 +118,22 @@ internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeLease.ReleaseBounded(release, _logger);
|
||||
if (owned) NativeLease.ReleaseBounded(release, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — each read owns and releases its own lease.</summary>
|
||||
public void Dispose() { }
|
||||
/// <summary>Releases the leases a batch reader held; a per-read reader has none.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
List<Func<ValueTask>> releases;
|
||||
lock (_heldLock)
|
||||
{
|
||||
_disposed = true;
|
||||
releases = [.. _held.Values.Select(h => h.Release)];
|
||||
_held.Clear();
|
||||
}
|
||||
foreach (var release in releases) NativeLease.ReleaseBounded(release, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
|
||||
@@ -104,4 +141,7 @@ internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFac
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAbCipTemplateReader CreateBatch() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared, holdLeases: true);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,13 @@
|
||||
Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix /
|
||||
Micro800 / SLC500 / PLC-5. -->
|
||||
<PackageReference Include="libplctag"/>
|
||||
<!-- #580: orphan-release helper shared with the sibling AB driver (source-linked; keeps a Libplctag*.cs name). -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared\LibplctagOrphanRelease.cs" Link="LibplctagOrphanRelease.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps transport status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
|
||||
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
|
||||
/// Maps transport status codes to OPC UA StatusCodes via the table shared with AbCip
|
||||
/// (<see cref="AbOpcUaStatusMap"/>).
|
||||
/// </summary>
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
@@ -32,45 +31,5 @@ public static class AbLegacyStatusMapper
|
||||
/// <summary>Map a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>Map an exception thrown by a transport to an OPC UA StatusCode.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The mapped status for an <see cref="AbTransportException"/>, else BadCommunicationError.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
|
||||
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
|
||||
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
|
||||
/// the transports surface only an <see cref="AbStatus"/> rather than exposing
|
||||
/// the raw STS byte, so this method is not wired into the current read/write path.
|
||||
/// It is retained as the reference mapping for future PCCC-STS inspection.
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapPcccStatus(byte sts) => sts switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x10 => BadNotSupported,
|
||||
0x20 => BadNodeIdUnknown,
|
||||
0x30 => BadNotWritable,
|
||||
0x40 => BadDeviceFailure,
|
||||
0x50 => BadDeviceFailure,
|
||||
0xF0 => BadInternalError, // extended status not inspected at this layer
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
|
||||
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
|
||||
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
|
||||
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
|
||||
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
|
||||
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
|
||||
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
|
||||
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
|
||||
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
|
||||
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
|
||||
/// background for the life of the process.</para>
|
||||
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
|
||||
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
var handle = HandleOf(tag);
|
||||
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
|
||||
var status = (Status)plctag.plc_tag_status(handle);
|
||||
plctag.plc_tag_unregister_callback(handle);
|
||||
plctag.plc_tag_destroy(handle);
|
||||
ClearHandle(tag);
|
||||
Interlocked.Increment(ref _released);
|
||||
return status;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
|
||||
// state the native side is in, its callback delegate stays alive.
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,11 +175,15 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
return (Math.Max(1, PcccServices.MaxDataBytes / _elementSize), address);
|
||||
}
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct)
|
||||
/// <summary>Bytes of an Execute PCCC reply beyond its data: CIP header, requestor id, PCCC command / status / TNS
|
||||
/// (rounded up) — the scheduler budgets <c>PcccReplyOverhead + data</c> against the connection (#582).</summary>
|
||||
private const int PcccReplyOverhead = 32;
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct, int replyDataBytes = 0)
|
||||
{
|
||||
var tns = unchecked((ushort)Interlocked.Increment(ref s_tns));
|
||||
var request = PcccServices.ExecutePccc(_vendor, _serial, pccc(tns));
|
||||
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
|
||||
var reply = await _scheduler.SendAsync(request, ct, expectedReplyBytes: PcccReplyOverhead + replyDataBytes).ConfigureAwait(false);
|
||||
return PcccServices.ParseReply(reply, tns, _requestorId);
|
||||
}
|
||||
|
||||
@@ -200,7 +204,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
tns => plc5
|
||||
? PcccServices.Plc5RangeRead(tns, at, (ushort)(bytes / 2), bytes)
|
||||
: PcccServices.SlcTypedRead(tns, at, bytes),
|
||||
ct).ConfigureAwait(false);
|
||||
ct, bytes).ConfigureAwait(false);
|
||||
if (data.Length < bytes)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC read returned {data.Length} of {bytes} bytes.");
|
||||
if (data.Length > bytes)
|
||||
@@ -381,12 +385,11 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the lease on the shared device connection (the last release closes it).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_lease is null) return;
|
||||
// Closing does I/O (Forward Close / UnRegisterSession); never block the caller on it.
|
||||
_ = _lease.DisposeAsync().AsTask().ContinueWith(
|
||||
static t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
/// <summary>
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
/// The same release shape as the native AbCip runtime — see <see cref="ConnectionLease.ReleaseInBackground"/>
|
||||
/// for why a re-create the next instant still cannot overlap the close (Gitea #579).
|
||||
/// </summary>
|
||||
public void Dispose() => _lease?.ReleaseInBackground();
|
||||
}
|
||||
|
||||
@@ -24,10 +24,13 @@
|
||||
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
|
||||
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
|
||||
<PackageReference Include="libplctag"/>
|
||||
<!-- #580: orphan-release helper shared with the sibling AB driver (source-linked; keeps a Libplctag*.cs name). -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared\LibplctagOrphanRelease.cs" Link="LibplctagOrphanRelease.cs"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@page "/raw"
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@* prerender: false (#577 "first tree click ignored"): with prerender on, the server-rendered tree is shown
|
||||
before the circuit attaches, so a click on its (handler-less) twists is silently dropped, and the
|
||||
component then re-runs OnInitializedAsync and swaps in a second, freshly loaded node set. The tree
|
||||
is the only content here and needs the circuit anyway, so render it once, interactively. *@
|
||||
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@inject IRawTreeService Svc
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Device name</label>
|
||||
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
|
||||
<input class="form-control form-control-sm mono" @bind="_name" placeholder="e.g. plc1" />
|
||||
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -145,7 +145,7 @@
|
||||
{
|
||||
_resolvedDriverInstanceId = DriverInstanceId;
|
||||
_driverType = DriverType ?? "";
|
||||
_name = "Device1";
|
||||
_name = ""; // placeholder only: a pre-filled value made typing append to it (#577); the name is validated on save
|
||||
_enabled = true;
|
||||
_deviceConfigJson = "{}";
|
||||
_rowVersion = [];
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Preserve-unknown save for the typed driver forms (#576). A form deserialises the stored DriverConfig
|
||||
/// into a typed options object and re-serialises it on save, which erases every key the type does not
|
||||
/// model (a newer option, a hand-edited key, <c>rawTags</c>, a device's extra keys…). This overlays only
|
||||
/// the paths the form OWNS onto the ORIGINAL JSON object: an owned path is replaced by the form's value,
|
||||
/// or removed when the form emits nothing/null ("Default"); every other key in the original survives.
|
||||
/// </summary>
|
||||
internal static class DriverConfigJsonOverlay
|
||||
{
|
||||
/// <summary>Overlays the <paramref name="ownedPaths"/> of <paramref name="typedJson"/> onto <paramref name="originalJson"/>.</summary>
|
||||
/// <param name="originalJson">The stored config as loaded; blank or non-object input yields <paramref name="typedJson"/> unchanged.</param>
|
||||
/// <param name="typedJson">The form's own serialised options.</param>
|
||||
/// <param name="ownedPaths">Dot-separated, case-insensitive property paths the form edits (e.g. <c>transport</c>, <c>probe.enabled</c>).</param>
|
||||
/// <param name="timeSpanToMs">
|
||||
/// (TimeSpan-string path, integer-millisecond path) pairs. The typed options serialise durations as
|
||||
/// <c>"00:00:05"</c> but the driver factories bind <c>*Ms</c> integers (#576); each pair is rewritten
|
||||
/// in the typed output (and the legacy TimeSpan path becomes owned, so a stale one is removed).
|
||||
/// </param>
|
||||
public static string Apply(string? originalJson, string typedJson, IEnumerable<string> ownedPaths,
|
||||
IReadOnlyList<(string TimeSpanPath, string MsPath)>? timeSpanToMs = null)
|
||||
{
|
||||
if (JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson;
|
||||
var owned = ownedPaths.ToList();
|
||||
foreach (var (tsPath, msPath) in timeSpanToMs ?? [])
|
||||
{
|
||||
owned.Add(tsPath);
|
||||
owned.Add(msPath);
|
||||
if (Get(typed, tsPath) is JsonValue v && v.TryGetValue<string>(out var text)
|
||||
&& TimeSpan.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out var ts))
|
||||
{
|
||||
RemovePath(typed, tsPath);
|
||||
Set(typed, msPath, (int)ts.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject? original = null;
|
||||
if (!string.IsNullOrWhiteSpace(originalJson))
|
||||
{
|
||||
try { original = JsonNode.Parse(originalJson) as JsonObject; }
|
||||
catch (JsonException) { /* unusable original: fall through to the typed output */ }
|
||||
}
|
||||
if (original is null) return typed.ToJsonString();
|
||||
|
||||
foreach (var path in owned)
|
||||
{
|
||||
var segments = path.Split('.');
|
||||
JsonObject? from = typed;
|
||||
JsonObject? to = original;
|
||||
for (var i = 0; i < segments.Length - 1 && to is not null; i++)
|
||||
{
|
||||
from = from is null ? null : Find(from, segments[i]) as JsonObject;
|
||||
var next = Find(to, segments[i]) as JsonObject;
|
||||
if (next is null && from is not null)
|
||||
{
|
||||
next = [];
|
||||
to[segments[i]] = next;
|
||||
}
|
||||
to = next;
|
||||
}
|
||||
if (to is null) continue;
|
||||
|
||||
var leaf = segments[^1];
|
||||
var value = from is null ? null : Find(from, leaf);
|
||||
Remove(to, leaf);
|
||||
if (value is not null) to[leaf] = value.DeepClone();
|
||||
}
|
||||
return original.ToJsonString();
|
||||
}
|
||||
|
||||
private static JsonNode? Get(JsonObject root, string path)
|
||||
{
|
||||
JsonNode? cur = root;
|
||||
foreach (var seg in path.Split('.'))
|
||||
cur = cur is JsonObject o ? Find(o, seg) : null;
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void RemovePath(JsonObject root, string path)
|
||||
{
|
||||
var segs = path.Split('.');
|
||||
JsonObject? cur = root;
|
||||
for (var i = 0; i < segs.Length - 1 && cur is not null; i++)
|
||||
cur = Find(cur, segs[i]) as JsonObject;
|
||||
if (cur is not null) Remove(cur, segs[^1]);
|
||||
}
|
||||
|
||||
private static void Set(JsonObject root, string path, JsonNode value)
|
||||
{
|
||||
var segs = path.Split('.');
|
||||
var cur = root;
|
||||
for (var i = 0; i < segs.Length - 1; i++)
|
||||
{
|
||||
if (Find(cur, segs[i]) is not JsonObject next) { next = []; cur[segs[i]] = next; }
|
||||
cur = next;
|
||||
}
|
||||
cur[segs[^1]] = value;
|
||||
}
|
||||
|
||||
private static JsonNode? Find(JsonObject o, string key)
|
||||
{
|
||||
foreach (var kv in o)
|
||||
if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)) return kv.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Remove(JsonObject o, string key)
|
||||
{
|
||||
var hit = o.Select(kv => kv.Key).FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase));
|
||||
if (hit is not null) o.Remove(hit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Shared millisecond helpers for the AB forms (#576): the driver factories bind <c>*Ms</c> integers
|
||||
/// while the forms edit whole seconds, so a sub-second stored value must survive an edit of an
|
||||
/// unrelated field.
|
||||
/// </summary>
|
||||
internal static class DriverConfigMs
|
||||
{
|
||||
/// <summary>Reads an integer-millisecond key at <paramref name="path"/> from <paramref name="json"/>, or null.</summary>
|
||||
public static int? Read(string? json, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
JsonNode? cur = JsonNode.Parse(json);
|
||||
foreach (var seg in path.Split('.'))
|
||||
{
|
||||
if (cur is not JsonObject o) return null;
|
||||
cur = o.FirstOrDefault(kv => string.Equals(kv.Key, seg, StringComparison.OrdinalIgnoreCase)).Value;
|
||||
}
|
||||
return cur is JsonValue v && v.TryGetValue<int>(out var ms) ? ms : null;
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Whole seconds for display (truncating, like the pre-#576 forms).</summary>
|
||||
public static int ToSeconds(TimeSpan t) => (int)t.TotalSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// The duration to save: the loaded value when the operator left the seconds field unchanged
|
||||
/// (keeps 500 ms from collapsing to 0 s), else the edited whole seconds.
|
||||
/// </summary>
|
||||
public static TimeSpan Save(int seconds, TimeSpan? loaded)
|
||||
=> loaded is { } l && ToSeconds(l) == seconds ? l : TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
+66
-8
@@ -18,6 +18,11 @@
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max concurrent reads</label>
|
||||
<InputNumber @bind-Value="_form.MaxConcurrentReads" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync" class="form-check-input" id="enableBrowse" />
|
||||
@@ -124,6 +129,27 @@
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
// The config exactly as loaded; the save overlays the form-owned keys onto it so keys this form does not
|
||||
// model (unknown/newer options, rawTags, per-device extras) survive an unrelated edit (#576).
|
||||
private string? _originalJson;
|
||||
|
||||
private static bool IsBlankConfig(string? json)
|
||||
=> string.IsNullOrWhiteSpace(json) || json.AsSpan().Trim().SequenceEqual("{}");
|
||||
|
||||
/// <summary>Top-level/nested JSON paths this form edits; everything else in the stored config is preserved.</summary>
|
||||
internal static readonly string[] OwnedPaths =
|
||||
[
|
||||
"transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
|
||||
"enableAlarmProjection", "maxConcurrentReads", "probeTimeoutSeconds",
|
||||
"probe.enabled", "probe.probeTagPath",
|
||||
];
|
||||
|
||||
/// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary>
|
||||
internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
|
||||
[
|
||||
("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"),
|
||||
("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
|
||||
];
|
||||
|
||||
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
|
||||
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
|
||||
@@ -135,7 +161,12 @@
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form = FormModel.FromJson(DriverConfigJson, opts);
|
||||
// #577: a brand-new driver (nothing authored yet) must not default the probe ON with no tag path —
|
||||
// that logs an operator warning per driver and runs no probe. An existing config keeps whatever
|
||||
// it stores (an absent "probe" key still means "enabled" to the driver, so don't flip it on edit).
|
||||
if (IsBlankConfig(DriverConfigJson)) _form.ProbeEnabled = false;
|
||||
_originalJson = DriverConfigJson;
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
@@ -145,16 +176,17 @@
|
||||
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||
/// from the device rows at deploy/probe time).</summary>
|
||||
public string GetConfigJson()
|
||||
=> Serialize(_form.ToOptions(_preservedDevices));
|
||||
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson);
|
||||
|
||||
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
||||
internal static string Serialize(AbCipDriverOptions options)
|
||||
=> JsonSerializer.Serialize(options, _jsonOpts);
|
||||
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
|
||||
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
_originalJson = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
@@ -180,6 +212,7 @@
|
||||
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
|
||||
// every edited driver to it.
|
||||
public AbCipTransport? Transport { get; set; }
|
||||
public int MaxConcurrentReads { get; set; } = 8;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
|
||||
@@ -196,10 +229,34 @@
|
||||
// Admin UI probe timeout
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// The loaded durations, so an unchanged seconds field saves the exact stored value (sub-second safe).
|
||||
private TimeSpan? _loadedTimeout, _loadedAlarmPoll, _loadedProbeInterval, _loadedProbeTimeout;
|
||||
|
||||
/// <summary>Loads from the stored JSON: the factory's <c>*Ms</c> keys win; a legacy TimeSpan-string key is the fallback.</summary>
|
||||
public static FormModel FromJson(string? json, AbCipDriverOptions o)
|
||||
{
|
||||
static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
|
||||
var f = FromOptions(o);
|
||||
f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
|
||||
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
|
||||
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
|
||||
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
|
||||
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
|
||||
f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
|
||||
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
|
||||
f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
|
||||
return f;
|
||||
}
|
||||
|
||||
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
||||
{
|
||||
_loadedTimeout = o.Timeout,
|
||||
_loadedAlarmPoll = o.AlarmPollInterval,
|
||||
_loadedProbeInterval = o.Probe.Interval,
|
||||
_loadedProbeTimeout = o.Probe.Timeout,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
MaxConcurrentReads = o.MaxConcurrentReads,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||
@@ -218,15 +275,16 @@
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
|
||||
Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
|
||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
|
||||
Transport = Transport,
|
||||
MaxConcurrentReads = MaxConcurrentReads,
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
EnableAlarmProjection = EnableAlarmProjection,
|
||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||
AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll),
|
||||
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
+44
-7
@@ -90,6 +90,22 @@
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
// The config exactly as loaded; the save overlays the form-owned keys onto it so keys this form does not
|
||||
// model (unknown/newer options, rawTags, per-device extras) survive an unrelated edit (#576).
|
||||
private string? _originalJson;
|
||||
|
||||
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
|
||||
internal static readonly string[] OwnedPaths =
|
||||
[
|
||||
"transport", "probeTimeoutSeconds",
|
||||
"probe.enabled", "probe.probeAddress",
|
||||
];
|
||||
|
||||
/// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary>
|
||||
internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
|
||||
[
|
||||
("timeout", "timeoutMs"), ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
|
||||
];
|
||||
|
||||
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
|
||||
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
|
||||
@@ -101,7 +117,8 @@
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form = FormModel.FromJson(DriverConfigJson, opts);
|
||||
_originalJson = DriverConfigJson;
|
||||
_preservedDevices = opts.Devices;
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
@@ -110,16 +127,17 @@
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
|
||||
/// existing Devices array (authored as separate v3 entities).</summary>
|
||||
public string GetConfigJson()
|
||||
=> Serialize(_form.ToOptions(_preservedDevices));
|
||||
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson);
|
||||
|
||||
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
||||
internal static string Serialize(AbLegacyDriverOptions options)
|
||||
=> JsonSerializer.Serialize(options, _jsonOpts);
|
||||
internal static string Serialize(AbLegacyDriverOptions options, string? originalJson = null)
|
||||
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
_originalJson = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
@@ -159,8 +177,27 @@
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
private TimeSpan? _loadedTimeout, _loadedProbeInterval, _loadedProbeTimeout;
|
||||
|
||||
/// <summary>Loads from the stored JSON: the factory's <c>*Ms</c> keys win; a legacy TimeSpan-string key is the fallback.</summary>
|
||||
public static FormModel FromJson(string? json, AbLegacyDriverOptions o)
|
||||
{
|
||||
static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
|
||||
var f = FromOptions(o);
|
||||
f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
|
||||
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
|
||||
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
|
||||
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
|
||||
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
|
||||
f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
|
||||
return f;
|
||||
}
|
||||
|
||||
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
||||
{
|
||||
_loadedTimeout = o.Timeout,
|
||||
_loadedProbeInterval = o.Probe.Interval,
|
||||
_loadedProbeTimeout = o.Probe.Timeout,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
@@ -177,11 +214,11 @@
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
|
||||
Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
|
||||
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
|
||||
Transport = Transport,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -56,3 +58,23 @@ public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<A
|
||||
Message(options.DefaultTransport));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Composition-root registration of <see cref="AbCipDriverDefaultsOptions"/>.</summary>
|
||||
public static class AbCipDriverDefaultsRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds + validates <c>Drivers:AbCip</c>. Unconditional on every role: BOTH consumers need it — the driver
|
||||
/// factory (driver role) and the Test Connect probe (admin role, which must probe the stack the driver would
|
||||
/// run). A bad value fails host start rather than silently falling back. <c>Program.cs</c> must call this
|
||||
/// (pinned by <c>AbCipDriverDefaultsOptionsTests</c>): without it an operator's pin is silently ignored (#585).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <returns>The same collection.</returns>
|
||||
public static IServiceCollection AddAbCipDriverDefaults(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +179,7 @@ builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.
|
||||
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
builder.Services.AddAbCipDriverDefaults(builder.Configuration);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -201,6 +201,44 @@ public sealed class DataPlaneAuthorizer
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: false);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the API key behind <paramref name="identity"/> is still usable — consulted by the two
|
||||
/// server-wide role gates (<c>WriteOperate</c>, <c>AlarmAck</c>) <b>regardless of
|
||||
/// <see cref="Enabled"/></b> (Gitea #563).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// With the flag off a session's roles are resolved once, at activation, so before this check a
|
||||
/// key that expired by the clock — or that a deployment disabled or removed — kept
|
||||
/// <c>WriteOperate</c> / <c>AlarmAck</c> on its open session until the client disconnected. With
|
||||
/// the flag on, <see cref="Authorize"/> already makes the identical check first thing, so the role
|
||||
/// gates call this only on the flag-off path and the two postures agree on key usability.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is the same <see cref="DataPlaneAuthoritySnapshot.FindUsableKey"/> probe against the same
|
||||
/// snapshot and the same clock as <see cref="Authorize"/>: one dictionary lookup, no I/O, so it is
|
||||
/// safe on the SDK's synchronous write and method-call threads. A session carrying <b>no</b> key id —
|
||||
/// Anonymous, or a legacy identity — has nothing that can expire and always passes; its roles stay
|
||||
/// exactly what activation resolved. A key absent from the snapshot denies, as it does with the flag
|
||||
/// on: on a driver-only node the artifact is the only key source there is, so "absent" means
|
||||
/// "removed by a deployment".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
|
||||
/// <param name="operation">The operation being attempted, for the denial log.</param>
|
||||
/// <param name="nodeId">The target node, for the denial log.</param>
|
||||
/// <returns><c>null</c> to proceed to the role check; otherwise the veto to return to the SDK.</returns>
|
||||
public ServiceResult? AuthorizeKeyUsable(
|
||||
RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId)
|
||||
{
|
||||
if (identity?.ApiKeyId is not { Length: > 0 } keyId) return null;
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
return snapshot.FindUsableKey(keyId, _time.GetUtcNow().UtcDateTime) is null
|
||||
? DenyRevokedKey(operation, nodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
|
||||
/// every check that must run per call comes first, the memoized verdict is consulted next, and the
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<!-- R2-08 (03/S2): the outage-sim integration test drives the internal static
|
||||
OpcUaApplicationHost.HandleImpersonation against the real LDAP library. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.IntegrationTests"/>
|
||||
<!-- Gitea #562: the over-the-wire event-revalidation test asserts the server-side fan-out, because a
|
||||
deleted item resurrected onto a notifier is invisible to the client that deleted it. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -99,4 +99,45 @@ public class ReadCommandTests
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A Bad node status is printed, not thrown (#577).</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsStatusCodeWhenTheReadFailsWithABadStatus()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
ReadException = new ServiceResultException(StatusCodes.BadNodeIdUnknown)
|
||||
};
|
||||
var command = new ReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=Missing"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console); // must not throw
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Node: ns=2;s=Missing");
|
||||
output.ShouldContain("BadNodeIdUnknown");
|
||||
fakeService.DisconnectCalled.ShouldBeTrue(); // positive control: the finally still ran
|
||||
}
|
||||
|
||||
/// <summary>A session-level failure is still an error, not a printed status.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_StillThrowsOnASessionLevelFailure()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
ReadException = new ServiceResultException(StatusCodes.BadConnectionClosed)
|
||||
};
|
||||
var command = new ReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=Any"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<ServiceResultException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,21 +1040,22 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the ".Condition" suffix is appended when the caller supplies the
|
||||
/// source node, but left alone when the caller already passes the condition node —
|
||||
/// matches the documented contract.
|
||||
/// Regression: OtOpcUa conditions are their own event source, so Acknowledge/Confirm must be invoked on
|
||||
/// the supplied condition node itself — no ".Condition" child exists on the server to append.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_LeavesConditionSuffixAlone()
|
||||
public async Task AcknowledgeAndConfirm_InvokeMethodsOnTheConditionNodeItself()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
await _service.AcknowledgeAlarmAsync("ns=2;s=Cond.Condition", new byte[] { 1, 2 }, "acked");
|
||||
await _service.AcknowledgeAlarmAsync("ns=2;s=Plant/Alarm1", new byte[] { 1, 2 }, "acked");
|
||||
await _service.ConfirmAlarmAsync("ns=2;s=Plant/Alarm1", new byte[] { 1, 2 }, "confirmed");
|
||||
|
||||
// Both call shapes reach the adapter once.
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds.ShouldAllBe(id => id == NodeId.Parse("ns=2;s=Plant/Alarm1"));
|
||||
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AcknowledgeableConditionType_Acknowledge);
|
||||
session.CallMethodMethodIds[1].ShouldBe(MethodIds.AcknowledgeableConditionType_Confirm);
|
||||
}
|
||||
|
||||
// --- ConfirmAlarm tests ---
|
||||
@@ -1113,6 +1114,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
|
||||
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AlarmConditionType_ShelvingState_OneShotShelve);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
@@ -1150,6 +1153,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
|
||||
session.CallMethodMethodIds[0].ShouldBe(MethodIds.AlarmConditionType_ShelvingState_Unshelve);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
@@ -1191,7 +1196,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
|
||||
session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Enable);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
@@ -1211,31 +1216,11 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond"));
|
||||
session.CallMethodMethodIds[0].ShouldBe(MethodIds.ConditionType_Disable);
|
||||
session.CallMethodInputArgs[0].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the ".Condition" suffix is appended when the caller supplies the source node,
|
||||
/// but left alone when the caller already passes the condition node — mirrors
|
||||
/// <see cref="AcknowledgeAlarmAsync_LeavesConditionSuffixAlone"/> for the Enable path so the
|
||||
/// object-node derivation in CallEnableDisableAsync is regression-covered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnableAsync_WhenConditionSuffixAlreadyPresent_DoesNotDoubleSuffix()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.EnableAsync("ns=2;s=Cond.Condition");
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
session.CallMethodObjectIds[0].ShouldBe(NodeId.Parse("ns=2;s=Cond.Condition"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="ServiceResultException"/> from the session is captured and
|
||||
/// returned as a bad <see cref="StatusCode"/> rather than propagating to the caller.
|
||||
|
||||
@@ -126,4 +126,43 @@ public sealed class ClusterRoleInfoRoleLeaderCaseTests
|
||||
hostA.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic pin for Gitea #590: a RoleLeaderChanged event whose leader address is not (yet)
|
||||
/// in <c>Cluster.State.Members</c> must still record that leader. The handler used to resolve the
|
||||
/// address through the state snapshot, which is published asynchronously and can lag the event;
|
||||
/// a miss stored null permanently. Here the cluster never joins anything, so the leader address
|
||||
/// is guaranteed absent from the snapshot — the lagging-snapshot case, made exact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RoleLeaderChanged_is_recorded_even_when_the_leader_is_not_in_the_state_snapshot()
|
||||
{
|
||||
var port = FreePort();
|
||||
var options = new AkkaClusterOptions
|
||||
{
|
||||
SystemName = SystemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1",
|
||||
Port = port, Roles = new[] { "driver" }, SeedNodes = Array.Empty<string>(),
|
||||
};
|
||||
var system = Akka.Actor.ActorSystem.Create(SystemName, Akka.Configuration.ConfigurationFactory.ParseString(
|
||||
"akka.actor.provider = cluster\nakka.remote.dot-netty.tcp.hostname = 127.0.0.1\n"
|
||||
+ $"akka.remote.dot-netty.tcp.port = {port}"));
|
||||
try
|
||||
{
|
||||
using var info = new ClusterRoleInfo(
|
||||
system, Options.Create(options),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<ClusterRoleInfo>.Instance);
|
||||
var driver = new Akka.Actor.Address("akka.tcp", SystemName, "127.0.0.1", 11111);
|
||||
var capital = new Akka.Actor.Address("akka.tcp", SystemName, "127.0.0.1", 22222);
|
||||
|
||||
info.HandleRoleLeaderChanged(new Akka.Cluster.ClusterEvent.RoleLeaderChanged("driver", driver));
|
||||
info.HandleRoleLeaderChanged(new Akka.Cluster.ClusterEvent.RoleLeaderChanged("Driver", capital));
|
||||
|
||||
info.RoleLeader("driver")!.Value.ToString().ShouldContain("11111");
|
||||
info.RoleLeader("Driver")!.Value.ToString().ShouldContain("22222");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ public sealed class AbStatusCodesTests
|
||||
public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
|
||||
AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void General_status_FF_with_no_extended_status_is_BadReply() =>
|
||||
AbStatusCodes.FromCip(0xFF, ReadOnlySpan<ushort>.Empty).ShouldBe(AbStatus.BadReply);
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, null, AbStatus.Ok)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
|
||||
/// <summary>One EtherNet/IP encapsulation frame from a golden capture.</summary>
|
||||
@@ -30,15 +31,15 @@ internal static class CaptureFile
|
||||
public static byte[] CipPayload(CapturedFrame f)
|
||||
{
|
||||
var b = f.Bytes;
|
||||
var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
|
||||
var cmd = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(0));
|
||||
if (cmd is not (0x6F or 0x70)) return []; // only SendRRData / SendUnitData carry CPF items
|
||||
var pos = 24 + 4 + 2; // header + interface handle + timeout
|
||||
var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
|
||||
var itemCount = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(pos)); pos += 2;
|
||||
byte[] last = [];
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var type = BitConverter.ToUInt16(b, pos);
|
||||
var len = BitConverter.ToUInt16(b, pos + 2);
|
||||
var type = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(pos));
|
||||
var len = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(pos + 2));
|
||||
last = b.AsSpan(pos + 4, len).ToArray();
|
||||
if (type == 0x00B1) last = last[2..]; // strip connected sequence count
|
||||
pos += 4 + len;
|
||||
|
||||
+5
-4
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -36,7 +37,7 @@ public sealed class CaptureFileTests
|
||||
foreach (var f in frames)
|
||||
{
|
||||
f.Bytes.Length.ShouldBeGreaterThanOrEqualTo(24, name);
|
||||
BitConverter.ToUInt16(f.Bytes, 2).ShouldBe((ushort)(f.Bytes.Length - 24), name);
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(f.Bytes.AsSpan(2)).ShouldBe((ushort)(f.Bytes.Length - 24), name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +55,12 @@ public sealed class CaptureFileTests
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_CPF_item_walk_ends_exactly_at_the_frame_end(string name)
|
||||
{
|
||||
foreach (var f in CaptureFile.Load(name).Where(f => BitConverter.ToUInt16(f.Bytes, 0) is 0x6F or 0x70))
|
||||
foreach (var f in CaptureFile.Load(name).Where(f => BinaryPrimitives.ReadUInt16LittleEndian(f.Bytes.AsSpan(0)) is 0x6F or 0x70))
|
||||
{
|
||||
var pos = 24 + 4 + 2;
|
||||
var itemCount = BitConverter.ToUInt16(f.Bytes, pos);
|
||||
var itemCount = BinaryPrimitives.ReadUInt16LittleEndian(f.Bytes.AsSpan(pos));
|
||||
pos += 2;
|
||||
for (var i = 0; i < itemCount; i++) pos += 4 + BitConverter.ToUInt16(f.Bytes, pos + 2);
|
||||
for (var i = 0; i < itemCount; i++) pos += 4 + BinaryPrimitives.ReadUInt16LittleEndian(f.Bytes.AsSpan(pos + 2));
|
||||
|
||||
pos.ShouldBe(f.Bytes.Length, name);
|
||||
}
|
||||
|
||||
+205
-16
@@ -2,11 +2,14 @@ using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
|
||||
|
||||
/// <summary>Serializes every test class that asserts on <see cref="CipConnection"/>'s process-wide
|
||||
/// diagnostic counters, so no parallel test can move them mid-assertion.</summary>
|
||||
/// <summary>Serializes the loopback-socket test classes. Their diagnostic counters are per connection / per
|
||||
/// pool since Gitea #581 (no longer process-wide statics), but <see cref="DeviceSessionPoolTests"/> still
|
||||
/// reconfigures <see cref="DeviceSessionPool.Shared"/>'s logger, and the timing-sensitive storm test is kept
|
||||
/// off a thread pool its neighbours are saturating.</summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class CipConnectionStatics
|
||||
{
|
||||
@@ -66,7 +69,7 @@ public sealed class CipConnectionTests
|
||||
target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x08)(fo) : FakeCipTarget.Accept(fo);
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
var before = Volatile.Read(ref CipConnection.ForwardOpenCount);
|
||||
var before = conn.Diagnostics.ForwardOpens;
|
||||
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
@@ -76,7 +79,7 @@ public sealed class CipConnectionTests
|
||||
fos[1][0].ShouldBe((byte)0x54);
|
||||
RegularOTParams(fos[1]).ShouldBe((ushort)(0x4200 | 504));
|
||||
conn.MaxRequestBytes.ShouldBe(502);
|
||||
(Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(2);
|
||||
(conn.Diagnostics.ForwardOpens - before).ShouldBe(2);
|
||||
target.ConnectedRequests.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -109,14 +112,12 @@ public sealed class CipConnectionTests
|
||||
[Fact]
|
||||
public async Task A_spent_open_attempt_budget_refuses_the_reconnect_before_any_io()
|
||||
{
|
||||
// Task 18 review r1 (C1): the differential harness caps the process at one open attempt, so a press
|
||||
// Task 18 review r1 (C1): the differential harness caps the press at one open attempt, so a press
|
||||
// that refuses the Forward Open sees one TCP connect and one Forward Open, never one per remaining tag.
|
||||
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) };
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(size: 504), time);
|
||||
var before = Volatile.Read(ref CipConnection.OpenAttempts);
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, before + 1);
|
||||
try
|
||||
using (conn.Diagnostics.LimitOpenAttempts(Key(target), 1))
|
||||
{
|
||||
await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
||||
var retry = conn.SendAsync(ReadTag, TestCt);
|
||||
@@ -128,13 +129,43 @@ public sealed class CipConnectionTests
|
||||
refused.Message.ShouldContain("budget");
|
||||
target.Server.Connections.ShouldBe(1);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
|
||||
conn.Diagnostics.OpenAttempts.ShouldBe(2); // the refused one is counted too
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_attempt_limit_is_scoped_to_one_device_and_lifted_by_its_scope()
|
||||
{
|
||||
// Gitea #566 / #581: the limit is per DeviceKey on one pool's diagnostics, not a process-wide static, so
|
||||
// limiting the press under test can never refuse an open to any other device.
|
||||
await using var limited = new FakeCipTarget();
|
||||
await using var other = new FakeCipTarget();
|
||||
var time = new ManualTimeProvider();
|
||||
var pool = new DeviceSessionPool(time);
|
||||
var limitedKey = Key(limited);
|
||||
|
||||
using (pool.Diagnostics.LimitOpenAttempts(limitedKey, 0))
|
||||
{
|
||||
await using var otherLease = pool.Acquire(Key(other), Options());
|
||||
(await otherLease.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
|
||||
await using var limitedLease = pool.Acquire(limitedKey, Options());
|
||||
var refused = await Should.ThrowAsync<AbTransportException>(() => limitedLease.Scheduler.SendAsync(ReadTag, TestCt));
|
||||
refused.Status.ShouldBe(AbStatus.Abort);
|
||||
refused.Message.ShouldContain("budget");
|
||||
Should.Throw<InvalidOperationException>(() => pool.Diagnostics.LimitOpenAttempts(limitedKey, 5)); // one at a time
|
||||
}
|
||||
|
||||
pool.Diagnostics.HasOpenAttemptLimit(limitedKey).ShouldBeFalse();
|
||||
time.Advance(TimeSpan.FromSeconds(5)); // the refused attempt still started the device's throttle interval
|
||||
await using var lifted = pool.Acquire(limitedKey, Options());
|
||||
(await lifted.Scheduler.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
limited.ForwardOpens.ShouldBe(1); // the refused attempt never reached the wire; the lifted one did
|
||||
other.ForwardOpens.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static DeviceKey Key(FakeCipTarget target) => new("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
[Fact]
|
||||
public async Task Connection_in_use_does_not_fall_back()
|
||||
{
|
||||
@@ -245,7 +276,7 @@ public sealed class CipConnectionTests
|
||||
};
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
var before = Volatile.Read(ref CipConnection.ForwardOpenCount);
|
||||
var before = conn.Diagnostics.ForwardOpens;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCt);
|
||||
var pending = conn.SendAsync(ReadTag, cts.Token);
|
||||
@@ -256,7 +287,7 @@ public sealed class CipConnectionTests
|
||||
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
(Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(1);
|
||||
(conn.Diagnostics.ForwardOpens - before).ShouldBe(1);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.Server.Connections.ShouldBe(1);
|
||||
target.ConnectedRequests.ShouldBe(2);
|
||||
@@ -321,7 +352,7 @@ public sealed class CipConnectionTests
|
||||
await using var target = new FakeCipTarget();
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
var before = Volatile.Read(ref CipConnection.RequestsSent);
|
||||
var before = conn.Diagnostics.RequestsSent;
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(8));
|
||||
@@ -336,7 +367,7 @@ public sealed class CipConnectionTests
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
conn.KeepAlivesSent.ShouldBe(2);
|
||||
await FakeCipTarget.Until(() => target.ConnectedRequests == 3, Guard, "the second keep-alive");
|
||||
(Volatile.Read(ref CipConnection.RequestsSent) - before).ShouldBe(3); // keep-alives count
|
||||
(conn.Diagnostics.RequestsSent - before).ShouldBe(3); // keep-alives count
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -368,6 +399,9 @@ public sealed class CipConnectionTests
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Positive control (#583): the keep-alive timer really ticked (at 8, 16 and 24 s) and re-armed itself, so
|
||||
// the zero below is the timer declining to send, not a timer that never ran.
|
||||
time.HasTimerDueAt(time.Start + TimeSpan.FromSeconds(32)).ShouldBeTrue();
|
||||
conn.KeepAlivesSent.ShouldBe(0);
|
||||
target.ForwardOpens.ShouldBe(0);
|
||||
target.Server.Connections.ShouldBe(0);
|
||||
@@ -411,4 +445,159 @@ public sealed class CipConnectionTests
|
||||
await dispose.WaitAsync(Guard, TestCt);
|
||||
target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_during_the_session_registration_never_puts_a_Forward_Open_on_the_wire()
|
||||
{
|
||||
// Gitea #566: disposal that begins while the TCP connect / RegisterSession is on the wire lets that step
|
||||
// finish (it is bounded), then releases the session instead of sending a Forward Open nobody would close.
|
||||
await using var target = new FakeCipTarget();
|
||||
var registerHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.Server.RegisterSessionGate = () => registerHeld.Task;
|
||||
var time = new ManualTimeProvider();
|
||||
var conn = Create(target, Options(), time);
|
||||
|
||||
var send = conn.SendAsync(ReadTag, TestCt);
|
||||
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.RegisterSession, Guard);
|
||||
var dispose = conn.DisposeAsync().AsTask();
|
||||
conn.OpenInProgress.ShouldBeTrue(); // disposal is waiting out the attempt on the wire...
|
||||
dispose.IsCompleted.ShouldBeFalse(); // ...rather than abandoning it
|
||||
registerHeld.SetResult();
|
||||
|
||||
(await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort);
|
||||
await dispose.WaitAsync(Guard, TestCt);
|
||||
// The session is released (positive control); the server records frames in arrival order, so a Forward
|
||||
// Open sent before the UnRegisterSession would already be counted.
|
||||
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
||||
target.ForwardOpens.ShouldBe(0);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
conn.Diagnostics.OpenAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
private const ushort AbandonedOpenRefusal = 0x7A11; // an extended status no other test uses
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_every_waiter_abandoned_leaves_no_unobserved_task_exception()
|
||||
{
|
||||
// Gitea #566: the shared open fails AFTER its only waiter gave up (its own token). Nothing awaits the
|
||||
// open's task then; without the observer it would surface as UnobservedTaskException at finalization.
|
||||
var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAFailingOpenAsync, Guard);
|
||||
|
||||
unobserved.ShouldNotContain(e => e.Message.Contains($"0x{AbandonedOpenRefusal:X4}"));
|
||||
}
|
||||
|
||||
// Kept out of the test body so no local in the test's own state machine roots the abandoned open.
|
||||
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||
private static async Task AbandonAFailingOpenAsync()
|
||||
{
|
||||
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, AbandonedOpenRefusal) };
|
||||
var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardOpenGate = () => openHeld.Task;
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(size: 504), time);
|
||||
using (var cts = new CancellationTokenSource())
|
||||
{
|
||||
var waiter = conn.SendAsync(ReadTag, cts.Token);
|
||||
await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open");
|
||||
await cts.CancelAsync();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => waiter);
|
||||
}
|
||||
openHeld.SetResult(); // the target refuses, after every waiter has gone
|
||||
await FakeCipTarget.Until(() => !conn.OpenInProgress, Guard, "the abandoned open to fail");
|
||||
}
|
||||
|
||||
// ---- CIP-level close (Gitea #566) ------------------------------------------------------------
|
||||
|
||||
private static byte[] Ok(byte[] cip) => [(byte)(cip[0] | 0x80), 0, 0, 0];
|
||||
|
||||
private static byte[] Status(byte[] cip, byte general, ushort extended) =>
|
||||
[(byte)(cip[0] | 0x80), 0, general, 1, (byte)extended, (byte)(extended >> 8)];
|
||||
|
||||
[Theory]
|
||||
[InlineData(ConnectionManagerServices.TargetConnectionNotFound)]
|
||||
[InlineData(ConnectionManagerServices.ConnectionTimedOut)]
|
||||
public async Task A_reply_saying_the_CIP_connection_is_gone_retires_it_and_the_next_send_reopens(ushort extended)
|
||||
{
|
||||
// The target dropped the class-3 connection but the TCP session is still up: every later connected request
|
||||
// would get the same answer with the session looking healthy, so the connection is recycled at once.
|
||||
await using var target = new FakeCipTarget();
|
||||
var gone = 0;
|
||||
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
|
||||
Interlocked.Exchange(ref gone, 0) == 1 ? Status(cip, 0x01, extended) : Ok(cip));
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
Volatile.Write(ref gone, 1);
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => conn.SendAsync(ReadTag, TestCt));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
ex.Message.ShouldContain($"0x{extended:X4}");
|
||||
conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(1);
|
||||
await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
||||
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close");
|
||||
target.ForwardCloses.ShouldBe(0); // the target just said there is nothing to close
|
||||
|
||||
var reopen = conn.SendAsync(ReadTag, TestCt);
|
||||
await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // through the throttle
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
CipReply.Parse(await reopen.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
target.Server.Connections.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_keepalive_told_the_connection_is_gone_recycles_it_before_any_request_fails()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(
|
||||
cip.SequenceEqual(CipConnection.KeepAliveRequest) ? Status(cip, 0x01, 0x0107) : Ok(cip));
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
await conn.SendAsync(ReadTag, TestCt);
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(8)); // idle: the keep-alive goes out and is told the connection is gone
|
||||
await FakeCipTarget.Until(() => conn.Diagnostics.ConnectionsClosedByTarget == 1, Guard, "the retirement");
|
||||
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "the session to close");
|
||||
|
||||
// The next request reopens (the throttle interval is long past) instead of failing on a dead connection.
|
||||
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt).WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_error_statuses_describe_the_request_and_leave_the_connection_alone()
|
||||
{
|
||||
// Negative control for the recycle: a tag-level error and a connection-failure status that is not about
|
||||
// this connection come back as ordinary replies, and the next request rides the same connection.
|
||||
await using var target = new FakeCipTarget();
|
||||
var replies = new Queue<Func<byte[], byte[]>>(
|
||||
[
|
||||
cip => Status(cip, 0x04, 0x0000), // path segment error: the tag
|
||||
cip => Status(cip, 0x01, 0x0204), // unconnected request timed out: not this connection
|
||||
Ok,
|
||||
]);
|
||||
target.ConnectedHandler = cip => Task.FromResult<byte[]?>(replies.Dequeue()(cip));
|
||||
var time = new ManualTimeProvider();
|
||||
await using var conn = Create(target, Options(), time);
|
||||
|
||||
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x04);
|
||||
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0x01);
|
||||
CipReply.Parse(await conn.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); // same connection
|
||||
|
||||
conn.Diagnostics.ConnectionsClosedByTarget.ShouldBe(0);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.Server.Connections.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_connection_failure_0107_and_0203_mean_the_connection_is_gone()
|
||||
{
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x07, 0x01]).ShouldBe((ushort)0x0107);
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x03, 0x02]).ShouldBe((ushort)0x0203);
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 1, 0x04, 0x02]).ShouldBeNull(); // 0x0204
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x01, 0]).ShouldBeNull(); // no extended word
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0x05, 1, 0x07, 0x01]).ShouldBeNull(); // not 0x01
|
||||
ConnectionManagerServices.IsConnectionGone([0xCC, 0, 0, 0]).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,23 @@ public sealed class CipPathTests
|
||||
public void Symbolic_rejects_bit_selector() =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic("Tag.5"));
|
||||
|
||||
/// <summary>Gitea #570: names that used to be encoded silently wrong (a non-ASCII name as '?', an over-long
|
||||
/// one with a wrapped length byte, an empty member as a zero-length segment) or that threw an unrelated
|
||||
/// exception (an unterminated subscript) are refused as invalid arguments before any I/O.</summary>
|
||||
[Theory]
|
||||
[InlineData("Tëmp")]
|
||||
[InlineData("A..B")]
|
||||
[InlineData("Arr[1")]
|
||||
public void Symbolic_rejects_names_it_cannot_encode_faithfully(string name) =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(name));
|
||||
|
||||
[Fact]
|
||||
public void Symbolic_rejects_a_member_longer_than_its_length_byte()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(new string('A', 256)));
|
||||
CipPath.EncodeSymbolic(new string('A', 255))[1].ShouldBe((byte)255); // the boundary still encodes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splits_bit_selector()
|
||||
{
|
||||
@@ -49,6 +66,65 @@ public sealed class CipPathTests
|
||||
public void Encodes_class_instance(ushort cls, uint inst, string hex) =>
|
||||
Convert.ToHexString(CipPath.EncodeClassInstance(cls, inst)).ShouldBe(hex);
|
||||
|
||||
[Theory]
|
||||
[InlineData("-1,0")] // negative port
|
||||
[InlineData("65536,0")] // port does not fit a u16
|
||||
[InlineData("x,0")] // non-numeric port
|
||||
[InlineData("1,é.example")] // non-ASCII link
|
||||
public void Malformed_route_throws_ArgumentException(string path) =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeRoute(path));
|
||||
|
||||
[Fact]
|
||||
public void Route_link_over_255_characters_throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeRoute("1," + new string('a', 256)));
|
||||
CipPath.EncodeRoute("1," + new string('a', 255)).Length.ShouldBe(2 + 255 + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_over_255_words_throws() =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeRoute(string.Join(",", Enumerable.Repeat("1,0", 256))));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("A..B")] // empty member
|
||||
[InlineData(".A")]
|
||||
[InlineData("A[1")] // unterminated subscript
|
||||
[InlineData("A[x]")] // non-numeric subscript
|
||||
[InlineData("A[-1]")]
|
||||
[InlineData("A[]")]
|
||||
[InlineData("A[1]B")] // junk after the subscript
|
||||
[InlineData("Tä")] // non-ASCII
|
||||
[InlineData("A[99999999999]")] // subscript overflows u32
|
||||
[InlineData("Tag.99999999999")] // bit selector overflows int
|
||||
public void Malformed_tag_names_throw_ArgumentException(string name) =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(name));
|
||||
|
||||
[Fact]
|
||||
public void Tag_member_over_255_characters_throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => CipPath.EncodeSymbolic(new string('a', 256)));
|
||||
CipPath.EncodeSymbolic(new string('a', 255)).Length.ShouldBe(2 + 255 + 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)] // odd
|
||||
[InlineData(3)]
|
||||
[InlineData(512)] // 256 words: size byte would wrap
|
||||
public void WithPathSize_rejects_odd_and_oversize_paths(int length) =>
|
||||
Should.Throw<ArgumentException>(() => CipPath.WithPathSize(new byte[length]));
|
||||
|
||||
[Fact]
|
||||
public void WithPathSize_accepts_the_255_word_maximum() =>
|
||||
CipPath.WithPathSize(new byte[510])[0].ShouldBe((byte)255);
|
||||
|
||||
[Fact]
|
||||
public void Well_known_paths_are_immutable_spans()
|
||||
{
|
||||
CipPath.MessageRouter.ToArray().ShouldBe(new byte[] { 0x20, 0x02, 0x24, 0x01 });
|
||||
CipPath.ConnectionManager.ToArray().ShouldBe(new byte[] { 0x20, 0x06, 0x24, 0x01 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefixes_path_size_in_words() =>
|
||||
Convert.ToHexString(CipPath.WithPathSize([0x20, 0x02, 0x24, 0x01])).ShouldBe("0220022401");
|
||||
|
||||
+19
@@ -105,6 +105,25 @@ public sealed class ConnectionManagerServicesTests
|
||||
ex.Message.ShouldContain("0x0109");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refused_forward_open_carries_the_statuses_as_structured_data()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("DB000102" + "09010203"));
|
||||
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
||||
ex.GeneralStatus.ShouldBe((byte)0x01);
|
||||
ex.ExtendedStatus.ShouldBe(new ushort[] { 0x0109, 0x0302 });
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
|
||||
{
|
||||
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
|
||||
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
|
||||
reply.ExtendedStatus[0] = 0xFFFF;
|
||||
ex.ExtendedStatus[0].ShouldBe((ushort)0x0109);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DB000800", true)] // Service Not Supported
|
||||
[InlineData("DB0001010901", true)] // general 1 / extended 0x0109
|
||||
|
||||
+8
-4
@@ -69,7 +69,8 @@ public sealed class ForwardOpenThrottleTests
|
||||
var first = throttle.RunAsync(Attempt, TestCt);
|
||||
time.Advance(TimeSpan.FromSeconds(5)); // the interval is no obstacle; only the in-flight one is
|
||||
var others = Enumerable.Range(0, 4).Select(_ => throttle.RunAsync(Attempt, TestCt)).ToArray();
|
||||
await Task.Delay(50, TestCt);
|
||||
// Positive control (#583): all four have reached the throttle and are parked behind the in-flight attempt.
|
||||
await FakeCipTarget.Until(() => throttle.Queued == 4, Guard, "four callers queued behind the first");
|
||||
Volatile.Read(ref inFlight).ShouldBe(1);
|
||||
others.ShouldAllBe(t => !t.IsCompleted);
|
||||
|
||||
@@ -131,8 +132,8 @@ public sealed class ForwardOpenThrottleTests
|
||||
await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) };
|
||||
var time = new ManualTimeProvider();
|
||||
var options = new ConnectionOptions { ConnectionSize = 504, Timeout = TimeSpan.FromSeconds(2) };
|
||||
var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options,
|
||||
new ForwardOpenThrottle(options.MinForwardOpenInterval, time), time);
|
||||
var throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, time);
|
||||
var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options, throttle, time);
|
||||
using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestCt);
|
||||
var failures = 0;
|
||||
|
||||
@@ -151,7 +152,10 @@ public sealed class ForwardOpenThrottleTests
|
||||
await Task.Delay(10, TestCt);
|
||||
time.Advance(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
await Task.Delay(100, TestCt); // let any attempt released by the last step reach the wire
|
||||
// Positive control (#583), instead of a fixed settle: wait until the next attempt is parked on the interval.
|
||||
// Then no attempt is in flight and none can start until the clock moves, so every attempt the last step
|
||||
// released has already reached the wire and been counted.
|
||||
await FakeCipTarget.Until(() => throttle.IsWaitingOutInterval, Guard, "the next attempt to park on the interval");
|
||||
|
||||
await stop.CancelAsync();
|
||||
await Task.WhenAll(callers).WaitAsync(Guard, TestCt);
|
||||
|
||||
+44
@@ -437,4 +437,48 @@ public sealed class RequestSchedulerTests
|
||||
ch.Sent[1][^1].ShouldBe((byte)2);
|
||||
FakeCipChannel.SplitMsp(ch.Sent[2]).Select(e => e[^1]).ShouldBe(new byte[] { 3, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget()
|
||||
{
|
||||
// Every request is 8 bytes, so the REQUEST budget would pack all of them into one MSP; only the
|
||||
// expected-reply budget (#582) splits the batch. Reply envelope = 6 + 2n + sum(replies) <= 504.
|
||||
const int scalar = 8, str = 96, array = 200;
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 504);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
|
||||
var sizes = new[] { scalar, str, str, str, str, array, scalar, RequestScheduler.UnknownReply };
|
||||
var tasks = sizes.Select((size, i) => s.SendAsync(Req(8, (byte)(10 + i)), Ct, expectedReplyBytes: size)).ToArray();
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(tasks.Append(a)).WaitAsync(Timeout, Ct);
|
||||
|
||||
// [sc, S, S, S, S] = 6+10+392 = 408; the array (+200) would make 608 > 504, so it starts batch 2
|
||||
// with the trailing scalar; the unknown-size read goes alone.
|
||||
ch.Sent.Count.ShouldBe(4);
|
||||
FakeCipChannel.SplitMsp(ch.Sent[1]).Select(e => e[^1]).ShouldBe(new byte[] { 10, 11, 12, 13, 14 });
|
||||
FakeCipChannel.SplitMsp(ch.Sent[2]).Select(e => e[^1]).ShouldBe(new byte[] { 15, 16 });
|
||||
IsMsp(ch.Sent[3]).ShouldBeFalse();
|
||||
ch.Sent[3][^1].ShouldBe((byte)17);
|
||||
ReplyEnvelopeFor(scalar, str, str, str, str).ShouldBeLessThanOrEqualTo(504);
|
||||
ReplyEnvelopeFor(scalar, str, str, str, str, array).ShouldBeGreaterThan(504);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_only_replies_still_pack_to_the_request_limit()
|
||||
{
|
||||
// Positive control for the reply budget: the default (status-only) reply never splits a batch
|
||||
// the request budget allows, so existing write / probe coalescing is unchanged.
|
||||
var ch = new FakeCipChannel(maxRequestBytes: 504);
|
||||
await using var s = new RequestScheduler(ch, allowPacking: true);
|
||||
var a = await HoldFirstAsync(s, ch, Req(8, 1));
|
||||
var rest = Enumerable.Range(0, 20).Select(i => s.SendAsync(Req(8, (byte)(2 + i)), Ct)).ToArray();
|
||||
ch.Gate!.SetResult();
|
||||
await Task.WhenAll(rest.Append(a)).WaitAsync(Timeout, Ct);
|
||||
|
||||
ch.Sent.Count.ShouldBe(2);
|
||||
FakeCipChannel.SplitMsp(ch.Sent[1]).Count.ShouldBe(20);
|
||||
}
|
||||
|
||||
private static int ReplyEnvelopeFor(params int[] replies) => RequestScheduler.ReplyEnvelopeSize(replies);
|
||||
}
|
||||
|
||||
+106
-1
@@ -203,7 +203,9 @@ public sealed class DeviceSessionPoolTests
|
||||
|
||||
await using var c = pool.Acquire(key, Options());
|
||||
var cSend = c.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await Task.Delay(300, TestCt);
|
||||
// Positive control (#583): C's open has reached the predecessor wait and parked there, so the absence of
|
||||
// its Forward Open below is the wait holding it, not C merely not having got that far yet.
|
||||
await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "C to park behind A's close");
|
||||
target.ForwardOpens.ShouldBe(1); // C is held back behind A
|
||||
cSend.IsCompleted.ShouldBeFalse();
|
||||
|
||||
@@ -267,6 +269,109 @@ public sealed class DeviceSessionPoolTests
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #579: <see cref="ConnectionLease.ReleaseInBackground"/> returns before the last release's Forward
|
||||
/// Close completes, yet a successor acquired that same instant still cannot open until the close is over —
|
||||
/// the closing chain is registered synchronously, inside the call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Release_in_background_returns_before_the_close_and_a_successor_still_waits_for_it()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardCloseGate = () => closeHeld.Task;
|
||||
var time = new ManualTimeProvider();
|
||||
var pool = new DeviceSessionPool(time);
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
var a = pool.Acquire(key, Options());
|
||||
await a.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
var teardown = a.ReleaseInBackground();
|
||||
pool.EntryCount.ShouldBe(0); // bookkeeping done before it returned
|
||||
await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "the Forward Close");
|
||||
teardown.IsCompleted.ShouldBeFalse(); // ...but the close is still pending
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle
|
||||
await using var b = pool.Acquire(key, Options());
|
||||
var bSend = b.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "B to park behind A's close");
|
||||
target.ForwardOpens.ShouldBe(1); // held behind A's close
|
||||
bSend.IsCompleted.ShouldBeFalse();
|
||||
|
||||
closeHeld.SetResult();
|
||||
await teardown.WaitAsync(Guard, TestCt);
|
||||
(await bSend.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
await a.ReleaseInBackground().WaitAsync(Guard, TestCt); // idempotent: b's reference is untouched
|
||||
pool.EntryCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #566: releasing a lease while the device's shared open is still on the wire does not wait for that
|
||||
/// open (up to its whole open budget) — <see cref="ConnectionLease.ReleaseInBackground"/> (#579) returns at
|
||||
/// once, and the teardown it hands back still Forward Closes what the open produced once it lands.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Release_in_background_does_not_wait_behind_an_in_flight_shared_open()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardOpenGate = () => openHeld.Task;
|
||||
var pool = new DeviceSessionPool(new ManualTimeProvider());
|
||||
var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix");
|
||||
|
||||
var lease = pool.Acquire(key, Options());
|
||||
var send = lease.Scheduler.SendAsync(ReadTag, TestCt);
|
||||
await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open");
|
||||
((CipConnection)lease.Channel).OpenInProgress.ShouldBeTrue();
|
||||
|
||||
var teardown = lease.ReleaseInBackground(); // returns synchronously: the open is still held
|
||||
|
||||
pool.EntryCount.ShouldBe(0);
|
||||
teardown.IsCompleted.ShouldBeFalse("the teardown waits for the open on the wire; the release call did not");
|
||||
(await Should.ThrowAsync<AbTransportException>(() => send.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.BadConnection);
|
||||
|
||||
openHeld.SetResult();
|
||||
await teardown.WaitAsync(Guard, TestCt);
|
||||
target.ForwardCloses.ShouldBe(1); // what the late-accepted open created is still closed
|
||||
}
|
||||
|
||||
/// <summary>Gitea #566: the close path disposes the connection even when the scheduler's dispose throws —
|
||||
/// otherwise the connection's keep-alive would hold the press connection open forever.</summary>
|
||||
[Fact]
|
||||
public async Task Close_disposes_the_connection_even_when_the_scheduler_dispose_throws()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var scheduler = new Disposable(() => throw new InvalidOperationException("scheduler dispose failed"));
|
||||
var connection = new Disposable(() => { });
|
||||
var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix");
|
||||
|
||||
await DeviceSessionPool.CloseAsync(key, scheduler, connection, logger);
|
||||
|
||||
scheduler.Disposed.ShouldBe(1);
|
||||
connection.Disposed.ShouldBe(1);
|
||||
logger.Warnings.ShouldBe(1);
|
||||
|
||||
// And a throwing connection dispose is logged, never propagated to the release.
|
||||
var failing = new Disposable(() => throw new InvalidOperationException("close failed"));
|
||||
await DeviceSessionPool.CloseAsync(key, new Disposable(() => { }), failing, logger);
|
||||
failing.Disposed.ShouldBe(1);
|
||||
logger.Warnings.ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class Disposable(Action onDispose) : IAsyncDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
public int Disposed => Volatile.Read(ref _disposed);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Interlocked.Increment(ref _disposed);
|
||||
onDispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_is_one_process_wide_instance() =>
|
||||
DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared);
|
||||
|
||||
+108
-26
@@ -151,13 +151,15 @@ public sealed class EncapsulationClientTests
|
||||
|
||||
using var callerCts = new CancellationTokenSource();
|
||||
var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(300), callerCts.Token);
|
||||
await server.WaitForFrameAsync(IsRR, Guard);
|
||||
// Both calls are issued back-to-back, before any await: `first` acquired the gate synchronously
|
||||
// and `next` is therefore already queued behind it before the cancel — the ordering does not
|
||||
// depend on how long the test thread takes to get around to it.
|
||||
var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
|
||||
await callerCts.CancelAsync();
|
||||
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
||||
client.IsConnected.ShouldBeTrue(); // cancel alone never faults
|
||||
await server.WaitForFrameAsync(IsRR, Guard); // the abandoned request was on the wire
|
||||
|
||||
// Queued behind the draining exchange: must fail fast once that exchange times out, not hang.
|
||||
var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(next.WaitAsync(Guard, TestCt));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
@@ -168,31 +170,111 @@ public sealed class EncapsulationClientTests
|
||||
.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterSession_reply_matches_only_through_the_zero_context_tolerance()
|
||||
{
|
||||
// Documents a dependency: RegisterSession goes out with sender context 0 (ab_server refuses
|
||||
// non-zero) so its reply echoes 0, while the exchange's own context is a non-zero counter value.
|
||||
// The only thing that lets the reply match is TryMatch's "reply context == 0" rule; tighten
|
||||
// that rule and every Connect breaks, not just the zero-context-server case.
|
||||
await using var server = new FakeEipServer();
|
||||
await using var client = await Connect(server);
|
||||
|
||||
var request = EncapsulationCodec.ReadHeader(server.Received[0]);
|
||||
request.Command.ShouldBe(EncapCommand.RegisterSession);
|
||||
request.SenderContext.ShouldBe(0UL);
|
||||
client.SessionHandle.ShouldBe(FakeEipServer.Session);
|
||||
client.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public async Task Non_positive_timeouts_are_rejected_everywhere(int milliseconds)
|
||||
{
|
||||
await using var server = new FakeEipServer();
|
||||
var bad = TimeSpan.FromMilliseconds(milliseconds);
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
||||
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, bad, TestCt));
|
||||
await using var client = await Connect(server);
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, bad, TestCt));
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, bad, TestCt));
|
||||
client.IsConnected.ShouldBeTrue(); // a bad argument is not a comms fault
|
||||
server.Received.Count(IsRR).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Infinite_timeouts_are_rejected_everywhere()
|
||||
{
|
||||
await using var server = new FakeEipServer();
|
||||
var inf = Timeout.InfiniteTimeSpan;
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
||||
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, inf, TestCt));
|
||||
await using var client = await Connect(server);
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, inf, TestCt));
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, inf, TestCt));
|
||||
client.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiting_for_the_gate_is_bounded_by_the_timeout_and_leaves_the_connection_intact()
|
||||
{
|
||||
await using var server = new FakeEipServer { Stall = true };
|
||||
var client = await Connect(server);
|
||||
|
||||
var holder = client.SendRRDataAsync(new byte[] { 0x01 }, Generous, TestCt); // holds the gate: no reply comes
|
||||
await server.WaitForFrameAsync(IsRR, Guard);
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(
|
||||
client.SendRRDataAsync(new byte[] { 0x02 }, TimeSpan.FromMilliseconds(150), TestCt).WaitAsync(Guard, TestCt));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
client.IsConnected.ShouldBeTrue(); // the waiter wrote nothing, so nothing desynced
|
||||
holder.IsCompleted.ShouldBeFalse(); // positive control: the holder is still waiting on its reply
|
||||
server.Received.Count(IsRR).ShouldBe(1);
|
||||
|
||||
await client.DisposeAsync(); // closes the socket under the holder
|
||||
(await Should.ThrowAsync<AbTransportException>(holder.WaitAsync(Guard, TestCt)))
|
||||
.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // drain outlives the unregister budget: the socket is closed under it, nothing is written
|
||||
[InlineData(true)] // drain times out while Dispose waits for the gate: the session is faulted, nothing is written
|
||||
public async Task Dispose_during_a_cancelled_reply_drain_closes_without_interleaving_an_UnRegisterSession(bool drainTimesOutFirst)
|
||||
{
|
||||
await using var server = new FakeEipServer { Stall = true };
|
||||
var client = await Connect(server);
|
||||
|
||||
using var callerCts = new CancellationTokenSource();
|
||||
var first = client.SendRRDataAsync(new byte[] { 0x01 },
|
||||
drainTimesOutFirst ? TimeSpan.FromMilliseconds(100) : Generous, callerCts.Token);
|
||||
await server.WaitForFrameAsync(IsRR, Guard);
|
||||
await callerCts.CancelAsync();
|
||||
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
||||
|
||||
await client.DisposeAsync().AsTask().WaitAsync(Guard, TestCt); // must not hang on the drain
|
||||
|
||||
client.IsConnected.ShouldBeFalse();
|
||||
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x03 }, Generous, TestCt)))
|
||||
.Status.ShouldBe(AbStatus.BadConnection);
|
||||
|
||||
// Absence needs a positive control: a second client's clean dispose MUST put an UnRegisterSession
|
||||
// on the wire, so by the time it arrives the first client's (had there been one) would have too.
|
||||
var second = await Connect(server);
|
||||
await second.DisposeAsync();
|
||||
await server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
||||
server.Received.Count(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession).ShouldBe(1);
|
||||
server.Received.Count(IsRR).ShouldBe(1); // and the queued/abandoned work never doubled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_abandoned_exchange_that_times_out_leaves_no_unobserved_task_exception()
|
||||
{
|
||||
var unobserved = new List<Exception>();
|
||||
EventHandler<UnobservedTaskExceptionEventArgs> onUnobserved = (_, e) =>
|
||||
{
|
||||
if (e.Exception.Flatten().InnerExceptions.Any(x => x.Message.Contains("within 317 ms")))
|
||||
lock (unobserved) unobserved.Add(e.Exception);
|
||||
};
|
||||
TaskScheduler.UnobservedTaskException += onUnobserved;
|
||||
try
|
||||
{
|
||||
await AbandonAnExchangeThatTimesOut();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
await Task.Delay(20, TestCt);
|
||||
}
|
||||
lock (unobserved) unobserved.ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
TaskScheduler.UnobservedTaskException -= onUnobserved;
|
||||
}
|
||||
// Gitea #583: collected against a positive control (a deliberately unobserved fault that MUST be reported),
|
||||
// not after a fixed number of GC cycles.
|
||||
var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAnExchangeThatTimesOut, Guard);
|
||||
unobserved.ShouldNotContain(x => x.Message.Contains("within 317 ms"));
|
||||
}
|
||||
|
||||
// Kept out of the test body so no local in the test's own state machine roots the abandoned task.
|
||||
@@ -228,7 +310,7 @@ public sealed class EncapsulationClientTests
|
||||
var units = server.Received.Where(f => FakeEipServer.Command(f) == EncapCommand.SendUnitData).ToList();
|
||||
var sequences = units.Select(FakeEipServer.RequestSequence).ToList();
|
||||
sequences.ShouldBe(Enumerable.Range(1, 20).Select(i => (ushort)i).ToList()); // strictly increasing, no dupes
|
||||
units.Select(f => BitConverter.ToUInt32(f, 36)).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id
|
||||
units.Select(f => BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(36))).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id
|
||||
for (var i = 0; i < 20; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) }); // each got its own reply
|
||||
server.MaxOutstanding.ShouldBe(1);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
@@ -72,7 +73,7 @@ public sealed class EncapsulationCodecTests
|
||||
var captured = CaptureFile.Load("logix-read-dint.hex").First(f => f.FromClient && f.Bytes[0] == 0x70).Bytes;
|
||||
var h = EncapsulationCodec.ReadHeader(captured);
|
||||
var parsed = EncapsulationCodec.ParseReply(captured, expectConnected: true);
|
||||
var connId = BitConverter.ToUInt32(captured, 24 + 8 + 4); // header, iface+timeout+count, A1 type+len
|
||||
var connId = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(24 + 8 + 4)); // header, iface+timeout+count, A1 type+len
|
||||
var ours = EncapsulationCodec.SendUnitData(h.SessionHandle, h.SenderContext, connId, parsed.Sequence, parsed.Cip.Span);
|
||||
|
||||
ours.Length.ShouldBe(captured.Length);
|
||||
|
||||
@@ -29,6 +29,7 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
private readonly ConcurrentBag<TcpClient> _clients = [];
|
||||
private readonly ConcurrentBag<Exception> _handlerErrors = [];
|
||||
private int _connections;
|
||||
private int _open;
|
||||
private int _outstanding;
|
||||
private int _maxOutstanding;
|
||||
|
||||
@@ -45,6 +46,9 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
|
||||
public int Connections => Volatile.Read(ref _connections);
|
||||
|
||||
/// <summary>TCP connections accepted and not yet closed by the client (Gitea #579 leak checks).</summary>
|
||||
public int OpenConnections => Volatile.Read(ref _open);
|
||||
|
||||
/// <summary>Largest number of non-session requests ever received but not yet answered.</summary>
|
||||
public int MaxOutstanding => Volatile.Read(ref _maxOutstanding);
|
||||
|
||||
@@ -57,6 +61,9 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
/// <summary>Synchronous convenience setter for <see cref="HandlerAsync"/>.</summary>
|
||||
public Func<byte[], byte[]?> Handler { set => HandlerAsync = req => Task.FromResult(value(req)); }
|
||||
|
||||
/// <summary>Awaited before a RegisterSession is answered (the request is already recorded).</summary>
|
||||
public Func<Task>? RegisterSessionGate { get; set; }
|
||||
|
||||
/// <summary>Never answer non-session requests.</summary>
|
||||
public bool Stall { get; set; }
|
||||
|
||||
@@ -108,12 +115,19 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
try { c = await _listener.AcceptTcpClientAsync(_cts.Token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _connections);
|
||||
Interlocked.Increment(ref _open);
|
||||
_clients.Add(c);
|
||||
_ = Serve(c);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Serve(TcpClient c)
|
||||
{
|
||||
try { await ServeCore(c); }
|
||||
finally { Interlocked.Decrement(ref _open); }
|
||||
}
|
||||
|
||||
private async Task ServeCore(TcpClient c)
|
||||
{
|
||||
using var _ = c;
|
||||
var s = c.GetStream();
|
||||
@@ -149,6 +163,7 @@ internal sealed class FakeEipServer : IAsyncDisposable
|
||||
switch (Command(frame))
|
||||
{
|
||||
case EncapCommand.RegisterSession:
|
||||
if (RegisterSessionGate is { } gate) await gate();
|
||||
reply = (byte[])frame.Clone();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(reply.AsSpan(4), Session);
|
||||
break;
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="TaskScheduler.UnobservedTaskException"/>s raised after some code abandons work, with a
|
||||
/// positive control (Gitea #583): after the abandon, a task is deliberately faulted and dropped, and garbage is
|
||||
/// collected until THAT task's exception has been reported. Only then is the collection returned — so "nothing
|
||||
/// of ours was reported" means the finalizer pass that would have reported it really ran, not merely that a
|
||||
/// fixed number of GC cycles went by before the runtime got round to it.
|
||||
/// </summary>
|
||||
internal static class UnobservedTaskProbe
|
||||
{
|
||||
public static async Task<IReadOnlyList<Exception>> CollectAsync(Func<Task> abandon, TimeSpan timeout)
|
||||
{
|
||||
var marker = $"unobserved-task-probe-control-{Guid.NewGuid():N}";
|
||||
var seen = new ConcurrentQueue<Exception>();
|
||||
var controlReported = 0;
|
||||
EventHandler<UnobservedTaskExceptionEventArgs> handler = (_, e) =>
|
||||
{
|
||||
foreach (var ex in e.Exception.Flatten().InnerExceptions)
|
||||
{
|
||||
if (ex.Message == marker) Volatile.Write(ref controlReported, 1);
|
||||
else seen.Enqueue(ex);
|
||||
}
|
||||
};
|
||||
TaskScheduler.UnobservedTaskException += handler;
|
||||
try
|
||||
{
|
||||
await abandon();
|
||||
AbandonControl(marker);
|
||||
var giveUp = DateTime.UtcNow + timeout;
|
||||
while (Volatile.Read(ref controlReported) == 0)
|
||||
{
|
||||
if (DateTime.UtcNow > giveUp)
|
||||
throw new TimeoutException("The positive control's unobserved exception was never reported.");
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
await Task.Delay(10);
|
||||
}
|
||||
// One more full pass, so anything that became garbage in the same cycle as the control is reported too.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
finally
|
||||
{
|
||||
TaskScheduler.UnobservedTaskException -= handler;
|
||||
}
|
||||
return [.. seen];
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void AbandonControl(string marker) => _ = Task.FromException(new InvalidOperationException(marker));
|
||||
}
|
||||
+207
-9
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -10,33 +11,230 @@ public sealed class LibplctagConfinementGuardTests
|
||||
@"\busing\s+(static\s+)?(\w+\s*=\s*)?libplctag\b|\blibplctag\.[A-Z]",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
[Fact]
|
||||
public void Only_the_libplctag_adapters_reference_libplctag()
|
||||
// Directories whose Libplctag*.cs files may reference libplctag. LibplctagShared is the project-less home of
|
||||
// sources compiled into BOTH drivers (#580).
|
||||
private static readonly string[] AllowedSourceDirs =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbCip",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared",
|
||||
];
|
||||
|
||||
// The only projects that legitimately carry the libplctag package today (#569).
|
||||
private static readonly string[] AllowedPackageProjects =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj",
|
||||
];
|
||||
|
||||
private static readonly Regex PackageRef = new(
|
||||
@"<PackageReference\b[^>]*\b(Include|Update)\s*=\s*[""']libplctag(\.[\w.]*)?[""']",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static DirectoryInfo RepoRoot()
|
||||
{
|
||||
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent;
|
||||
root.ShouldNotBeNull("could not find the repo root");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool IsBuildOutput(string file) =>
|
||||
file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}");
|
||||
|
||||
/// <summary>
|
||||
/// Reduces C# source to its code: comments are dropped and string / char literal CONTENT is emptied
|
||||
/// (newlines are kept, so line numbers hold). Strings are tokenised properly rather than split on
|
||||
/// <c>//</c> — a URL literal used to truncate the line and hide a reference after it, and a
|
||||
/// <c>/* */</c> block comment was never stripped (#569).
|
||||
/// </summary>
|
||||
/// <param name="text">The C# source.</param>
|
||||
/// <returns>The source with only code left.</returns>
|
||||
internal static string StripCommentsAndLiterals(string text)
|
||||
{
|
||||
var sb = new StringBuilder(text.Length);
|
||||
var i = 0;
|
||||
while (i < text.Length)
|
||||
{
|
||||
var c = text[i];
|
||||
var next = i + 1 < text.Length ? text[i + 1] : '\0';
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
while (i < text.Length && text[i] != '\n') i++;
|
||||
}
|
||||
else if (c == '/' && next == '*')
|
||||
{
|
||||
i += 2;
|
||||
while (i < text.Length && !(text[i] == '*' && i + 1 < text.Length && text[i + 1] == '/'))
|
||||
{
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
i = Math.Min(i + 2, text.Length);
|
||||
}
|
||||
else if (c == '"' && next == '"' && i + 2 < text.Length && text[i + 2] == '"')
|
||||
{
|
||||
// Raw string literal: a run of >= 3 quotes opens it and closes on a run of at least that length.
|
||||
var open = 0;
|
||||
while (i + open < text.Length && text[i + open] == '"') open++;
|
||||
i += open;
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (text[i] == '"')
|
||||
{
|
||||
var run = 0;
|
||||
while (i + run < text.Length && text[i + run] == '"') run++;
|
||||
i += run;
|
||||
if (run >= open) break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
}
|
||||
sb.Append("\"\"");
|
||||
}
|
||||
else if (c == '"' || (c is '@' or '$' && IsStringPrefix(text, i)))
|
||||
{
|
||||
var verbatim = false;
|
||||
while (text[i] != '"')
|
||||
{
|
||||
if (text[i] == '@') verbatim = true;
|
||||
i++;
|
||||
}
|
||||
i++; // opening quote
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (verbatim && text[i] == '"' && i + 1 < text.Length && text[i + 1] == '"') { i += 2; continue; }
|
||||
if (!verbatim && text[i] == '\\') { i += 2; continue; }
|
||||
if (text[i] == '"') { i++; break; }
|
||||
if (text[i] == '\n') sb.Append('\n');
|
||||
i++;
|
||||
}
|
||||
sb.Append("\"\"");
|
||||
}
|
||||
else if (c == '\'')
|
||||
{
|
||||
i++;
|
||||
while (i < text.Length && text[i] != '\'')
|
||||
{
|
||||
if (text[i] == '\\') i++;
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
sb.Append("''");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// @"..", $"..", $@"..", @$".." — a run of prefix characters followed by a quote.
|
||||
private static bool IsStringPrefix(string text, int i)
|
||||
{
|
||||
var j = i;
|
||||
while (j < text.Length && text[j] is '@' or '$') j++;
|
||||
return j < text.Length && text[j] == '"';
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_libplctag_adapters_reference_libplctag()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
|
||||
var offenders = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||||
file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue;
|
||||
if (IsBuildOutput(file)) continue;
|
||||
var name = Path.GetFileName(file);
|
||||
var dir = Path.GetFileName(Path.GetDirectoryName(file)!);
|
||||
var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal)
|
||||
&& dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy";
|
||||
var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal) && AllowedSourceDirs.Contains(dir);
|
||||
if (allowed) continue;
|
||||
var lines = File.ReadAllLines(file);
|
||||
var raw = File.ReadAllLines(file);
|
||||
var lines = StripCommentsAndLiterals(File.ReadAllText(file)).Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var code = lines[i].Split("//")[0];
|
||||
if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}");
|
||||
if (Reference.IsMatch(lines[i]))
|
||||
offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {(i < raw.Length ? raw[i].Trim() : lines[i].Trim())}");
|
||||
}
|
||||
}
|
||||
offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders));
|
||||
}
|
||||
|
||||
/// <summary>#569: the libplctag PACKAGE may be referenced only by the two drivers that still carry the adapters.</summary>
|
||||
[Fact]
|
||||
public void Only_the_two_AB_driver_projects_reference_the_libplctag_package()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
var offenders = new List<string>();
|
||||
foreach (var top in new[] { "src", "tests" })
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, top), "*.csproj", SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsBuildOutput(file)) continue;
|
||||
if (AllowedPackageProjects.Contains(Path.GetFileName(file))) continue;
|
||||
if (PackageRef.IsMatch(File.ReadAllText(file))) offenders.Add(Path.GetRelativePath(root.FullName, file));
|
||||
}
|
||||
}
|
||||
offenders.ShouldBeEmpty("only the AbCip / AbLegacy driver projects may reference the libplctag package:" +
|
||||
Environment.NewLine + string.Join(Environment.NewLine, offenders));
|
||||
}
|
||||
|
||||
/// <summary>The csproj regex matches the shapes a libplctag package reference can take (positive control).</summary>
|
||||
/// <param name="xml">A package reference element.</param>
|
||||
[Theory]
|
||||
[InlineData("<PackageReference Include=\"libplctag\"/>")]
|
||||
[InlineData("<PackageReference Include=\"libplctag\" Version=\"1.5.2\" />")]
|
||||
[InlineData("<PackageReference Update='libplctag'/>")]
|
||||
[InlineData("<PackageReference Include=\"libplctag.NativeImport\"/>")]
|
||||
public void Csproj_package_references_trip_the_guard(string xml) => PackageRef.IsMatch(xml).ShouldBeTrue(xml);
|
||||
|
||||
/// <summary>An unrelated package that merely starts with the same letters does not trip it.</summary>
|
||||
[Fact]
|
||||
public void Unrelated_packages_do_not_trip_the_csproj_guard() =>
|
||||
PackageRef.IsMatch("<PackageReference Include=\"libplctagger\"/>").ShouldBeFalse();
|
||||
|
||||
/// <summary>A reference AFTER a string literal containing <c>//</c> is still seen (#569 item 1).</summary>
|
||||
[Fact]
|
||||
public void A_reference_after_a_url_literal_is_not_hidden()
|
||||
{
|
||||
var code = StripCommentsAndLiterals("var u = \"http://example\"; var t = new libplctag.Tag();");
|
||||
Reference.IsMatch(code).ShouldBeTrue(code);
|
||||
}
|
||||
|
||||
/// <summary>A libplctag mention inside a string literal is text, not a reference.</summary>
|
||||
[Fact]
|
||||
public void A_reference_inside_a_string_literal_is_ignored()
|
||||
{
|
||||
Reference.IsMatch(StripCommentsAndLiterals("var s = \"using libplctag;\";")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("var s = @\"a \"\"quoted\"\" libplctag.Tag\";")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Block comments are stripped; code after them on the same line is kept (#569 item 2).</summary>
|
||||
[Fact]
|
||||
public void Block_comments_are_stripped_but_code_after_them_is_not()
|
||||
{
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* using libplctag; */ var x = 1;")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* a\n using libplctag;\n b */ var x = 1;")).ShouldBeFalse();
|
||||
Reference.IsMatch(StripCommentsAndLiterals("/* c */ var t = new libplctag.Tag();")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Stripping keeps the line count, so offender line numbers stay accurate.</summary>
|
||||
[Fact]
|
||||
public void Stripping_preserves_line_numbers()
|
||||
{
|
||||
var src = "a\n/* x\ny */\nvar s = @\"p\nq\";\nusing libplctag;\n";
|
||||
var stripped = StripCommentsAndLiterals(src).Split('\n');
|
||||
stripped.Length.ShouldBe(src.Split('\n').Length);
|
||||
Reference.IsMatch(stripped[5]).ShouldBeTrue(string.Join("|", stripped));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prose_mentions_of_libplctag_do_not_trip_the_guard()
|
||||
{
|
||||
|
||||
+25
@@ -279,4 +279,29 @@ public sealed class LogixTagClientTests
|
||||
() => client.WriteAsync(Path, LogixTypeCode.DInt, null, 40, Bytes(160, 0), Ct));
|
||||
ch.Sent.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_struct_handle_that_changes_between_fragments_fails_the_read()
|
||||
{
|
||||
var (ch, client, s) = Create();
|
||||
await using var _ = s;
|
||||
ch.RespondPerService = r => ReadOffset(r) == 0
|
||||
? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.Struct, Bytes(8, 0), handle: 0x0FCE))
|
||||
: FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.Struct, Bytes(4, 8), handle: 0x1234));
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => client.ReadAsync(Path, 1, Ct));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.BadReply);
|
||||
ex.Message.ShouldContain("0x0FCE");
|
||||
ex.Message.ShouldContain("0x1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expected_read_reply_bytes_budgets_elements_times_width_and_is_unknown_without_a_width()
|
||||
{
|
||||
// header 4 + type/handle 4 + 3 STRING elements.
|
||||
LogixTagClient.ExpectedReadReplyBytes(3, LogixTypeCode.StringElementBytes, 0).ShouldBe(4 + 4 + 3 * 88);
|
||||
LogixTagClient.ExpectedReadReplyBytes(3, LogixTypeCode.StringElementBytes, 100).ShouldBe(4 + 4 + 3 * 88 - 100);
|
||||
LogixTagClient.ExpectedReadReplyBytes(3, 0, 0).ShouldBe(RequestScheduler.UnknownReply);
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -56,6 +56,17 @@ public sealed class SymbolObjectServicesTests
|
||||
more.ShouldBeTrue(); last.ShouldBe(5u); entries.ShouldBe(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxInstance_is_the_highest_in_the_page_whatever_its_order()
|
||||
{
|
||||
static string Entry(uint i) => BitConverter.ToString(BitConverter.GetBytes(i)).Replace("-", "")
|
||||
+ "C400" + "0400" + "00000000" + "00000000" + "00000000" + "0100" + "41";
|
||||
var entries = Convert.FromHexString(Entry(9) + Entry(3) + Entry(5));
|
||||
var (_, max, more) = SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x06, [], entries));
|
||||
more.ShouldBeTrue();
|
||||
max.ShouldBe(9u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() =>
|
||||
|
||||
@@ -34,6 +34,25 @@ public sealed class CipReplyTests
|
||||
r.Status.ShouldBe(AbStatus.OutOfBounds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replies_parsed_from_the_same_bytes_are_equal()
|
||||
{
|
||||
byte[] raw = [0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00, 0xAA];
|
||||
// Distinct arrays on both sides: reference equality (the record default) would say unequal.
|
||||
var a = CipReply.Parse(raw.ToArray());
|
||||
var b = CipReply.Parse(raw.ToArray());
|
||||
a.ShouldBe(b);
|
||||
a.GetHashCode().ShouldBe(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replies_differing_in_extended_status_or_data_are_not_equal()
|
||||
{
|
||||
var a = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x07, 0x21, 0x01 });
|
||||
a.ShouldNotBe(CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x05, 0x21, 0x01 }));
|
||||
a.ShouldNotBe(CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x07, 0x21, 0x02 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_reply_throws_BadReply()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire;
|
||||
|
||||
public sealed class CipWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void PatchU16_overwrites_an_already_written_word()
|
||||
{
|
||||
var w = new CipWriter().U16(0).U8(7);
|
||||
w.PatchU16(0, 0xABCD);
|
||||
w.ToArray().ShouldBe(new byte[] { 0xCD, 0xAB, 0x07 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)] // would straddle the end: only bytes 0..2 are written
|
||||
[InlineData(3)]
|
||||
[InlineData(100)] // inside spare buffer capacity (256), but never written
|
||||
public void PatchU16_outside_the_written_range_throws(int offset)
|
||||
{
|
||||
var w = new CipWriter().U16(0).U8(7);
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => w.PatchU16(offset, 1));
|
||||
w.ToArray().ShouldBe(new byte[] { 0, 0, 7 }); // nothing was patched
|
||||
}
|
||||
}
|
||||
+48
-22
@@ -27,6 +27,13 @@ internal sealed class TransportObservations
|
||||
public Dictionary<uint, AbCipUdtShape?> Shapes { get; } = [];
|
||||
public Dictionary<uint, string> TemplateErrors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The transport status of each refused template, where the refusal was an <see cref="AbTransportException"/>.
|
||||
/// Lets parity tell a template BOTH transports refuse with the same status (an equal refusal) from a real
|
||||
/// divergence without parsing <see cref="TemplateErrors"/> text.
|
||||
/// </summary>
|
||||
public Dictionary<uint, AbStatus> TemplateErrorStatuses { get; } = [];
|
||||
|
||||
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
|
||||
public int TemplatesTruncated { get; set; }
|
||||
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
|
||||
@@ -85,9 +92,9 @@ internal sealed class DifferentialAbortedException(string message, Exception? in
|
||||
/// <para><b>Stop on first failure (review r1 C1).</b> The first connection-level failure on either side —
|
||||
/// connect / RegisterSession / Forward Open refused, timeout, session lost — aborts the whole run with the
|
||||
/// operation and status; nothing is retried. The native side is additionally capped at ONE open attempt
|
||||
/// process-wide (<see cref="CipConnection.OpenAttemptBudget"/>), so a reconnect cannot reach the wire even
|
||||
/// to the press under test (<see cref="ConnectionDiagnostics.LimitOpenAttempts"/>), so a reconnect cannot reach the wire even
|
||||
/// from inside an enumerator that swallows a per-program failure, and the run aborts the moment
|
||||
/// <see cref="CipConnection.ForwardOpenCount"/> passes 1.</para>
|
||||
/// <see cref="ConnectionDiagnostics.ForwardOpens"/> passes 1.</para>
|
||||
/// </summary>
|
||||
internal static class DifferentialRun
|
||||
{
|
||||
@@ -215,9 +222,9 @@ internal static class DifferentialRun
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="body"/> with every native operation sharing ONE connection, held open by an anchor
|
||||
/// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one
|
||||
/// more attempt, and <paramref name="body"/> receives a check to call after each operation that aborts the
|
||||
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
|
||||
/// lease: the shared pool's Forward Open counters are reset, the press is limited to exactly one
|
||||
/// open attempt, and <paramref name="body"/> receives a check to call after each operation that aborts the
|
||||
/// moment a second Forward Open reaches the wire. The anchor is released and the limit lifted in
|
||||
/// <c>finally</c>. Shared by the differential and the latency comparison.
|
||||
/// </summary>
|
||||
/// <returns>The body's result, the Forward Opens counted between the reset and the anchor release, and the
|
||||
@@ -226,43 +233,38 @@ internal static class DifferentialRun
|
||||
string phase, DifferentialTarget target, Func<Action<string>, Task<T>> body)
|
||||
{
|
||||
var anchorParams = target.Params("@tags");
|
||||
var key = NativeAbCipDevice.Key(anchorParams);
|
||||
var diagnostics = DeviceSessionPool.Shared.Diagnostics;
|
||||
|
||||
void CheckOpens(string operation)
|
||||
{
|
||||
var opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
|
||||
var opens = diagnostics.ForwardOpens;
|
||||
if (opens > 1)
|
||||
throw new DifferentialAbortedException(
|
||||
$"Differential ABORTED in the {phase} phase after {operation}: {opens} Forward Open frames on the wire, " +
|
||||
"expected exactly one (a reconnect, or a Large→regular fallback). Nothing was retried.");
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
|
||||
Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0);
|
||||
Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0);
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
|
||||
diagnostics.ResetForwardCounts();
|
||||
T result;
|
||||
int forwardOpens;
|
||||
NativeCloses forwardCloses;
|
||||
try
|
||||
// Exactly one open attempt to the press under test; lifted when the scope ends, whatever happens.
|
||||
using (diagnostics.LimitOpenAttempts(key, 1))
|
||||
{
|
||||
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
|
||||
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
|
||||
var anchor = DeviceSessionPool.Shared.Acquire(key, NativeAbCipDevice.Options(anchorParams));
|
||||
try
|
||||
{
|
||||
result = await body(CheckOpens).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
|
||||
forwardOpens = diagnostics.ForwardOpens;
|
||||
await anchor.DisposeAsync().ConfigureAwait(false);
|
||||
forwardCloses = new NativeCloses(
|
||||
Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked));
|
||||
forwardCloses = new NativeCloses(diagnostics.ForwardClosesSent, diagnostics.ForwardClosesAcked);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
|
||||
}
|
||||
return (result, forwardOpens, forwardCloses);
|
||||
}
|
||||
|
||||
@@ -320,6 +322,7 @@ internal static class DifferentialRun
|
||||
{
|
||||
if (IsConnectionLevel(ex)) throw Abort(phase, $"template {id}", ex);
|
||||
into.TemplateErrors[id] = Describe(ex);
|
||||
if (ex is AbTransportException ate) into.TemplateErrorStatuses[id] = ate.Status;
|
||||
}
|
||||
afterEach?.Invoke($"template {id}");
|
||||
}
|
||||
@@ -659,9 +662,22 @@ internal static class DifferentialRun
|
||||
a is not null && b is not null
|
||||
&& a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
|
||||
|
||||
/// <summary>
|
||||
/// Template ids BOTH transports refused with the SAME <see cref="AbStatus"/> and no shape on either side:
|
||||
/// the controller refuses that template and both transports report it identically, which is parity
|
||||
/// (Gitea #575) — not a divergence, and not a shape comparison either. A refusal with differing statuses,
|
||||
/// or one side refusing while the other decoded, is not listed here and stays a problem.
|
||||
/// </summary>
|
||||
internal static List<uint> EqualRefusals(TransportObservations lib, TransportObservations native) =>
|
||||
[.. lib.TemplateErrorStatuses.Keys
|
||||
.Where(id => !lib.Shapes.ContainsKey(id) && !native.Shapes.ContainsKey(id)
|
||||
&& native.TemplateErrorStatuses.TryGetValue(id, out var n) && n == lib.TemplateErrorStatuses[id])
|
||||
.Order()];
|
||||
|
||||
/// <summary>
|
||||
/// Template-parity problems; empty means parity. Vacuity is a problem: browse found structured tags
|
||||
/// but no template id was compared (review r1 I3).
|
||||
/// but no template id was compared (review r1 I3). An equal refusal (<see cref="EqualRefusals"/>) is
|
||||
/// parity and is not a problem, but is not a comparison either: it does not satisfy the vacuity check.
|
||||
/// </summary>
|
||||
internal static List<string> TemplateProblems(TransportObservations lib, TransportObservations native)
|
||||
{
|
||||
@@ -669,19 +685,29 @@ internal static class DifferentialRun
|
||||
var ids = lib.Shapes.Keys.Concat(lib.TemplateErrors.Keys)
|
||||
.Concat(native.Shapes.Keys).Concat(native.TemplateErrors.Keys)
|
||||
.Distinct().Order().ToList();
|
||||
var refused = EqualRefusals(lib, native).ToHashSet();
|
||||
var structured = lib.Browse.Concat(native.Browse).Count(t => t.DataType == AbCipDataType.Structure);
|
||||
if (structured > 0 && ids.Count == 0)
|
||||
if (structured > 0 && ids.Count(id => !refused.Contains(id)) == 0)
|
||||
problems.Add($"vacuous: browse found {structured} structured tag(s) but no template id was compared");
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (refused.Contains(id)) continue;
|
||||
var libShape = lib.Shapes.GetValueOrDefault(id);
|
||||
var nativeShape = native.Shapes.GetValueOrDefault(id);
|
||||
if (!ShapesEqual(libShape, nativeShape))
|
||||
problems.Add($"template {id}: " + (libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ"));
|
||||
problems.Add($"template {id}: " + Divergence(lib, native, id, libShape, nativeShape));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
private static string Divergence(
|
||||
TransportObservations lib, TransportObservations native, uint id, AbCipUdtShape? libShape, AbCipUdtShape? nativeShape)
|
||||
{
|
||||
if (lib.TemplateErrorStatuses.TryGetValue(id, out var l) && native.TemplateErrorStatuses.TryGetValue(id, out var n) && l != n)
|
||||
return $"refused with different statuses (libplctag {l}, native {n})";
|
||||
return libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ";
|
||||
}
|
||||
|
||||
internal static string Describe(Exception ex) => ex is AbTransportException ate
|
||||
? $"{ate.Status}: {ate.Message}"
|
||||
: $"{ex.GetType().Name}: {ex.Message}";
|
||||
|
||||
+107
-4
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
@@ -29,12 +30,28 @@ public sealed class DifferentialSafetyTests
|
||||
() => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("native phase at browse");
|
||||
// Long enough for a throttled (≥ 1 s) reconnect to have reached the listener, had one been allowed.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken);
|
||||
endpoint.Connections.ShouldBe(1);
|
||||
Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored after the phase");
|
||||
DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted after the phase");
|
||||
await ControlOpenAsync(target);
|
||||
endpoint.Connections.ShouldBe(2, "the phase's one connect, then the control's — no reconnect from the phase");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control (Gitea #583) for "no reconnect reached the listener": one more open to the same device,
|
||||
/// made after the phase, once its limit is lifted. It goes through the same per-device Forward Open throttle,
|
||||
/// whose waiters are served in arrival order, so by the time it has reached the listener (it is counted on
|
||||
/// accept, before the hang-up that fails it) any reconnect the phase had queued would already have got there
|
||||
/// — and, the limit being gone, nothing would have refused it.
|
||||
/// </summary>
|
||||
private static async Task ControlOpenAsync(DifferentialTarget target)
|
||||
{
|
||||
// A native tag initialisation (an existence read), like the harness's own: same pool, same device key.
|
||||
using var runtime = AbCipTransportFactories.For(AbCipTransport.Native).Tags
|
||||
.Create(target.Params("A", 1, false, AbCipDataType.DInt));
|
||||
await Should.ThrowAsync<AbTransportException>(() => runtime.InitializeAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static DeviceKey Key(DifferentialTarget target) => NativeAbCipDevice.Key(target.Params("@tags"));
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read()
|
||||
{
|
||||
@@ -99,6 +116,92 @@ public sealed class DifferentialSafetyTests
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_template_both_transports_refuse_with_the_same_status_is_an_equal_refusal_not_a_problem()
|
||||
{
|
||||
var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]);
|
||||
var lib = new TransportObservations();
|
||||
var native = new TransportObservations();
|
||||
lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
|
||||
lib.Browse.Add(new AbCipDiscoveredTag("V", null, AbCipDataType.Structure, false, TemplateInstanceId: 8));
|
||||
// 8 decodes on both sides (a real comparison, so the run is not vacuous); 7 is refused by both.
|
||||
lib.Shapes[8] = shape;
|
||||
native.Shapes[8] = shape;
|
||||
lib.TemplateErrors[7] = "NotFound: general 0x05 (libplctag wording)";
|
||||
native.TemplateErrors[7] = "NotFound: Read Template 7 failed: general 0x05 (native wording)";
|
||||
lib.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
native.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBe(new uint[] { 7 });
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty();
|
||||
|
||||
// Positive control: the same ids with DIFFERENT statuses are still a problem, naming both statuses.
|
||||
native.TemplateErrorStatuses[7] = AbStatus.NotAllowed;
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty();
|
||||
var differ = DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem();
|
||||
differ.ShouldContain("template 7");
|
||||
differ.ShouldContain("different statuses");
|
||||
|
||||
// One side refusing while the other decoded is a problem too.
|
||||
native.TemplateErrors.Remove(7);
|
||||
native.TemplateErrorStatuses.Remove(7);
|
||||
native.Shapes[7] = shape;
|
||||
DifferentialRun.EqualRefusals(lib, native).ShouldBeEmpty();
|
||||
DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("template 7");
|
||||
|
||||
// A run whose only templates are equal refusals compared no shape: still vacuous, not silently green.
|
||||
var onlyLib = new TransportObservations();
|
||||
var onlyNative = new TransportObservations();
|
||||
onlyLib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7));
|
||||
onlyLib.TemplateErrorStatuses[7] = onlyNative.TemplateErrorStatuses[7] = AbStatus.NotFound;
|
||||
onlyLib.TemplateErrors[7] = onlyNative.TemplateErrors[7] = "NotFound";
|
||||
DifferentialRun.TemplateProblems(onlyLib, onlyNative).ShouldHaveSingleItem().ShouldContain("vacuous");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_open_attempt_budget_alone_stops_a_second_open_even_when_nothing_aborts_the_phase()
|
||||
{
|
||||
// Unlike the abort test above, the body here SWALLOWS every failure and keeps going, so the harness's
|
||||
// abort-on-first-failure can never end the phase: only the per-device open-attempt limit stands between
|
||||
// the second request and the wire.
|
||||
await using var endpoint = HangUpListener.Start();
|
||||
var target = Target(endpoint.Port);
|
||||
var statuses = new List<AbStatus>();
|
||||
string? secondMessage = null;
|
||||
var attemptsBefore = DeviceSessionPool.Shared.Diagnostics.OpenAttempts;
|
||||
var tags = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
|
||||
|
||||
await DifferentialRun.WithNativeAnchorAsync("native", target, async _ =>
|
||||
{
|
||||
// Two independent tag initialisations, each of which needs the connection open. Failures are
|
||||
// swallowed here on purpose (the real harness would abort on the first).
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var runtime = tags.Create(target.Params("A", 1, false, AbCipDataType.DInt));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
statuses.Add(ex.Status);
|
||||
if (i == 1) secondMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
statuses.Count.ShouldBe(2, "both requests failed; neither aborted the phase");
|
||||
statuses[1].ShouldBe(AbStatus.Abort);
|
||||
secondMessage.ShouldNotBeNull().ShouldContain("budget");
|
||||
// The pool is process-wide, so another test's opens could move this counter; at least these two are in it.
|
||||
(DeviceSessionPool.Shared.Diagnostics.OpenAttempts - attemptsBefore).ShouldBeGreaterThanOrEqualTo(2,
|
||||
"the second attempt was counted, then refused");
|
||||
DeviceSessionPool.Shared.Diagnostics.HasOpenAttemptLimit(Key(target)).ShouldBeFalse("the limit is lifted afterwards");
|
||||
await ControlOpenAsync(target);
|
||||
endpoint.Connections.ShouldBe(2, "exactly the one budgeted open attempt reached the wire, then the control's");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
|
||||
{
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using System.Globalization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -125,7 +126,7 @@ public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
|
||||
$"{passes.Sum(p => p.TagFailures)} |";
|
||||
}
|
||||
|
||||
private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) =>
|
||||
private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.ConnectionDiagnostics.ExchangeStats? s) =>
|
||||
s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}";
|
||||
|
||||
private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
|
||||
+8
-8
@@ -5,13 +5,13 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
|
||||
/// <summary>One timed burst: every tag read once, concurrently.</summary>
|
||||
/// <param name="Requests">Native only: <see cref="CipConnection.RequestsSent"/> delta over the pass (keep-alives included).</param>
|
||||
/// <param name="Requests">Native only: <see cref="ConnectionDiagnostics.RequestsSent"/> delta over the pass (keep-alives included).</param>
|
||||
/// <param name="TagFailures">Reads that ended with a tag-level status (a connection-level one aborts instead).</param>
|
||||
/// <param name="Gc">Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here).</param>
|
||||
/// <param name="Rtt">Native only: round trip of each CIP exchange in the pass, from <see cref="CipConnection"/> (a slow
|
||||
/// controller shows here; a slow pass with fast exchanges points at the client).</param>
|
||||
internal sealed record LatencyPass(
|
||||
int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null);
|
||||
int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, ConnectionDiagnostics.ExchangeStats? Rtt = null);
|
||||
|
||||
/// <summary>GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports).</summary>
|
||||
internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2)
|
||||
@@ -137,7 +137,7 @@ internal static class PollLatencyRun
|
||||
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
|
||||
try
|
||||
{
|
||||
var before = Volatile.Read(ref CipConnection.RequestsSent);
|
||||
var before = DeviceSessionPool.Shared.Diagnostics.RequestsSent;
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false);
|
||||
@@ -145,7 +145,7 @@ internal static class PollLatencyRun
|
||||
if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; }
|
||||
runtimes.Add((tag, rt));
|
||||
}
|
||||
var setup = Volatile.Read(ref CipConnection.RequestsSent) - before;
|
||||
var setup = DeviceSessionPool.Shared.Diagnostics.RequestsSent - before;
|
||||
var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct)
|
||||
.ConfigureAwait(false);
|
||||
return (passes, setup);
|
||||
@@ -214,15 +214,15 @@ internal static class PollLatencyRun
|
||||
{
|
||||
if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false);
|
||||
|
||||
var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent);
|
||||
CipConnection.ResetExchangeStats();
|
||||
var requestsBefore = DeviceSessionPool.Shared.Diagnostics.RequestsSent;
|
||||
DeviceSessionPool.Shared.Diagnostics.ResetExchangeStats();
|
||||
var gcBefore = GcDelta.Now();
|
||||
var clock = Stopwatch.StartNew();
|
||||
var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false);
|
||||
clock.Stop();
|
||||
var gc = GcDelta.Now() - gcBefore;
|
||||
var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null;
|
||||
var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore;
|
||||
var rtt = countRequests ? DeviceSessionPool.Shared.Diagnostics.ReadExchangeStats() : (ConnectionDiagnostics.ExchangeStats?)null;
|
||||
var requests = DeviceSessionPool.Shared.Diagnostics.RequestsSent - requestsBefore;
|
||||
|
||||
var failures = 0;
|
||||
for (var i = 0; i < outcomes.Length; i++)
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
@@ -77,11 +78,15 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
|
||||
Log("| Template | libplctag | native | Equal? |");
|
||||
Log("|---|---|---|---|");
|
||||
}
|
||||
var refused = DifferentialRun.EqualRefusals(r.Libplctag, r.Native).ToHashSet();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id));
|
||||
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {(equal ? "yes" : "**NO**")} |");
|
||||
var verdict = refused.Contains(id) ? "equal refusal" : equal ? "yes" : "**NO**";
|
||||
Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {verdict} |");
|
||||
}
|
||||
if (refused.Count > 0)
|
||||
Log($"- EQUAL REFUSAL (not a divergence; both transports refuse with the same status): {string.Join(", ", refused)}");
|
||||
|
||||
var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native);
|
||||
foreach (var p in problems) Log($"- PROBLEM: {p}");
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag.NativeImport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -48,26 +48,33 @@ public sealed class AbCipAlarmProjectionTests
|
||||
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
private static AbCipDriverOptions Options(bool enabled) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = enabled,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
|
||||
[Fact]
|
||||
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = false, // explicit; also the default
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
var drv = new AbCipDriver(Options(enabled: false), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
|
||||
|
||||
// Wait a touch — if polling were active, a fake member-read would be triggered.
|
||||
await Task.Delay(100);
|
||||
// Positive control (#583): the same options with the flag ON poll three times — so a loop this
|
||||
// subscription had started would have read (and so created) the ALMD runtime by now.
|
||||
await PolledThreeTimesWithTheFlagOnAsync();
|
||||
factory.Tags.ShouldNotContainKey("HighTemp");
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
|
||||
|
||||
@@ -80,17 +87,7 @@ public sealed class AbCipAlarmProjectionTests
|
||||
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
@@ -99,21 +96,16 @@ public sealed class AbCipAlarmProjectionTests
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
|
||||
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
|
||||
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
|
||||
// then flip to 1 (fault) + wait for the raise event.
|
||||
// gets created + polled. Set InFaulted offset-value to 0 first (clear), let polls seed the
|
||||
// "last-seen false" state, then flip to 1 (fault) + wait for the raise event.
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
|
||||
await Task.Delay(80); // let a tick seed the "last-seen false" state
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 2);
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
|
||||
await Task.Delay(200); // allow several polls to be safe
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||||
&& e.Message.Contains("raised"));
|
||||
}
|
||||
await WaitForEventAsync(events, e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||||
&& e.Message.Contains("raised"));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
@@ -135,15 +127,7 @@ public sealed class AbCipAlarmProjectionTests
|
||||
public async Task Resubscribe_Collapses_To_Single_Loop_No_Duplicate_Raise()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
@@ -158,17 +142,18 @@ public sealed class AbCipAlarmProjectionTests
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8
|
||||
await Task.Delay(80); // let sub1's loop seed its "last-seen false" baseline
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline
|
||||
|
||||
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reads the same
|
||||
// shared HighTemp runtime; give it time to seed its own "last-seen false" baseline too.
|
||||
// shared HighTemp runtime; let it poll (and so seed its own "last-seen false" baseline) too.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await Task.Delay(80);
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // one 0->1 transition
|
||||
// Wait past several 20ms poll intervals so any leaked second loop has ample time to also
|
||||
// fire its duplicate raise before we assert.
|
||||
await Task.Delay(250);
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
// Processed-count wait (#583), not a fixed delay: six more reads of the shared runtime after the raise,
|
||||
// so a leaked second loop — which reads it too — has had polls in which to fire its duplicate.
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
@@ -186,17 +171,7 @@ public sealed class AbCipAlarmProjectionTests
|
||||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
@@ -207,16 +182,10 @@ public sealed class AbCipAlarmProjectionTests
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
await Task.Delay(80); // observe raise
|
||||
await WaitForEventAsync(events, e => e.Message.Contains("raised"));
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
|
||||
await Task.Delay(200);
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.Message.Contains("raised"));
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
await WaitForEventAsync(events, e => e.Message.Contains("cleared"));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
@@ -227,33 +196,59 @@ public sealed class AbCipAlarmProjectionTests
|
||||
public async Task Unsubscribe_Stops_The_Poll_Loop()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||||
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||||
EnableDeclarationOnlyUdtGrouping = true,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 1); // the loop is running
|
||||
|
||||
// Unsubscribe awaits the loop's exit, so no read can land after it returns.
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await Task.Delay(100); // well past several poll intervals if the loop were still alive
|
||||
var postUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
|
||||
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
|
||||
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
|
||||
// Positive control (#583): an identical, still-subscribed driver polls three more times on the same
|
||||
// interval — so the unsubscribed loop, had it survived, would have read again by now.
|
||||
await PolledThreeTimesWithTheFlagOnAsync();
|
||||
factory.Tags["HighTemp"].ReadCount.ShouldBe(postUnsubReadCount);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A control driver with the projection ON polls its ALMD runtime three times.</summary>
|
||||
private static async Task PolledThreeTimesWithTheFlagOnAsync()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var control = new AbCipDriver(Options(enabled: true), "drv-control", factory);
|
||||
await control.InitializeAsync("{}", CancellationToken.None);
|
||||
var handle = await control.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
|
||||
await control.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await control.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Waits for <paramref name="more"/> further reads of the fake runtime (a processed-count wait).</summary>
|
||||
private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more)
|
||||
{
|
||||
var tag = factory.Tags[tagName];
|
||||
var target = tag.ReadCount + more;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (tag.ReadCount < target && DateTime.UtcNow < deadline) await Task.Delay(5);
|
||||
tag.ReadCount.ShouldBeGreaterThanOrEqualTo(target, $"'{tagName}' should have been read {more} more times");
|
||||
}
|
||||
|
||||
private static async Task WaitForEventAsync(List<AlarmEventArgs> events, Func<AlarmEventArgs, bool> match)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
lock (events) if (events.Any(match)) return;
|
||||
await Task.Delay(5);
|
||||
}
|
||||
throw new TimeoutException("The expected alarm event was never raised.");
|
||||
}
|
||||
|
||||
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
|
||||
+5
-2
@@ -152,8 +152,11 @@ public sealed class AbCipDriverCodeReviewRegressionTests
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Give the probe loop a moment to actually start spinning.
|
||||
await Task.Delay(60);
|
||||
// Wait until the probe loop is actually spinning (#583: a processed count, not a fixed moment).
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!(factory.Tags.TryGetValue("ProbeTag", out var probe) && probe.ReadCount >= 2) && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(5, TestContext.Current.CancellationToken);
|
||||
factory.Tags["ProbeTag"].ReadCount.ShouldBeGreaterThanOrEqualTo(2);
|
||||
|
||||
// Must complete cleanly — no ObjectDisposedException from a loop racing a disposed CTS.
|
||||
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
|
||||
|
||||
@@ -253,6 +253,50 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #572: a browse that fetches many templates (a UDT and its nested UDT here) reads them all
|
||||
/// through ONE batch reader, so the native transport holds one device lease across them rather than
|
||||
/// leasing per template. The batch reader is disposed when the device's browse ends.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Discovery_reads_every_template_through_one_batch_reader()
|
||||
{
|
||||
const string device = "ab://10.0.0.5/1,0";
|
||||
const uint motorTemplateId = 0x2A;
|
||||
const uint statusTemplateId = 0x123;
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: motorTemplateId),
|
||||
new AbCipDiscoveredTag("Motor2", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 0x2B));
|
||||
var templates = new IdKeyedTemplateReaderFactory
|
||||
{
|
||||
Templates =
|
||||
{
|
||||
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
|
||||
("Speed", 0xCA, 0, 0), ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)),
|
||||
[0x2B] = BuildSimpleTemplate("Motor2Udt", 4, ("Speed", 0xCA, 0, 0)),
|
||||
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4, ("Code", 0xC4, 0, 0)),
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device)],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templates);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
templates.RequestedIds.Count.ShouldBe(3);
|
||||
templates.BatchReaders.ShouldBe(1);
|
||||
templates.PerCallReaders.ShouldBe(0);
|
||||
templates.BatchReadersDisposed.ShouldBe(1);
|
||||
|
||||
// Outside a discovery run a fetch is still a self-contained per-call read.
|
||||
await drv.FetchUdtShapeAsync(device, 0x2B, CancellationToken.None);
|
||||
await drv.FetchUdtShapeAsync(device, 0x999, CancellationToken.None);
|
||||
templates.PerCallReaders.ShouldBe(1, "0x2B is cached; 0x999 is not");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
|
||||
/// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
|
||||
@@ -799,10 +843,20 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
/// <summary>Gets every template id any reader was asked for, in request order.</summary>
|
||||
public List<uint> RequestedIds { get; } = new();
|
||||
|
||||
/// <summary>Creates a reader over the shared template map.</summary>
|
||||
public IAbCipTemplateReader Create() => new Reader(this);
|
||||
/// <summary>Gets how many per-call readers were created.</summary>
|
||||
public int PerCallReaders { get; private set; }
|
||||
/// <summary>Gets how many batch readers were created.</summary>
|
||||
public int BatchReaders { get; private set; }
|
||||
/// <summary>Gets how many batch readers have been disposed.</summary>
|
||||
public int BatchReadersDisposed { get; set; }
|
||||
|
||||
private sealed class Reader(IdKeyedTemplateReaderFactory outer) : IAbCipTemplateReader
|
||||
/// <summary>Creates a reader over the shared template map.</summary>
|
||||
public IAbCipTemplateReader Create() { PerCallReaders++; return new Reader(this); }
|
||||
|
||||
/// <summary>Creates a batch reader (disposal is counted).</summary>
|
||||
public IAbCipTemplateReader CreateBatch() { BatchReaders++; return new Reader(this, batch: true); }
|
||||
|
||||
private sealed class Reader(IdKeyedTemplateReaderFactory outer, bool batch = false) : IAbCipTemplateReader
|
||||
{
|
||||
public Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
@@ -813,7 +867,7 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
public void Dispose() { if (batch) outer.BatchReadersDisposed++; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,11 +100,36 @@ public sealed class AbCipHostProbeTests
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
// #583: structural, not a fixed wait — no probe loop was ever started, so no transition can follow.
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ProbeTask.ShouldBeNull();
|
||||
factory.Tags.ShouldBeEmpty();
|
||||
transitions.ShouldBeEmpty();
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await ProbeLoopIsObservableAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the "no probe loop" assertions above: the same observation (the device's
|
||||
/// <c>ProbeTask</c>) on a driver whose probe IS configured is non-null, and that loop does produce a
|
||||
/// transition — so a null <c>ProbeTask</c> really means "no loop", not "a loop the hook cannot see".
|
||||
/// </summary>
|
||||
private static async Task ProbeLoopIsObservableAsync()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var control = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = "@raw_cpu_type", Interval = TimeSpan.FromMilliseconds(100) },
|
||||
}, "drv-control", factory);
|
||||
control.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
await control.InitializeAsync("{}", CancellationToken.None);
|
||||
(control.GetDeviceState("ab://10.0.0.5/1,0")!.ProbeTask is not null).ShouldBeTrue();
|
||||
await WaitForAsync(() => !transitions.IsEmpty, TimeSpan.FromSeconds(2));
|
||||
transitions.ShouldNotBeEmpty();
|
||||
await control.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the probe is skipped when ProbeTagPath is null.</summary>
|
||||
@@ -118,10 +143,11 @@ public sealed class AbCipHostProbeTests
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
|
||||
drv.GetDeviceState("ab://10.0.0.5/1,0")!.ProbeTask.ShouldBeNull(); // #583: no loop, so no transition
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await ProbeLoopIsObservableAsync();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the probe loops across multiple devices independently.</summary>
|
||||
|
||||
@@ -65,8 +65,12 @@ public sealed class AbCipLoggingTests
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Give the probe loop a couple of ticks to log.
|
||||
await Task.Delay(200);
|
||||
// Wait (bounded) for the entry itself rather than a fixed couple of ticks (#583).
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (!logger.Entries.Any(e => e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase)
|
||||
|| (e.Exception?.Message.Contains("simulated probe init failure") ?? false))
|
||||
&& DateTime.UtcNow < deadline)
|
||||
await Task.Delay(5, TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
@@ -186,8 +190,10 @@ public sealed class AbCipLoggingTests
|
||||
/// <summary>Test logger that captures all log entries.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the captured log entries.</summary>
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
private readonly List<(LogLevel Level, string Message, Exception? Exception)> _entries = new();
|
||||
|
||||
/// <summary>Gets a snapshot of the captured log entries (the probe loop logs from another thread).</summary>
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get { lock (_entries) return [.. _entries]; } }
|
||||
|
||||
/// <summary>Begins a scope (stub implementation).</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
@@ -206,7 +212,7 @@ public sealed class AbCipLoggingTests
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception), exception));
|
||||
lock (_entries) _entries.Add((logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #570: a tag whose own name cannot become a request is a configuration error on THAT tag. It
|
||||
/// reports <c>BadConfigurationError</c>, never opens the device's connect-backoff window and never
|
||||
/// degrades driver health, so healthy tags on the same device keep reading. Before the fix the native
|
||||
/// encoder's raw exception was counted as a failed Forward Open: the window opened and every cold sibling
|
||||
/// was throttled to <c>BadCommunicationError</c> — one typo blacked out the device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipMalformedTagNameTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
private const int Polls = 3;
|
||||
|
||||
#pragma warning disable OTOPCUA0001 // the driver's own status handling is under test, not resilience wrapping
|
||||
[Fact]
|
||||
public async Task A_name_the_encoder_refuses_is_BadConfigurationError_and_does_not_throttle_a_cold_sibling()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "Bad"
|
||||
? new FakeAbCipTag(p)
|
||||
{
|
||||
ThrowOnInitialize = true,
|
||||
Exception = new AbCipTagConfigurationException("'Bad' cannot be encoded"),
|
||||
}
|
||||
: new FakeAbCipTag(p) { Value = 7 },
|
||||
};
|
||||
var log = new CapturingLogger();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", Host, "Bad", AbCipDataType.DInt, Writable: true),
|
||||
new AbCipTagDefinition("Good", Host, "Good", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-570", factory, logger: log);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["Bad"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadConfigurationError);
|
||||
(await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadConfigurationError);
|
||||
|
||||
// The sibling is still COLD here, so it goes through the backoff check — a window opened by the
|
||||
// malformed tag would throttle it to BadCommunicationError without ever creating it.
|
||||
var good = (await drv.ReadAsync(["Good"], CancellationToken.None)).Single();
|
||||
good.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
good.Value.ShouldBe(7);
|
||||
factory.Tags["Good"].InitializeCount.ShouldBe(1);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().State.ShouldBe(health.State);
|
||||
log.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("misconfigured")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_TagPath_is_BadConfigurationError_without_degrading_health()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 7 } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", Host, "1nvalid[", AbCipDataType.DInt, Writable: true),
|
||||
new AbCipTagDefinition("Good", Host, "Good", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-570-path", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.BadConfigurationError);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); // positive control: the batch did read
|
||||
(await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadConfigurationError);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
factory.Tags.ContainsKey("1nvalid[").ShouldBeFalse(); // never created
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end over the REAL native runtime + a private pool + a fake CIP target: a non-ASCII name (which
|
||||
/// <see cref="AbCipTagPath"/> accepts — <c>char.IsLetter</c> — but the CIP symbolic encoder refuses) is
|
||||
/// BadConfigurationError, the sibling on the same device reads Good, and the device sees ONE Forward Open.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Over_the_native_runtime_a_non_ASCII_name_leaves_the_device_and_its_siblings_alone()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
// Read Tag Fragmented (0x52) → DINT 42.
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
||||
req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00] : target.ServiceReply(req));
|
||||
var pool = new DeviceSessionPool();
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", host, "Tëmp", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Good", host, "Good", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "drv-570-native", new NativeAbCipTagFactory(pool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["Bad"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadConfigurationError);
|
||||
target.ForwardOpens.ShouldBe(0); // nothing reached the device
|
||||
|
||||
var good = (await drv.ReadAsync(["Good"], CancellationToken.None)).Single();
|
||||
good.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
good.Value.ShouldBe(42);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
@@ -9,27 +9,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipStatusMapperTests
|
||||
{
|
||||
/// <summary>Verifies that CIP general status codes are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The raw CIP general status byte.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
|
||||
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
|
||||
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
|
||||
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that transport status values are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The transport status to map.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
|
||||
@@ -67,7 +67,8 @@ public sealed class AbCipSubscriptionTests
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await Task.Delay(500);
|
||||
// Processed-count wait (#583): five polls have read the unchanged value, not "500 ms went by".
|
||||
await WaitForReadsAsync(factory, "Speed", 5);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
@@ -101,22 +102,30 @@ public sealed class AbCipSubscriptionTests
|
||||
public async Task Unsubscribe_halts_polling()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 1 };
|
||||
factory.Customise = _ => tagRef;
|
||||
factory.Customise = p => p.TagName == "Speed" ? tagRef : new FakeAbCipTag(p) { Value = 1 };
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
var controlHandle = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Any(e => e.FullReference == "Speed"), TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var afterUnsub = events.Count;
|
||||
var afterUnsub = events.Count(e => e.FullReference == "Speed");
|
||||
var readsAfterUnsub = tagRef.ReadCount;
|
||||
tagRef.Value = 999;
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
// Positive control (#583): the still-subscribed sibling, on the same interval, polls three more times —
|
||||
// so Speed's loop, had it survived the unsubscribe, has had three chances to read the change.
|
||||
await WaitForReadsAsync(factory, "Temp", factory.Tags["Temp"].ReadCount + 3);
|
||||
|
||||
events.Count(e => e.FullReference == "Speed").ShouldBe(afterUnsub);
|
||||
tagRef.ReadCount.ShouldBe(readsAfterUnsub);
|
||||
await drv.UnsubscribeAsync(controlHandle, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that polling intervals below 100ms are floored to the minimum.</summary>
|
||||
@@ -132,12 +141,17 @@ public sealed class AbCipSubscriptionTests
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(5), CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
// Processed-count wait (#583): three polls, then measure the spacing they actually ran at.
|
||||
var started = System.Diagnostics.Stopwatch.StartNew();
|
||||
await WaitForReadsAsync(factory, "Speed", 3);
|
||||
var elapsed = started.Elapsed;
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
// Value is stable → only the initial-data push fires; the 100 ms floor keeps polls sparse enough
|
||||
// that no extra event is produced against a stable value.
|
||||
// Value is stable → only the initial-data push fires.
|
||||
events.Count.ShouldBe(1);
|
||||
// The floor: the initial read is immediate, so the two later ones took at least two 100 ms intervals.
|
||||
// At the requested 5 ms they would have taken ~10 ms.
|
||||
elapsed.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(180));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ShutdownAsync cancels all active subscriptions.</summary>
|
||||
@@ -154,11 +168,25 @@ public sealed class AbCipSubscriptionTests
|
||||
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
var speed = factory.Tags["Speed"];
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(300);
|
||||
var readsAfterShutdown = speed.ReadCount;
|
||||
speed.Value = 2; // a change any surviving loop would raise
|
||||
|
||||
// Positive control (#583): a second, independent driver polling on the same interval reads three more
|
||||
// times — so the shut-down driver's loop, had it survived, has had three chances to raise the change.
|
||||
var (control, controlFactory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await control.InitializeAsync("{}", CancellationToken.None);
|
||||
controlFactory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
||||
_ = await control.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForReadsAsync(controlFactory, "Speed", 3);
|
||||
await control.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
speed.ReadCount.ShouldBe(readsAfterShutdown);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscriptions on UDT members use the synthesized full reference.</summary>
|
||||
@@ -195,4 +223,12 @@ public sealed class AbCipSubscriptionTests
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
/// <summary>Waits until the fake runtime for <paramref name="tag"/> has served at least <paramref name="reads"/>
|
||||
/// reads (a processed-count wait), failing — never passing vacuously — if it does not get there.</summary>
|
||||
private static async Task WaitForReadsAsync(FakeAbCipTagFactory factory, string tag, int reads)
|
||||
{
|
||||
await WaitForAsync(() => factory.Tags.TryGetValue(tag, out var t) && t.ReadCount >= reads, TimeSpan.FromSeconds(5));
|
||||
factory.Tags[tag].ReadCount.ShouldBeGreaterThanOrEqualTo(reads, $"'{tag}' should have been polled {reads} times");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ public sealed class AbCipTransportSwitchTests
|
||||
"ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}"""));
|
||||
existing.GetType().ShouldBe(bad.GetType());
|
||||
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
|
||||
// #574: a device-level field must not be reported as a tag named 'device'.
|
||||
existing.Message.ShouldNotContain("tag '");
|
||||
existing.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown PlcFamily 'Bogus'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -113,6 +116,21 @@ public sealed class AbCipTransportSwitchTests
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagTagFactory>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the libplctag path against a listener that accepts TCP and never speaks CIP.
|
||||
/// libplctag reports the stall two ways that race on the wall clock: the probe's own cancellation
|
||||
/// ("Probe timed out after Ns") or libplctag's internal timeout, which has the same duration and
|
||||
/// surfaces as the <c>ErrorTimeout</c> status. Which fires first depends on scheduler load, so both are
|
||||
/// the libplctag outcome; the native path never produces either (it fails at once with "Connection lost").
|
||||
/// </summary>
|
||||
private static void ShouldBeLibplctagTimeout(string? message)
|
||||
{
|
||||
message.ShouldNotBeNull();
|
||||
(message.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("ErrorTimeout", StringComparison.Ordinal))
|
||||
.ShouldBeTrue($"expected the libplctag stall outcome (timed out / ErrorTimeout) but was: {message}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native
|
||||
/// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens
|
||||
@@ -145,6 +163,9 @@ public sealed class AbCipTransportSwitchTests
|
||||
native.Message.ShouldNotBeNull();
|
||||
native.Message!.ShouldContain("CIP handshake failed: Connection lost");
|
||||
lib.Message!.ShouldNotContain("Connection lost");
|
||||
// Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout),
|
||||
// not merely "something other than the native text".
|
||||
ShouldBeLibplctagTimeout(lib.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,6 +201,9 @@ public sealed class AbCipTransportSwitchTests
|
||||
absent.Ok.ShouldBeFalse();
|
||||
absent.Message!.ShouldContain("CIP handshake failed: Connection lost");
|
||||
lib.Message!.ShouldNotContain("Connection lost");
|
||||
// Positive (#574): the libplctag path ran and reported its own outcome (the bounded probe timeout),
|
||||
// not merely "something other than the native text".
|
||||
ShouldBeLibplctagTimeout(lib.Message);
|
||||
}
|
||||
|
||||
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
|
||||
@@ -259,5 +283,20 @@ public sealed class AbCipTransportSwitchTests
|
||||
unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost");
|
||||
pinned.Ok.ShouldBeFalse();
|
||||
pinned.Message!.ShouldNotContain("Connection lost");
|
||||
ShouldBeLibplctagTimeout(pinned.Message); // positive: libplctag's own outcome (#574)
|
||||
}
|
||||
|
||||
/// <summary>Gitea #572: <c>browsePageDelayMs</c> parses (default 5 ms, 0 allowed, negative clamps to 0).</summary>
|
||||
[Theory]
|
||||
[InlineData(null, 5)]
|
||||
[InlineData("0", 0)]
|
||||
[InlineData("25", 25)]
|
||||
[InlineData("-3", 0)]
|
||||
public void Config_browsePageDelayMs_parses(string? json, int expectedMs)
|
||||
{
|
||||
var config = $$"""{{{(json is null ? "" : $"\"browsePageDelayMs\":{json},")}}"devices":[{{Device}}]}""";
|
||||
|
||||
AbCipDriverFactoryExtensions.ParseOptions("ab1", config).BrowsePageDelay
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,38 @@ public sealed class AbCipTypeMismatchTests
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>#585: the libplctag-style width-mismatch path (status Ok, but the buffer is not the authored
|
||||
/// type's width — a REAL authored as INT) gets the same treatment as the native TypeMismatch status.</summary>
|
||||
[Fact]
|
||||
public async Task A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
var creates = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => { Interlocked.Increment(ref creates); return new FakeAbCipTag(p) { Value = 1, Size = 4 }; },
|
||||
};
|
||||
var log = new CapturingLogger();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.Int)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-585", factory, logger: log);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
@@ -155,5 +187,92 @@ public sealed class AbCipTypeMismatchTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #570 mistype churn — the path the I4 fix above did not reach. A controller that refuses the
|
||||
/// EXISTENCE read itself with a type error (CIP 0xFF / 0x2107) made <c>InitializeAsync</c> throw, so the
|
||||
/// driver disposed the runtime (the sole lease: Forward Close), opened the device backoff window, and
|
||||
/// re-created the runtime (Forward Open) on the next poll — forever, throttled. One create and one
|
||||
/// Forward Open across N polls, every poll BadTypeMismatch, health unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_type_refusal_on_the_existence_read_causes_no_runtime_or_connection_churn()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
// Read Tag Fragmented (0x52) → general status 0xFF, extended 0x2107 (type mismatch).
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
||||
req[0] == 0x52 ? [0xD2, 0x00, 0xFF, 0x01, 0x07, 0x21] : target.ServiceReply(req));
|
||||
var pool = new DeviceSessionPool();
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var creates = 0;
|
||||
var native = new NativeAbCipTagFactory(pool);
|
||||
var counting = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); });
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", host, "T", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "drv-570-churn", counting);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
try
|
||||
{
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The driver-side half of the same guarantee, independent of the native runtime: a runtime whose
|
||||
/// <c>InitializeAsync</c> throws a type mismatch is kept, not disposed and re-created per poll.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_runtime_that_throws_a_type_mismatch_from_initialize_is_kept_not_recreated()
|
||||
{
|
||||
var creates = 0;
|
||||
var mismatch = new AbTransportException(AbStatus.TypeMismatch, "REAL on the controller, DINT authored");
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
Interlocked.Increment(ref creates);
|
||||
return new FakeAbCipTag(p) { ThrowOnInitialize = true, ThrowOnRead = true, Exception = mismatch };
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-570-fake", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
/// <summary>Gets the count of <see cref="InitializeAsync"/> invocations.</summary>
|
||||
public int InitializeCount { get; private set; }
|
||||
/// <summary>Gets the count of <see cref="ReadAsync"/> invocations.</summary>
|
||||
public int ReadCount { get; private set; }
|
||||
public int ReadCount => Volatile.Read(ref _readCount);
|
||||
private int _readCount; // interlocked: poll loops read it from another thread (#583 processed-count waits)
|
||||
/// <summary>Gets the count of <see cref="WriteAsync"/> invocations.</summary>
|
||||
public int WriteCount { get; private set; }
|
||||
/// <summary>Gets a value indicating whether the tag has been disposed.</summary>
|
||||
@@ -48,7 +49,7 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
/// <param name="cancellationToken">The cancellation token for the operation.</param>
|
||||
public virtual Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadCount++;
|
||||
Interlocked.Increment(ref _readCount);
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
using libplctag.NativeImport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -17,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class LibplctagOrphanReleaseCollection
|
||||
{
|
||||
/// <summary>The release counters and the rooting seam are process-wide: nothing else may run alongside.</summary>
|
||||
/// <summary>The release counters are process-wide: nothing else may run alongside.</summary>
|
||||
public const string Name = "libplctag orphan release";
|
||||
}
|
||||
|
||||
@@ -89,23 +90,29 @@ public sealed class LibplctagOrphanReleaseTests
|
||||
[Fact]
|
||||
public async Task Without_a_readable_handle_the_failed_tag_is_rooted_instead()
|
||||
{
|
||||
// Gitea #581: the "unreadable handle" decision is passed in, not flipped through a process-wide switch.
|
||||
var rooted = LibplctagOrphanRelease.RootedCount;
|
||||
var released = LibplctagOrphanRelease.Released;
|
||||
var rt = new LibplctagTagRuntime(Refused());
|
||||
LibplctagOrphanRelease.ForceRootingForTest = true;
|
||||
var tag = LibplctagTagRuntime.BuildTag(Refused());
|
||||
try
|
||||
{
|
||||
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
|
||||
await Should.ThrowAsync<Exception>(() => tag.InitializeAsync(CancellationToken.None));
|
||||
var handle = LibplctagOrphanRelease.HandleOf(tag);
|
||||
handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
|
||||
|
||||
LibplctagOrphanRelease.ReleaseIfOrphaned(tag, canReadHandles: false).ShouldBeNull();
|
||||
|
||||
LibplctagOrphanRelease.RootedCount.ShouldBe(rooted + 1);
|
||||
LibplctagOrphanRelease.Released.ShouldBe(released);
|
||||
NativeTagExists(handle).ShouldBeTrue("the rooting fallback destroys nothing");
|
||||
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(handle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LibplctagOrphanRelease.ForceRootingForTest = false;
|
||||
// Clean up the native tag this test deliberately leaked (its callback was unregistered by the timeout).
|
||||
var handle = rt.NativeHandle;
|
||||
var handle = LibplctagOrphanRelease.HandleOf(tag);
|
||||
if (handle > 0) plctag.plc_tag_destroy(handle);
|
||||
rt.Dispose();
|
||||
tag.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,66 @@ public sealed class LibplctagStatusTranslationTests
|
||||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #568: the message-parse is pinned against the REAL exception libplctag builds from a <see cref="Status"/>
|
||||
/// (not a hand-typed message), for every defined status — if a libplctag bump changes the message format
|
||||
/// the translation would silently degrade to BadConnection, and this fails instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_real_LibPlcTagException_built_from_a_Status_round_trips_through_the_message_parse()
|
||||
{
|
||||
foreach (var status in Enum.GetValues<Status>())
|
||||
{
|
||||
if (status == Status.Ok || status == Status.Pending) continue; // libplctag throws only on errors
|
||||
var ex = new LibPlcTagException(status);
|
||||
LibplctagStatusTranslation.ToAbStatus(ex).ShouldBe(LibplctagStatusTranslation.ToAbStatus(status), $"{status}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#568: a cancellation is not a libplctag failure — WrapSync must not translate or swallow it.</summary>
|
||||
[Fact]
|
||||
public void WrapSync_passes_OperationCanceledException_through()
|
||||
{
|
||||
Should.Throw<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.WrapSync<int>(() => throw new OperationCanceledException()));
|
||||
Should.Throw<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.WrapSync(() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>WrapSync translates a libplctag failure and returns a successful result unchanged.</summary>
|
||||
[Fact]
|
||||
public void WrapSync_translates_libplctag_failures_and_returns_results()
|
||||
{
|
||||
LibplctagStatusTranslation.WrapSync(() => 42).ShouldBe(42);
|
||||
var ex = Should.Throw<AbTransportException>(
|
||||
() => LibplctagStatusTranslation.WrapSync<int>(() => throw new LibPlcTagException("ErrorTimeout")));
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #568: the ADAPTER (not just the helper) routes decode through WrapSync. A real, never-initialised native
|
||||
/// <c>Tag</c> (no network I/O — nothing was created) throws a libplctag exception on a decode, and the runtime
|
||||
/// must surface it as an <see cref="AbTransportException"/>. Remove the WrapSync around the decode and this
|
||||
/// sees a raw <see cref="LibPlcTagException"/> (or <see cref="InvalidOperationException"/>) instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LibplctagTagRuntime_decode_on_an_unread_tag_surfaces_AbTransportException()
|
||||
{
|
||||
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
|
||||
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "NeverInitialised", Timeout: TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
Should.Throw<AbTransportException>(() => rt.DecodeValueAt(AbCipDataType.DInt, 0, null));
|
||||
}
|
||||
|
||||
/// <summary>Wrap passes a cancellation through untranslated.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_passes_OperationCanceledException_through()
|
||||
{
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => LibplctagStatusTranslation.Wrap(() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>Wrap rethrows as a transport exception keeping the original as InnerException.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_rethrows_as_AbTransportException_with_inner()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #579: the native runtime's <c>Dispose</c> — which the driver calls under the tag's lock — used to
|
||||
/// block for up to 2 s waiting for the last lease's Forward Close. It now releases in the background
|
||||
/// (<see cref="ConnectionLease.ReleaseInBackground"/>, the same shape as the native AbLegacy runtime), and
|
||||
/// these tests pin both halves of why that is safe: the caller is not held, and a re-create still cannot
|
||||
/// overlap the close or leak a connection.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NativeAbCipLeaseReleaseTests
|
||||
{
|
||||
private static readonly TimeSpan Guard = TimeSpan.FromSeconds(15);
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static AbCipTagCreateParams Params(int port) => new(
|
||||
Gateway: "127.0.0.1",
|
||||
Port: port,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
TagName: "T",
|
||||
Timeout: TimeSpan.FromSeconds(5),
|
||||
AllowPacking: false,
|
||||
ElementCount: 1,
|
||||
IsArray: false,
|
||||
AuthoredType: AbCipDataType.DInt,
|
||||
UseConnectedMessaging: true);
|
||||
|
||||
/// <summary>A target whose Read Tag Fragmented (0x52) answers DINT 42.</summary>
|
||||
private static FakeCipTarget NewTarget()
|
||||
{
|
||||
var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
||||
req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00] : target.ServiceReply(req));
|
||||
return target;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Release_under_the_tag_lock_returns_promptly_while_the_Forward_Close_is_still_pending()
|
||||
{
|
||||
await using var target = NewTarget();
|
||||
var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
target.ForwardCloseGate = () => closeHeld.Task;
|
||||
var pool = new DeviceSessionPool();
|
||||
var factory = new NativeAbCipTagFactory(pool);
|
||||
|
||||
var first = (NativeAbCipTagRuntime)factory.Create(Params(target.Port));
|
||||
await first.InitializeAsync(Ct);
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
|
||||
// The driver disposes under the per-tag lock; hold one and time the release inside it.
|
||||
using var tagLock = new SemaphoreSlim(1, 1);
|
||||
await tagLock.WaitAsync(Ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
first.Dispose();
|
||||
sw.Stop();
|
||||
tagLock.Release();
|
||||
|
||||
// The old Dispose waited up to 2 s for exactly this close; it must not wait at all now.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500));
|
||||
await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "the Forward Close");
|
||||
(first.TeardownTask is null).ShouldBeFalse();
|
||||
first.TeardownTask!.IsCompleted.ShouldBeFalse(); // the close really is still pending
|
||||
|
||||
// An immediate re-create (what the driver does after an evict) must NOT open a second session while
|
||||
// the first one's Forward Close is in flight (review finding 22) — the pool holds it behind the close.
|
||||
var second = (NativeAbCipTagRuntime)factory.Create(Params(target.Port));
|
||||
var init = second.InitializeAsync(Ct);
|
||||
// Positive control (#583): the second open has reached the pool's predecessor wait and parked there, so
|
||||
// the missing Forward Open below is that wait holding it — not the open simply not having got that far.
|
||||
await FakeCipTarget.Until(() => pool.Diagnostics.PredecessorWaits == 1, Guard, "the re-create to park behind the close");
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
init.IsCompleted.ShouldBeFalse();
|
||||
|
||||
closeHeld.SetResult();
|
||||
await first.TeardownTask.WaitAsync(Guard, Ct);
|
||||
await init.WaitAsync(Guard, Ct);
|
||||
target.ForwardOpens.ShouldBe(2);
|
||||
second.GetStatus().ShouldBe((int)AbStatus.Ok);
|
||||
second.DecodeValue(AbCipDataType.DInt, null).ShouldBe(42);
|
||||
|
||||
second.Dispose();
|
||||
await second.TeardownTask!.WaitAsync(Guard, Ct);
|
||||
target.ForwardCloses.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The offline soak: N create → read → release cycles, each release fire-and-forget and each create
|
||||
/// immediately after it — the evict/re-create rhythm at its tightest. Every connection opened is closed
|
||||
/// (one Forward Open and one Forward Close per cycle, one TCP connection per cycle), no TCP connection is
|
||||
/// left open on the target, and the pool ends empty. A clock that runs fast lets the 1 s Forward Open
|
||||
/// throttle (press safety) pass every cycle without the test taking N seconds; the throttle is still
|
||||
/// exercised.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Repeated_create_and_release_cycles_leak_no_connection()
|
||||
{
|
||||
const int cycles = 25;
|
||||
await using var target = NewTarget();
|
||||
var pool = new DeviceSessionPool(new FastForwardClock());
|
||||
var factory = new NativeAbCipTagFactory(pool);
|
||||
|
||||
NativeAbCipTagRuntime? last = null;
|
||||
for (var i = 0; i < cycles; i++)
|
||||
{
|
||||
var rt = (NativeAbCipTagRuntime)factory.Create(Params(target.Port));
|
||||
await rt.InitializeAsync(Ct).WaitAsync(Guard, Ct);
|
||||
rt.DecodeValue(AbCipDataType.DInt, null).ShouldBe(42);
|
||||
rt.Dispose(); // not awaited — the next cycle starts at once
|
||||
last = rt;
|
||||
}
|
||||
|
||||
await last!.TeardownTask!.WaitAsync(Guard, Ct);
|
||||
await FakeCipTarget.Until(() => target.Server.OpenConnections == 0, Guard, "every TCP connection to close");
|
||||
target.ForwardOpens.ShouldBe(cycles);
|
||||
target.ForwardCloses.ShouldBe(cycles);
|
||||
target.Server.Connections.ShouldBe(cycles);
|
||||
pool.EntryCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real timers, but a monotonic clock that jumps 10 s on every read — so an interval measured between
|
||||
/// two reads has always "elapsed". The Forward Open throttle reads elapsed time this way; the only other
|
||||
/// reader, the keep-alive idle check, runs on a real-time timer and at worst sends a harmless keep-alive.
|
||||
/// </summary>
|
||||
private sealed class FastForwardClock : TimeProvider
|
||||
{
|
||||
private long _skew;
|
||||
|
||||
public override long GetTimestamp() =>
|
||||
System.GetTimestamp() + Interlocked.Add(ref _skew, 10 * System.TimestampFrequency);
|
||||
|
||||
public override long TimestampFrequency => System.TimestampFrequency;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user