Compare commits

...

41 Commits

Author SHA1 Message Date
Joseph Doherty 3baa332d6b Merge: over-the-wire test of the shared client's alarm method calls against the in-process server
v2-ci / build (push) Successful in 4m36s
v2-ci / unit-tests (push) Failing after 19m50s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:44 -04:00
Joseph Doherty d66269a99b test(client): over-the-wire alarm method calls against the in-process server
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:32 -04:00
Joseph Doherty d978573fb2 Merge #591: FOCAS duplicate-sibling tests wait for the first axis pass, not the bootstrap stamp
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:41 -04:00
Joseph Doherty 159f30dcd0 test(focas): wait for the first full poll, not the bootstrap stamp, before reading (#591)
LastFixedPollUtc is stamped by the fixed-tree bootstrap before any axis snapshot
is published, so under load Each_duplicate_axis_reads_its_own_position could read
inside that window and get BadNodeIdUnknown. The helper now waits for the last
axis slot plus the servo/spindle caches. 45/45 green under 14-core yes load.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:25 -04:00
Joseph Doherty 3803df0433 Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:23:20 -04:00
Joseph Doherty e2663643dd fix(alarms): native conditions support Acknowledge/AddComment only; report only real method changes (#587 review)
Review of 167a48bf (FIX-FIRST): making native Confirmed/Shelving method-owned produced over-the-wire
regressions — a native TimedShelve never expired, a native OneShotShelve was silently undone by
SetActiveState, an OPC UA ack left a native alarm Unconfirmed forever (ScadaBridge maps ConfirmedState),
and any local state was per-node, never reaching the redundant peer.

- Reverted: the driver projection owns native Confirmed/Shelving again.
- Native Confirm / OneShotShelve / TimedShelve / Unshelve return BadNotSupported with no event, as native
  Enable/Disable already did. Scripted alarms keep every method.
- A native Acknowledge re-asserts ConfirmedState=true and the projection's Retain rule (Active || !Acked)
  before its single report. The native condition starts Confirmed.
- The method path reports only when the state actually moved (before/after). A re-ack is silent and
  restores the Confirmed/Retain that the SDK flips, so a scripted node never drifts from its engine.
  AddComment always reports.
- Docs: native alarms support Acknowledge/AddComment only; the Secondary-node native-ack flap is documented.

Tests: refused native methods (5), native ack stays Confirmed through echo/clear/reactivate, scripted
TimedShelve expires with exactly one unshelve event, re-ack silent (native + scripted). Each fix falsified.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:19:38 -04:00
Joseph Doherty 85f003272c Merge: Client ack/confirm/enable/disable/shelve call Part 9 methods on the condition node (no .Condition/.ShelvingState string ids)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:23 -04:00
Joseph Doherty fa7efb3b62 fix(client-cli): call Part 9 ack/confirm/enable/shelve methods on the condition node itself
The client appended .Condition / .ShelvingState to the condition id; neither child exists on the
OtOpcUa server (conditions are their own SourceNode, ShelvingState is an SDK numeric ns0 node), so
every CLI ack/confirm/shelve/enable/disable returned BadNodeIdUnknown.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:06 -04:00
Joseph Doherty 167a48bffa fix(alarms,acl): one condition event per Part 9 method call; gate Server-object model-change/audit events (#587, #588)
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's
ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node
already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each
method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed
re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise
AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies
it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report
every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0
and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed.

#588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent
(ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the
Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus
the SDK audit/encryption guard. Flag off: unchanged.

#586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per
condition ⇒ O(N²) deploys under Lock).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:54:08 -04:00
Joseph Doherty 15dd75379d Merge #590: ClusterRoleInfo records the RoleLeaderChanged address directly (lagging State snapshot could pin a role leader to null)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:26:16 -04:00
Joseph Doherty 129b84fd69 fix(cluster): record RoleLeaderChanged leader from the event, not the lagging state snapshot (#590)
ClusterRoleInfo resolved the leader address through Cluster.State.Members, which is
published asynchronously; a miss stored null permanently, so RoleLeader(role) stayed null
(flaky test timed out under load: 6/10 failing with CPU contention, 0/20 after). Only the
address is ever read, so store it directly. Adds a deterministic unit test (fails without
the fix) and InternalsVisibleTo for the Cluster.Tests project.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:25:57 -04:00
Joseph Doherty e8453eef6c Merge #589: S7 span-read tests wait out the clock tick before asserting LastSuccessfulRead advanced
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:25 -04:00
Joseph Doherty b36f5644d3 test(s7): wait out the clock tick before asserting LastSuccessfulRead advanced (#589)
The driver stamps DateTime.UtcNow at init and again per read pass; under CPU
contention both land in the same tick, so the strict greater-than assertions
flaked (reproduced 1/40 under load, sibling test A_cancelled_pass_still_flushes...
failed identically with equal timestamps). Test-only race; no production defect.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:11 -04:00
Joseph Doherty 390c8269a0 Merge #586: Server-object (i=2253) event items gated by the data-plane ACL; per-condition delivery with SDK audit guard + monitored bookkeeping
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:09:03 -04:00
Joseph Doherty fe92ff4c34 fix(acl): keep the SDK's audit guard and EventsMonitored bookkeeping on Server-object delivery (#586)
Review of 75e559f6:
- OnConditionReportEvent now mirrors MonitoredNode2's AuditEventState guard (only while Server.Auditing,
  over SignAndEncrypt / https-uabinary). Conditions report their Part 9 method audit events through
  themselves, so the first cut queued AuditConditionAcknowledge (i=8944) etc. to every i=2253 item with
  auditing off -- the removed folder->Server forward used to drop them.
- ReconcileAllEventsMonitoring keeps one AreEventsMonitored unit on every materialised condition while any
  i=2253 item exists (called from SubscribeToAllEvents, materialise, removal, rebuild), so
  ConditionState.EventsMonitored() is true on a server whose only subscribers are Server-object items.
  A protected SubscribeToEvents override no-ops all-events items, which stops the SDK's AddRootNotifier
  (unsubscribe: true bug) taking that unit back from conditions rebuilt under a live item.

Flag-off over-the-wire coverage: Acknowledge/AddComment with and without a folder co-subscriber (no audit
event, transitions once each), EventsMonitored on/after-rebuild/off, rebuild survival with one copy per
event, native + scripted conditions once each, refresh once per condition with RefreshStart/End; plus an
ACL-on native-vs-scripted notifier-folder decision. Falsified: removing the audit guard reds both audit
cases; removing the reconcile reds the monitored test.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:05:12 -04:00
Joseph Doherty 277cd6ee3e Merge: deterministic AbCip transport-switch probe tests (libplctag timeout races the probe cancel under load)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:34 -04:00
Joseph Doherty fe2b0614c8 test(abcip): deterministic transport-switch probe tests
The libplctag-path positive control asserted 'timed out', but libplctag's internal
timeout equals the probe timeout and races the probe's own cancellation; under CPU
load it surfaces as 'ErrorTimeout' instead. Accept either libplctag stall outcome
(native never produces either: it fails at once with 'Connection lost').

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:11 -04:00
Joseph Doherty 75e559f6d8 fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)
Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root
notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded
to the core Server object, which this node manager never sees.

- SubscribeToAllEvents override records the item and attaches it to nothing.
- Each condition's own OnReportEvent (fires once per event) 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; otherwise mirrors MonitoredNode2.OnReportEvent.
- The root-notifier forward to the core Server object (OnReportEvent override) is dropped.
- ConditionRefresh on an i=2253 item is filtered the same way (once per condition).
- Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and
  revocation re-checked per call). Inert with Security:Acl:Enabled=false.

Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by
deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or
restoring the forward, turns the enforcement tests red.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:37:47 -04:00
Joseph Doherty e5fe2e5547 Merge #566/#581/#583: per-pool transport diagnostics + per-device open-attempt limit, CIP-level close recycle, positive-control absence tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:31:17 -04:00
Joseph Doherty fe94a3bdb8 test(ab): replace fixed-delay absence assertions with positive controls / processed counts (#583)
Ab.Transport.Tests: predecessor-wait counter instead of 300 ms (DeviceSessionPoolTests x2), throttle
queue/interval-park hooks instead of 50/100 ms (ForwardOpenThrottleTests x2), UnobservedTaskProbe with a
must-report control fault instead of N GC cycles (EncapsulationClientTests), re-armed keep-alive timer as
the control for the never-opened keep-alive test.
AbCip.Tests: still-polling sibling / control driver read counts instead of 300-500 ms (subscriptions x4,
alarm projection x5), structural ProbeTask==null + enabled-probe control (host probe x2), predecessor
wait (NativeAbCipLeaseReleaseTests), processed waits in the shutdown/logging tests.
AbLegacy.Tests: sibling-subscription control (Unsubscribe_halts_polling), ProbeCts==null + control.
AbCip.IntegrationTests differential safety: a post-phase control open through the same throttle instead
of 1.5-2.5 s sleeps (via a native tag init, keeping the harness read-only guard green).
Fake tag ReadCount made interlocked; CapturingLogger entries snapshotted under a lock.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:28:33 -04:00
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00
Joseph Doherty 35152fcaf7 Merge #562/#563: event/alarm items revalidated by both ACL triggers (reversible stand-down); key usability on WriteOperate/AlarmAck gates regardless of Security:Acl:Enabled
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:11:22 -04:00
Joseph Doherty 2b945672e9 fix(acl): revalidate event/alarm subscriptions on both triggers; key-usability check on role gates (#562, #563)
#562: direct event (alarm) monitored items are now tracked in a second map
(_eventItems, fed by the SubscribeToEvents override) and re-authorized by
RevalidateMonitoredItems on both triggers (DataPlaneAuthorityState.Changed and
AclRevalidationTimer). A denied item is detached via the SDK's own protected
SubscribeToEvents(unsubscribe: true) - never MonitoringMode.Disabled - and
re-attached on restore; a client delete of a stood-down item is absorbed.
Over-the-wire tests: revoke -> stop (positive control on a still-granted
notifier) -> restore -> resume; clock-driven expiry via the real timer with a
control session; delete-while-stood-down is not resurrected; unexpired
negative control. Server-object (all-events) subscriptions are documented as
outside the ACL (observed over the wire).

#563: DataPlaneAuthorizer.AuthorizeKeyUsable runs the FindUsableKey probe on
the WriteOperate and AlarmAck role gates regardless of Security:Acl:Enabled,
so an expired or deployment-disabled key loses its activation-time roles on
its open session. Anonymous (no key id) is unaffected; reads/subscriptions
stay ungated with the flag off. No new flag.

Docs: docs/security.md and the CLAUDE.md API-key/ACL section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:10:35 -04:00
Joseph Doherty 501f6ea4e5 Merge #570/#579: native AbCip per-tag config errors, no mistype churn, STRING 82, LWORD type checks; non-blocking lease release (AbCip + AbLegacy)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
2026-10-07 23:06:52 -04:00
Joseph Doherty 007174d28a Merge #573: AbLegacy fake tag factory is thread-safe (probe raced the test's tag create)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:06:07 -04:00
Joseph Doherty 4e6eed7454 fix(abcip): native runtime STRING capacity, mistype churn, malformed-name config error, LWORD type check; non-blocking lease release (#570, #579)
#570
- STRING: the built-in 88-byte Logix STRING holds 82 characters (LEN + DATA[82] + 2 pad), not 84; the
  write still sends the whole 88-byte structure and a read never decodes the pad as characters.
- Mistype churn: a type refusal (CIP 0xFF/0x2107) on the existence read no longer throws from
  InitializeAsync, so the runtime is not disposed/re-created (and re-Forward-Opened) every poll; the driver
  also keeps a runtime that throws TypeMismatch from init and records the device as reachable.
- Malformed tag name: a TagPath that does not parse, or a name the CIP symbolic encoder refuses (now also
  non-ASCII, empty member, >255-char member, unterminated subscript), is AbCipTagConfigurationException ->
  per-tag BadConfigurationError (named const in AbCipStatusMapper), warned once, never feeding the device
  backoff or health; siblings keep reading. A malformed probe tag no longer feeds the backoff either.
- Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are type-checked as integers of their width: REAL/LREAL or a
  narrower integer over LWORD is TypeMismatch, not decoded garbage.

#579
- ConnectionLease.ReleaseInBackground: one non-blocking release shape for both native AB runtimes. The
  AbCip runtime's Dispose no longer waits up to 2 s under the per-tag lock; the pool still registers the
  close synchronously so a re-create cannot overlap it. Offline soak tests (25 cycles, AbCip + AbLegacy)
  prove no leaked connection; FakeEipServer gains OpenConnections.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:49 -04:00
Joseph Doherty 8c685bcbba test(ablegacy): make FakeAbLegacyTagFactory.Tags thread-safe — probe loop raced the test thread (#573)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:34 -04:00
Joseph Doherty 1ab961c226 docs(abcip): browsePageDelayMs survives an AdminUI save now that the form preserves unknown keys (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:04:01 -04:00
Joseph Doherty 7105ef3eb5 Merge #574/#576/#577/#584: AB forms preserve unknown keys + write factory ms keys, /raw device-name + prerender fixes, CLI read prints Bad status, docs dot-form
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:48 -04:00
Joseph Doherty bafbcbee7e fix(adminui): AB forms read/write the factory-bound *Ms duration keys, migrate legacy TimeSpan strings (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:38 -04:00
Joseph Doherty 4ba6cbab3a Merge #572/#575: native browse paging/lease/pacing, differential equal-refusal + budget isolation
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/AbCip.md
2026-10-07 23:01:26 -04:00
Joseph Doherty be421a4cb0 fix(abcip): native browse paging/pacing/template lease (#572); differential equal-refusal + budget isolation (#575)
#572: listing continues at max(instance in page)+1 (no ascending-order assumption, no-progress guard kept);
browsePageDelayMs (default 5 ms) paces pages; DiscoverAsync reads templates through one batch reader
(IAbCipTemplateReaderFactory.CreateBatch) holding one lease.
#575: differential harness reports a same-status template refusal as an equal refusal; offline test pins the
open-attempt budget without relying on the abort ending the phase.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:42 -04:00
Joseph Doherty 29cd25dec8 Merge #568/#569/#580/#585: shared AB status map + orphan release, confinement guard tokeniser, adapter + DefaultTransport tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:30 -04:00
Joseph Doherty 341417f193 fix(ab): share status map + libplctag orphan release, harden confinement guard, pin width-mismatch + DefaultTransport wiring (#568 #569 #580 #585)
- Ab.Transport: AbOpcUaStatusMap is the single AbStatus->OPC UA table; AbCip/AbLegacy mappers delegate.
- Remove dead MapCipGeneralStatus / MapPcccStatus / AbLegacy MapException (+ tests).
- LibplctagOrphanRelease is one source file (Ab.LibplctagShared) compiled into both drivers.
- LibplctagConfinementGuardTests: tokenising comment/literal stripper, block comments, csproj PackageReference guard.
- LibplctagStatusTranslationTests: real LibPlcTagException round trip, WrapSync/Wrap OCE pass-through, adapter routes decode via WrapSync.
- AbCipTypeMismatchTests: libplctag width-mismatch path (BadTypeMismatch, handle kept, no Degraded, warn once).
- Host: AddAbCipDriverDefaults extension + Program.cs source pin for Drivers:AbCip:DefaultTransport.
- docs/drivers/AbCip.md: layout rows + benign Degraded-after-outage note.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:06 -04:00
Joseph Doherty 3d081ad29c fix(adminui,cli): /raw device-name placeholder + no-prerender tree, CLI read prints Bad status (#577)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:56:47 -04:00
Joseph Doherty 13a6727033 Merge #564/#565: CIP codec hardening, EncapsulationClient gate/timeout bounds
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:55:02 -04:00
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00
Joseph Doherty bd2bebeda6 Merge #567/#582: RequestScheduler reply-size budget, private lock, fragment struct-handle check
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:42 -04:00
Joseph Doherty 9641cefe2e fix(abcip): budget expected reply size in RequestScheduler.TakeBatch (#582); private lock + struct-handle check across read fragments (#567)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:10 -04:00
Joseph Doherty e588443ced fix(abcip,adminui): ParseEnum device wording + libplctag probe assertion (#574); AB forms preserve unknown keys + MaxConcurrentReads (#576); probe off for new AbCip driver (#577); docs T4:0.DN (#584)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:04 -04:00
Joseph Doherty 04b6563286 Merge native .NET Allen-Bradley transport (AbCip native default; AbLegacy native opt-in; real-press gated)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:40:06 -04:00
122 changed files with 8046 additions and 902 deletions
+60 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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) |
+4 -4
View File
@@ -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"/>
@@ -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>&lt;Compile Include&gt;</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 (&gt; 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 = [];
@@ -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);
}
@@ -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,
};
@@ -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;
}
}
+1 -2
View File
@@ -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;
@@ -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);
}
@@ -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");
@@ -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
@@ -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);
@@ -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);
}
@@ -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);
@@ -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);
}
@@ -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;
@@ -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));
}
@@ -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()
{
@@ -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);
}
}
@@ -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
}
}
@@ -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}";
@@ -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()
{
@@ -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);
@@ -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++)
@@ -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,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);
@@ -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