Complete the incomplete Addressing-003 fix: TryParseByteOrder now produces a
diagnostic mentioning "field 2" when a known type-code token (e.g. BOOL) is
supplied in the byte-order slot, so the user is guided to the correct field.
The previous fix only wired the message in the else-branch, which was unreachable
because LooksLikeByteOrderToken(BOOL) returned true first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject --region Coils combined with any non-boolean --type with a CommandException
that names the constraint: coils carry a single bit, so only --type Bool is valid.
Without this check a write like "--region Coils --type UInt16 --value 42" would
silently coerce to a coil ON with no diagnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add --bit-index, --string-length, and --string-byte-order options to
SubscribeCommand, mirroring ReadCommand, and pass them into ModbusTagDefinition
so that BitInRegister and String type subscriptions use the correct bit index and
string length rather than silently defaulting to bit-0 / zero-length.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject an empty 3rd field in the address parser by checking parts[2].Length > 0
before the All(char.IsDigit) guard, so a trailing-colon typo like "40001:F:"
produces a diagnostic instead of silently parsing as a scalar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
StatusCode is not a .NET type reference in this assembly — replace the unresolvable
<see cref="StatusCode"/> with prose text so TreatWarningsAsErrors does not fail the
build on the CS1574 unresolved-cref warning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetMemoryFootprint() returned a constant 0 with a stale "PR 4.4 sets this" comment
even though PR 4.4 shipped the SubscriptionRegistry. Replace with a live estimate:
64 bytes × TrackedItemHandleCount + 256 bytes × TrackedSubscriptionCount. A 50k-tag
set now registers ~3 MB with the server's cache-flush heuristic instead of being
invisible. Returns 0 when no subscriptions are active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clear _tagsByName, _lastPublishedByRef, and _lastWrittenByRef in ShutdownAsync
(via the new shared TeardownAsync helper) so a ReinitializeAsync cycle starts
from a clean state, consistent with the existing _autoProhibited.Clear().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix two resource-management bugs in StartDeployWatcher / BuildDefaultHierarchySource:
(a) Replace the discarded `_ = StartAsync(...)` with an explicit task variable that
surfaces any synchronous InvalidOperationException (called-twice guard) rather than
silently swallowing it.
(b) Change both StartDeployWatcher and BuildDefaultHierarchySource to use ??= on
_ownedRepositoryClient so the first client created (by whichever path runs first)
is reused by the second path, preventing a second GalaxyRepositoryClient from being
created and the first from leaking past the driver's lifetime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implement IAsyncDisposable on GalaxyDriver so async sub-component disposals
(EventPump, AlarmFeed, MxSession, MxClient, RepositoryClient) are awaited rather
than blocked on GetAwaiter().GetResult(). DisposeAsync is now the primary path;
Dispose() delegates to it for using-statement compatibility. Each async component's
shutdown is awaited individually with a best-effort catch so a single slow shutdown
cannot prevent the rest of the cleanup sequence from running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HashSet<T>.First() enumeration order is unspecified and unstable across mutations, so
the "owner" handle attached to alarm events was non-deterministic when multiple alarm
subscriptions were active. Change _alarmSubscriptions from HashSet to List (preserving
insertion order) and pick [0] — the earliest-registered handle — as the deterministic
owner. The server routes transitions by SourceNodeId, not by handle, so the choice of
handle does not affect delivery to active subscribers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add StatusCodeMap.ToQualityCategoryByte(uint) so the StatusCode → quality-byte
mapping lives in one place next to its inverse (FromQualityByte). GalaxyDriver
OnPumpDataChange now delegates to the helper instead of duplicating the shift+switch
inline; a future edit to the OPC UA bit layout cannot silently desync the probe-health
decode. Unit tests in StatusCodeMapTests pin all three category buckets and the
round-trip invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
StatusCodeMap.FromMxStatus checked `success != 0` to determine success, but the
mxaccessgw proto contract explicitly documents that `success` is not a boolean and
that clients must branch on `category` (MX_STATUS_CATEGORY_OK), not on `success`
alone. Replace the raw field check with `status.IsSuccess()` from
MxStatusProxyExtensions, which requires both `success != 0` AND `category == Ok`.
A worker reporting success=1 with a non-OK category was previously misreported as
Good. Updated StatusCodeMapTests with a regression case covering the inverted scenario.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EffectiveCipPath now references ParsedAddress/Profile properties instead
of the captured primary-constructor parameters to avoid CS9124 (param
captured into enclosing type AND used to init a member).
NonZero_libplctag_status_maps_via_AbLegacyStatusMapper updated to pass
(int)Status.ErrorNotFound rather than the stale magic integer -14 that
the old mapper happened to handle but the new enum-based mapper does not.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file was physically deleted and unstaged in the Driver.AbCip-006
commit but the git rm was not included. Committed separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finding 005 revised approach: keep the parent Structure tag in
`_tagsByName` so the whole-UDT grouping planner can find it (required
for Driver.AbCip-003 opt-in path + alarm projection). Instead, detect a
direct read of a Structure-with-Members in `ReadSingleAsync` and return
`BadNotSupported` rather than Good/null — explicitly documenting the
contract that callers must address member paths. Duplicate-key checks
(scalar and member fan-out) remain.
Finding 014 test corrections: `Structure_parent_tag_read_returns_BadNotSupported`
now asserts the new contract. `Read_UDInt_tag_returns_uint_value_not_negative_wrapped_int`
assertion fixed to use `ShouldBeOfType<uint>()` instead of
`ShouldNotBe(-1)` (Shouldly overflows comparing uint.MaxValue with int).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consume previously-dead AbLegacyPlcFamilyProfile fields:
- DeviceState.EffectiveCipPath applies DefaultCipPath when the parsed host
address has an empty CIP path (SLC 500 / PLC-5 misconfigured without /1,0
now gets the profile-supplied default route). All three tag/parent/probe
Create() callers updated.
- InitializeAsync validates each tag's DataType against SupportsLongFile /
SupportsStringFile and throws InvalidOperationException at init time so a
MicroLogix Long tag or similar fails early rather than at runtime with an
opaque comms error.
- MaxTagBytes tracked as a follow-up (string/array chunking requires broader
design work).
Tests added for CipPath fallback and Long/String type validation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MapLibplctagStatus now casts the int to libplctag.Status and switches on
named enum members (mirroring AbCipStatusMapper) instead of unverified
magic integers. A strongly-typed Status overload is the canonical path;
the int overload delegates to it. MapPcccStatus is retained with a comment
marking it as the reference mapping for future PCCC-STS inspection.
Tests updated to use Status enum members rather than raw integers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add #pragma warning disable xUnit1051 at the top of ContractsWireParityTests.cs.
The xUnit1051 analyser fires on MessagePack's Serialize/Deserialize overloads that
have an optional CancellationToken parameter; these are synchronous parity tests
where the token is not meaningful — the suppression is scoped to this file only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System.Threading.Thread is in the System.Threading namespace (not
System.Threading.Thread), so the existing ForbiddenNamespacePrefixes
entry "System.Threading.Thread" never matched — the namespace prefix
check compared against the type's containing namespace, which is
System.Threading. Move Thread into ForbiddenFullTypeNames (alongside
Environment / AppDomain / GC / Activator) where it is matched by exact
fully-qualified type name, which actually fires. Remove the dead
namespace-prefix entry and document why. The Rejects_Thread_new_at_compile
test now passes. (Core.Scripting-010.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InitializeAsync catch block now mirrors ShutdownAsync teardown: cancels
and disposes probe CancellationTokenSources, calls DisposeRuntimes, and
clears _devices/_tagsByName before rethrowing. A caller that catches and
abandons (rather than retrying via ReinitializeAsync) no longer leaves
orphaned probe tasks or libplctag handles alive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mark _health volatile. The record-reference assignment is atomic, but
without an acquire/release memory barrier GetHealth() on another thread
can observe a stale snapshot indefinitely. volatile enforces the barrier
at read and write sites without a lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Throw InvalidOperationException at InitializeAsync when a tag's
DeviceHostAddress does not match any entry in the Devices list, naming
both the tag and the unresolved host. Previously the missing-device
check was guarded by a TryGetValue so a typo silently bypassed
capability-matrix validation and deferred the error to per-read
BadNodeIdUnknown — the opposite of the documented "fail at load" goal.
Also resolves findings 004, 005, and 006 in the same file:
- 004: DiscoverAsync now unconditionally emits ViewOnly for all user
tags; the Writable config field no longer influences security class
because the wire backend always returns BadNotWritable.
- 005: All _health reads use Volatile.Read and all writes use
Volatile.Write so concurrent readers observe a consistent reference
and read-modify-write sequences capture a stable snapshot.
- 006: EnsureConnectedAsync disposes and nulls any existing
non-connected client before creating a fresh one, preventing
ObjectDisposedException loops after a HandleRecycle race or teardown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtimes and ParentRuntimes changed from Dictionary to ConcurrentDictionary.
EnsureTagRuntimeAsync and EnsureParentRuntimeAsync now use a per-key
GetCreationLock semaphore with a double-checked pattern: fast-path read
requires no lock; slow-path create+initialize+store is serialised per key
so a concurrent caller waits rather than creating a duplicate runtime that
would be leaked when DisposeRuntimes runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `EvictRuntime` helper that removes + disposes a stale
`ConcurrentDictionary` entry. Call it from `ReadSingleAsync`,
`ReadGroupAsync`, and `WriteAsync` on non-zero libplctag status and
transport exceptions so the next call for the same tag re-creates a
fresh handle — mirroring the probe loop's recreate-on-failure pattern.
Value-conversion exceptions (NotSupportedException, FormatException,
InvalidCastException, OverflowException) are not transport faults and
do not evict the handle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply _config.MaxValuesPerRead as a bucket cap in ReadAggregateAsync,
mirroring the existing cap in ReadRawAsync. Without this guard a processed
read over a wide time range with a small IntervalMs could accumulate an
unbounded HistorianAggregateSample list; if the serialised reply exceeded
the 16 MiB FrameWriter frame cap WriteAsync would throw and the client
correlation-id wait would hang. Truncation now logs a Warning with a hint
to widen IntervalMs or reduce the time range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DecodeValue for Bit with no bitIndex now reads the full 16-bit word via
GetInt16(0) and tests bit 0 instead of GetInt8(0), which only covered the
low byte and silently misread any bit in positions 8..15. The comment
explains the two decode paths (suffix-present vs suffix-absent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add exponential backoff (250 ms → 500 ms → 1 s → 2 s → 4 s → 8 s cap) to
PipeServer.RunAsync after each connection-loop exception, replacing the spin
loop that previously pegged a CPU core and flooded the log on persistent errors
such as a duplicate pipe name or a failing PipeAcl.Create. After 20 consecutive
failures the method re-throws so the SCM / NSSM supervisor can restart the
sidecar cleanly. A clean connection (even a short-lived one) resets the counter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TryParse now rejects three classes of malformed PCCC address:
- Sub-element + bit-index together (e.g. T4:0.ACC/2) — never valid in PCCC
- File number on I/O/S system files (e.g. I3:0, S2:1) — single-letter only
- Sub-element on non-T/C/R files (e.g. B3:0.DN, N7:0.FOO) — only Timer,
Counter, and Control files carry structured elements
New helper predicates IsNoFileNumberLetter / IsSubElementFileLetter
keep the parser's intent clear. Regression tests added in AbLegacyAddressTests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In TimedScriptEvaluator.RunAsync, the catch (TimeoutException) block
now checks ct.IsCancellationRequested before throwing
ScriptTimeoutException, so a caller cancellation that races a timeout
deterministically surfaces as OperationCanceledException regardless of
which WaitAsync observes first. Regression test
Caller_cancellation_wins_even_when_timeout_fires_first added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DependencyExtractor.VisitInvocationExpression now additionally checks
that the member-access receiver is the identifier "ctx" before treating
a GetTag / SetVirtualTag call as a ScriptContext dependency. This
prevents spurious dependencies when a script defines a local helper type
with a matching method name and calls it as other.GetTag("X"). Test
Ignores_member_access_GetTag_on_non_ctx_receiver added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add System.Threading.Tasks to ForbiddenNamespacePrefixes so scripts
cannot use Task.Run / Parallel to spawn background work that outlives
the per-evaluation timeout. Document the unbounded-memory accepted
trade-off and the Task denial rationale in docs/VirtualTags.md (new
"Known resource limits" subsection) and cross-reference from
docs/ScriptedAlarms.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the string-vs-numeric value selection from raw and at-time read
loops into a SelectValue helper method. aahClientManaged's HistoryQueryResult
has no data-type field in the bound SDK version, so the heuristic (prefer
StringValue when non-empty and Value==0) is unavoidable; the helper now
documents the limitation explicitly in its XML doc so the known edge case
(numeric tag at exactly zero with a formatted StringValue) is self-evident.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PlcTagHandle` and `DeviceState.TagHandles` were dead scaffolding: the
`ReleaseHandle` no-op never called `plc_tag_destroy` and the dict was
never populated. Removed the file, the dead dict, and its
`DisposeHandles` loop. Updated the `AbCipDriver` class doc to document
that native lifetime is owned by libplctag.NET `Tag.Dispose()` (invoked
from `DisposeHandles`) with the library's own finalizer covering any
GC-collected instances. Two test methods that only exercised the dead
`PlcTagHandle` class removed from `AbCipDriverTests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Normalise req.Events to Array.Empty<AlarmHistorianEventDto>() immediately
after MessagePack deserialization in HandleWriteAlarmEventsAsync. MessagePack
deserializes an absent or explicit-nil array field as null, not Array.Empty,
so a peer that sends a null Events array would trigger a NullReferenceException
on either .Length dereference (no-writer branch or catch block), leaving the
client correlation-id wait hanging with no reply frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document explicitly that WriteBatchAsync never returns PermanentFail because
the WriteAlarmEventsReply wire contract carries only a bool-per-event (no
unrecoverable/transient distinction). Add a <remarks> XML block explaining
the structural limitation, why poison events retry rather than dead-letter,
and that a coordinated per-event status enum extension to the .NET 4.8
sidecar is a tracked follow-up. Add inline NOTE comments in both the
success and catch paths for discoverability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce DeserializeSampleValue() helper that enforces a 64 KiB per-sample
ValueBytes size cap before calling MessagePackSerializer.Deserialize<object>,
and documents that the default StandardResolver (primitive-only, no typeless
or dynamic-type resolution) is in use. Both ToSnapshots and AlignAtTimeSnapshots
route through the new helper. Add inline XML comments to the two NuGetAuditSuppress
entries in the csproj recording the advisory title, why each does not apply to
this module's primitive-only deserialization, and when to revisit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Structure tags with declared Members no longer register the bare parent
name in `_tagsByName` — reading it would return Good/null, which is
misleading. Clients read individual member paths. Both the member
fan-out and the scalar-tag paths now perform a duplicate-key check that
throws `InvalidOperationException` naming both colliding entries (fail-
fast, consistent with the AbCipHostAddress validation pattern).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ToDriverDataType` mapped LInt/ULInt to Int32 (truncation) and UDInt
to Int32 (negative wrap for values > Int32.MaxValue). DriverDataType
already carries Int64/UInt64/UInt32, so map each Logix 64-bit and
unsigned-32-bit type to the correct member. `DecodeValueAt` in
`LibplctagTagRuntime` updated to return uint/ulong for UDInt/ULInt
so the runtime value type agrees with the declared OPC UA type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the synchronous non-cancellable _stream.ReadByte() for the kind byte
in FrameReader.ReadFrameAsync with an async ReadExactAsync(new byte[1], ct)
call so the full frame read honours the EffectiveCallTimeout-linked token
and cannot wedge the call gate when the sidecar stalls mid-frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WriteCommand.ParseValue wraps FormatException/OverflowException as
CliFx CommandException so a bad --value yields a clean one-line CLI error
naming the value and target type instead of a raw .NET stack trace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Driver.AbCip.Cli-001: WriteCommand.ParseValue wraps FormatException/
OverflowException as CommandException so bad --value input yields a clean
CLI error instead of a raw stack trace.
Driver.AbCip.Cli-002: probe/read/subscribe commands reject Structure types
up front (RejectStructure helper), matching the write guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ConfigureLogging is now idempotent via a _loggingConfigured guard field so
repeated calls from subclasses do not abandon and leak the previous logger.
The previous Log.Logger is disposed before overwriting to release its
console-sink resources cleanly.
A new protected static FlushLogging() helper calls Log.CloseAndFlush() so
commands can guarantee buffered output is flushed in their finally blocks
before the process exits — important for the long-running subscribe verb.
XML doc updated to reflect call-once semantics and document FlushLogging().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FormatStatus now matches named codes against code & 0xFFFF0000 (high-word
mask) rather than exact equality, so status codes carrying sub-code or flag
bits in the low 16 bits (e.g. 0x80050001) still resolve to their named class.
For codes not in the named shortlist a severity-class fallback using the top
2 bits always emits Good / Uncertain / Bad rather than bare hex.
Updated the stale FormatStatus_unknown_codes_fall_back_to_hex_only test (its
expectation became invalid once the severity-class fallback was added) and
added new Theory cases exercising both the high-word matching and the
severity-class fallback paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core.VirtualTags-002: cold-start guard publishes BadWaitingForInitialData
instead of silently returning a stale value.
Core.VirtualTags-003: Load detects duplicate Path values and keys the
upstream-subscription loop off the registered tag set.
Core.VirtualTags-005: VirtualTagSource fires the initial-data callback per
path before registering the change observer, fixing an ordering race.
Core.VirtualTags-008: DependencyGraph caches topological rank, lowering
per-change-event cost from O(V+E) to O(closure).
Core.VirtualTags-012: added 9 engine tests; CoerceResult null-return now
maps to BadInternalError as the code comment intended.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core.Abstractions-001: PollGroupEngine compares array values with structural
equality so a driver returning a fresh T[] each poll no longer fires spuriously.
Core.Abstractions-002: PollOnceAsync guards reader result cardinality and
throws a descriptive InvalidOperationException on mismatch instead of a
swallowed ArgumentOutOfRangeException that stalled the subscription.
Core.Abstractions-003: the poll loop Task is tracked; Unsubscribe/DisposeAsync
await loop completion before disposing the CTS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add engine-level tests covering the six gaps identified in the finding:
(1) timed-shelve auto-expiry driven via injectable clock + RunShelvingCheckForTest
hook so timer tests are deterministic;
(2) ConfirmAsync, TimedShelveAsync/UnshelveAsync round-trip, EnableAsync engine
methods exercised end-to-end;
(3) OnEvent subscriber-throws isolation — engine state advances and stays
operational after a subscriber throws;
(4) IAlarmStateStore.SaveAsync failure leaves in-memory state unchanged (locks in
the persist-before-update invariant from finding-007);
(5) second LoadAsync does not leak the old timer (regression for finding-002);
(6) AreInputsReady cold-start guard correctly blocks on Bad/missing inputs and
allows Uncertain-quality inputs through.
Expose RunShelvingCheckForTest() internal method on ScriptedAlarmEngine to
support deterministic timer tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SubscribeAsync now wraps each driver handle in a private HostBoundHandle
that carries the resolved host name. UnsubscribeAsync unwraps it and
routes through the recorded host's resilience pipeline, correctly
charging the subscription's originating host's circuit breaker/bulkhead
instead of always using the default host. Falls back to the default
host for handles not created by this invoker. Two regression tests
added; update findings.md Open count from 10 to 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>