Files
lmxopcua/code-reviews/Driver.AbLegacy/findings.md
T
Joseph Doherty 91e2609560 review(Driver.AbLegacy): fix Bit write 1-byte/2-byte encode-decode mismatch (Medium)
Re-review at 7286d320. -014 (Medium): Bit EncodeValue (no bitIndex) wrote SetInt8 while
DecodeValue read GetInt16 on a 16-bit B-file element, so a false write could round-trip
as true (stale high byte). Fix: SetInt16 + TDD. -015: tests pass CancellationToken.
2026-06-19 11:47:11 -04:00

483 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Code Review - Driver.AbLegacy
| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 0 |
## Checklist coverage
A comprehensive review completes every category, recording "No issues found" where
a category produced nothing rather than leaving it blank.
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.AbLegacy-001, Driver.AbLegacy-002, Driver.AbLegacy-003, Driver.AbLegacy-004, Driver.AbLegacy-014 |
| 2 | OtOpcUa conventions | Driver.AbLegacy-005 |
| 3 | Concurrency & thread safety | Driver.AbLegacy-006, Driver.AbLegacy-007, Driver.AbLegacy-008 |
| 4 | Error handling & resilience | Driver.AbLegacy-009, Driver.AbLegacy-010 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.AbLegacy-011 |
| 7 | Design-document adherence | Driver.AbLegacy-012 |
| 8 | Code organization & conventions | Driver.AbLegacy-013 |
| 9 | Testing coverage | Driver.AbLegacy-015 |
| 10 | Documentation & comments | No issues found |
## Findings
### Driver.AbLegacy-001
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | `AbLegacyAddress.cs:54`, `AbLegacyDriver.cs:368-374` |
| Status | Resolved |
**Description:** `AbLegacyAddress.TryParse` accepts a `BitIndex` of `0..31` for every
file type. A PCCC N-file word is a signed 16-bit integer, so valid bit indices are
`0..15`. When a tag is `Bit`-typed against an N-file with a bit suffix of `16..31`
(e.g. `N7:0/20`), `WriteBitInWordAsync` reads the parent as `AbLegacyDataType.Int`
(16-bit), then computes `current | (1 << bit)` / `current & ~(1 << bit)` with `bit`
up to 31. `1 << 20` produces a value outside the 16-bit range, the result is cast
`(short)updated`, and the high bits are silently truncated - the wrong bit (or no
bit) is written and no error is surfaced. The mask arithmetic is also done on a
sign-extended `int`. For L-file (32-bit) bits the parent is still read as `Int`
(16-bit), so bits 16..31 of a long can never be addressed correctly.
**Recommendation:** Validate `BitIndex` against the parent word width during parse or
in `WriteBitInWordAsync` - reject bit > 15 for N/B/I/O/S files and bit > 31 for L
files. For bit-in-word RMW against L files, read the parent as `Long`. Mask the
read-back value to the word width before applying the bit operation.
**Resolution:** Resolved 2026-05-22 — `AbLegacyAddress.TryParse` now range-checks the
bit index against per-file word width (0..15 for N/B/I/O/S/A, 0..31 for L, no bits on
F); `WriteBitInWordAsync` reads/writes an L-file parent as 32-bit `Long` and masks the
RMW arithmetic to the native width so sign-extension can no longer corrupt high bits.
### Driver.AbLegacy-002
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `AbLegacyDriver.cs:368` |
| Status | Resolved |
**Description:** In `WriteBitInWordAsync` the parent word is decoded with
`Convert.ToInt32(parentRuntime.DecodeValue(AbLegacyDataType.Int, ...))`.
`LibplctagLegacyTagRuntime.DecodeValue` for `AbLegacyDataType.Int` returns
`(int)_tag.GetInt16(0)` - a sign-extended `int`. When the current word has its high
bit set (value 0x8000..0xFFFF, decoded as a negative `int`), the subsequent
`(short)updated` cast re-encodes the low 16 bits correctly, but `current | (1 << bit)`
is performed on the sign-extended value. The result is bit-correct for the low 16
bits only because the cast preserves them; any future change to widen the mask range
will break silently. Combined with finding 001 this is a latent correctness hazard.
**Recommendation:** Mask `current` to `current & 0xFFFF` before the bit operation and
operate on an explicitly 16-bit value, or document the reliance on low-16-bit
preservation explicitly.
**Resolution:** Resolved 2026-05-22 — `current & widthMask` already applied in `WriteBitInWordAsync` by the -001 fix; no additional change needed.
### Driver.AbLegacy-003
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `AbLegacyAddress.cs:62-95` |
| Status | Resolved |
**Description:** `TryParse` does not reject several malformed PCCC addresses that the
XML docs imply are invalid:
- A sub-element and a bit index together (`T4:0.ACC/2`) parse successfully even
though no PCCC element supports both.
- I/O/S files with a file number (`I3:0`, `S2:1`) parse successfully - I/O and S are
single-letter files with no file number per the doc comment, but the parser only
requires "letter then optional digits".
- B-file addresses with a sub-element (`B3:0.DN`) parse successfully.
`ToLibplctagName()` re-emits whatever was parsed, so a malformed address is passed
through to libplctag rather than rejected early with a clear error.
**Recommendation:** Tighten the parser: reject sub-element + bit-index combinations,
reject file numbers on I/O/S, and restrict which file letters may carry a sub-element
(T/C/R only). Add unit coverage for the rejection cases.
**Resolution:** Resolved 2026-05-22 — `TryParse` now rejects sub-element+bit-index combinations, file numbers on I/O/S files, and sub-elements on non-T/C/R files; unit tests added in `AbLegacyAddressTests`.
### Driver.AbLegacy-004
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `LibplctagLegacyTagRuntime.cs:36-37` |
| Status | Resolved |
**Description:** `DecodeValue` for `AbLegacyDataType.Bit` with `bitIndex == null`
returns `_tag.GetInt8(0) != 0`. A bit-file element (`B3:0/0`) is a single bit inside
a 16-bit word; reading only the low byte (`GetInt8(0)`) means a `Bit` tag whose live
bit sits in bits 8..15 of the word, or a B-file element addressed without an explicit
bit suffix, decodes incorrectly. The driver passes `parsed.ToLibplctagName()` which
preserves the `/bit` suffix, so libplctag resolves the bit when a suffix is present -
but a `Bit`-typed tag configured with an address that has no `/bit` suffix (e.g.
`B3:0`) silently decodes the wrong thing.
**Recommendation:** For `Bit` with no `bitIndex`, decide explicitly: either require a
bit suffix on `Bit`-typed tags (validate in `CreateInstance`/`DiscoverAsync`) or
decode the full 16-bit word and test bit 0.
**Resolution:** Resolved 2026-05-22 — `DecodeValue` for `Bit` with no `bitIndex` now reads the full 16-bit word via `GetInt16(0)` and tests bit 0, avoiding the silent half-word truncation from `GetInt8`.
### Driver.AbLegacy-005
| Field | Value |
|---|---|
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | `AbLegacyDriver.cs` (whole file) |
| Status | Resolved |
**Description:** The driver uses no `ILogger`/Serilog at all. Probe-loop failures,
runtime initialisation failures, libplctag non-zero statuses, and read/write
exceptions are folded into `DriverHealth.Detail` strings but never logged. CLAUDE.md
names Serilog with a rolling daily file sink as the logging library. The complete
absence of structured logging makes field diagnosis of a PCCC comms problem (timeout
vs route failure vs wrong PLC family) rely entirely on a single overwritten `Detail`
string that the next read or write immediately clobbers.
**Recommendation:** Inject `ILogger<AbLegacyDriver>` (optional, like `tagFactory`) and
log probe transitions, runtime-init failures, and the first occurrence of a non-zero
libplctag status per device.
**Resolution:** Resolved 2026-05-23 — `AbLegacyDriver` now accepts an optional
`ILogger<AbLegacyDriver>` (falls back to `NullLogger`), mirroring the Modbus / S7 /
Galaxy driver pattern. `InitializeAsync` catch-path logs the init failure at Error
level; `TransitionDeviceState` logs every probe transition (Warning on downgrade to
Stopped, Information on recovery); `ReadAsync` logs the first non-zero libplctag
status per device at Warning level via a re-armable `DeviceState.FirstNonZeroStatusLogged`
latch so a permanently-bad PLC doesn't flood the rolling file. `AbLegacyDriverFactoryExtensions.Register`
gains an optional `ILoggerFactory` parameter so the Server bootstrap can wire DI
logging when it chooses; the legacy single-arg `CreateInstance` overload stays for
back-compat. Regression coverage in `AbLegacyLoggerInjectionTests`.
### Driver.AbLegacy-006
| Field | Value |
|---|---|
| Severity | High |
| Category | Concurrency & thread safety |
| Location | `AbLegacyDriver.cs:107-158`, `AbLegacyDriver.cs:162-234`, `LibplctagLegacyTagRuntime.cs` |
| Status | Resolved |
**Description:** A per-tag `IAbLegacyTagRuntime` (wrapping a single libplctag `Tag`)
is cached in `DeviceState.Runtimes` and reused. `ReadAsync` (called directly by the
server read path) and the `PollGroupEngine` poll loop (which also calls `ReadAsync`
via the reader delegate) can run concurrently, and two poll subscriptions covering
the same tag run on independent background tasks. All of them call
`EnsureTagRuntimeAsync` to the same `Tag` instance and call `runtime.ReadAsync` /
`GetStatus` / `DecodeValue` with no synchronisation. A libplctag `Tag` is not safe
for concurrent operations on the same handle: an interleaved Read/GetStatus/DecodeValue
from two threads can read a value mid-update or observe a status that belongs to the
other operation. `WriteAsync` shares the same runtime dictionary and compounds the
hazard. Only the bit-in-word RMW path is serialised (per-parent `SemaphoreSlim`).
**Recommendation:** Serialise all operations against a given runtime - a per-runtime
`SemaphoreSlim`, or a per-device read lock - so no two threads touch the same `Tag`
handle concurrently.
**Resolution:** Resolved 2026-05-22 — added a per-runtime `SemaphoreSlim`
(`DeviceState.GetRuntimeLock`, keyed by tag name); `ReadAsync` and `WriteAsync` now
hold it around the whole Read→GetStatus→Decode / Encode→Write→GetStatus sequence so the
shared libplctag `Tag` handle is never touched by two threads at once.
### Driver.AbLegacy-007
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `AbLegacyDriver.cs:411-438`, `AbLegacyDriver.cs:386-409` |
| Status | Resolved |
**Description:** `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` are
check-then-act: `device.Runtimes.TryGetValue(...)` then, after `await
runtime.InitializeAsync`, `device.Runtimes[def.Name] = runtime`. `Dictionary` is not
thread-safe, and two concurrent callers for the same tag (read + poll, or two poll
loops) both miss the lookup, both Create + InitializeAsync a runtime, and both write
the dictionary. One runtime is overwritten and leaked - `DisposeRuntimes` only
disposes what is currently in the dict - and concurrent `Dictionary` writes can
corrupt internal state. `ParentRuntimes` has the identical pattern.
**Recommendation:** Replace the runtime caches with `ConcurrentDictionary` and use
`GetOrAdd`, or guard runtime creation under a per-device lock. Ensure the losing
runtime of any race is disposed.
**Resolution:** Resolved 2026-05-22 — `Runtimes` and `ParentRuntimes` changed to `ConcurrentDictionary`; `EnsureTagRuntimeAsync` and `EnsureParentRuntimeAsync` now hold a per-key `GetCreationLock` semaphore around the double-checked create+initialize+store sequence so exactly one runtime is created per key and no race-loser is leaked.
### Driver.AbLegacy-008
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `AbLegacyDriver.cs:21`, `AbLegacyDriver.cs:138-146`, `AbLegacyDriver.cs:216-229` |
| Status | Resolved |
**Description:** `_health` is a plain non-volatile reference field mutated from
`ReadAsync`, `WriteAsync` (both can run on multiple threads / poll loops) and
`InitializeAsync`/`ShutdownAsync`, and read by `GetHealth()` from yet another thread.
There is no lock, no `volatile`, and no `Interlocked` exchange. The record reference
assignment is atomic, but without a memory barrier a reader can observe a stale
`_health` indefinitely, and concurrent writers race so a `Healthy` write from one
successful read can clobber a `Degraded` write from a concurrent failing read.
`GetHealth()` may therefore report `Healthy` while reads are persistently failing.
**Recommendation:** Mark `_health` volatile, or funnel health transitions through a
lock / `Interlocked.Exchange`. Consider only downgrading on failure and upgrading on a
successful poll so a single failed read does not flap the surface.
**Resolution:** Resolved 2026-05-22 — `_health` marked `volatile`; memory barrier comment documents the acquire/release ordering guarantee.
### Driver.AbLegacy-009
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `AbLegacyDriver.cs:41-74` |
| Status | Resolved |
**Description:** `InitializeAsync` starts probe loops with `Task.Run` inside the try
block. If `InitializeAsync` fails - or is re-entered - after some probe loops are
already started, the catch only sets `_health = Faulted` and rethrows; it does not
cancel `state.ProbeCts`, dispose runtimes, or clear `_devices`. A caller that catches
the exception and retries via `ReinitializeAsync` is covered (it calls `ShutdownAsync`
first), but a caller that catches and abandons the driver leaves orphaned probe tasks
and `CancellationTokenSource`s alive holding libplctag handles. Separately,
`ProbeLoopAsync` never escalates a permanently-unreachable device beyond `Stopped`.
**Recommendation:** On the catch path in `InitializeAsync`, run the same teardown as
`ShutdownAsync` (cancel probe CTSs, dispose runtimes, clear dictionaries) before
rethrowing, so a failed initialise leaves no live background work.
**Resolution:** Resolved 2026-05-22 — `InitializeAsync` catch block now cancels and disposes probe CTSs, calls `DisposeRuntimes`, and clears `_devices`/`_tagsByName` before rethrowing, leaving no orphaned background tasks or handles.
### Driver.AbLegacy-010
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `AbLegacyStatusMapper.cs:26-56` |
| Status | Resolved |
**Description:** `MapLibplctagStatus` maps the integer codes -5/-7/-14/-16/-17. These
do not match the native libplctag PLCTAG_ERR_* constants (PLCTAG_ERR_TIMEOUT = -32,
PLCTAG_ERR_NOT_FOUND = -22, PLCTAG_ERR_NOT_ALLOWED = -21, PLCTAG_ERR_OUT_OF_BOUNDS =
-25, PLCTAG_ERR_BAD_CONNECTION = -8). The mapper operates on `(int)_tag.GetStatus()`,
where `GetStatus()` returns the libplctag .NET wrapper Status enum whose underlying
ordinals differ from the native codes - so the -5/-7/... values are at best the .NET
enum ordinals (unverified, undocumented) and at worst wrong. Any unmatched negative
status falls through to `BadCommunicationError`, so a timeout is reported as a generic
comms error rather than `BadTimeout`. `MapPcccStatus` is dead code - the PCCC STS byte
is never inspected because libplctag surfaces only its own status enum.
**Recommendation:** Verify the actual `libplctag.Status` enum values against the 1.5.2
package and map by enum name rather than magic integers. Either wire `MapPcccStatus`
into a real PCCC-STS path or delete it as dead code. The same defect exists in
`AbCipStatusMapper` and should be fixed in lockstep.
**Resolution:** Resolved 2026-05-22 — `MapLibplctagStatus` now casts to `libplctag.Status` and switches on named enum members (matching the AbCip mapper pattern); `MapPcccStatus` retained with a comment documenting it as a reference mapping for future PCCC-STS inspection; tests updated to use `Status` enum members.
### Driver.AbLegacy-011
| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | `AbLegacyDriver.cs:440` |
| Status | Resolved |
**Description:** `Dispose()` is implemented as
`DisposeAsync().AsTask().GetAwaiter().GetResult()` - sync-over-async. `ShutdownAsync`
awaits `_poll.DisposeAsync()` (which completes synchronously) and does no other real
async work, so a deadlock is unlikely in practice, but the pattern blocks the calling
thread and would deadlock if any awaited continuation were ever marshalled back to a
single-threaded synchronization context.
**Recommendation:** Prefer callers use `IAsyncDisposable`. If a synchronous `Dispose()`
must exist, perform the synchronous teardown directly (cancel CTSs, dispose runtimes)
rather than blocking on the async path.
**Resolution:** Resolved 2026-05-23 — `Dispose()` now performs the synchronous
teardown directly (cancel probe CTSs, dispose runtimes, clear maps) rather than
wrapping `DisposeAsync().AsTask().GetAwaiter().GetResult()`. The poll engine's
`DisposeAsync` is drained with `.ConfigureAwait(false).GetAwaiter().GetResult()` so a
captured single-threaded `SynchronizationContext` can never be the resumption target —
the classic sync-over-async deadlock is structurally ruled out. Regression test
`Dispose_under_single_threaded_sync_context_does_not_deadlock` drives the path
through a cooperative single-threaded `SynchronizationContext` with a 2s pump timeout;
`Dispose_runs_teardown_without_blocking_on_async_wait` and `Dispose_is_idempotent`
cover the cleanup invariants.
### Driver.AbLegacy-012
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Design-document adherence |
| Location | `PlcFamilies/AbLegacyPlcFamilyProfile.cs:7-54`, `AbLegacyDriver.cs:48-52` |
| Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile` declares four record properties -
`DefaultCipPath`, `MaxTagBytes`, `SupportsStringFile`, `SupportsLongFile` - and only
`LibplctagPlcAttribute` is ever consumed. In particular:
- `DefaultCipPath` is dead: the per-family default path (empty for MicroLogix, 1,0
for SLC/PLC-5) is never used to substitute an empty CIP path. The CIP path always
comes verbatim from `AbLegacyHostAddress.CipPath`, so a SLC 500 misconfigured with
an empty path is never corrected to 1,0 even though the profile knows the right
default - contradicting the test-fixture doc, which calls out the /1,0 cip-path
workaround as required for SLC.
- `MaxTagBytes` is never used to validate or chunk a string/array read.
- `SupportsStringFile`/`SupportsLongFile` are never checked, so a `String` or `Long`
tag configured against a MicroLogix or PLC-5 (which the profile says lack them) is
accepted and only fails at runtime with an opaque comms error.
**Recommendation:** Either consume the profile fields (substitute `DefaultCipPath` when
the host CIP path is empty; reject `Long`/`String` tags against families whose profile
sets the corresponding flag false; use `MaxTagBytes` for validation) or remove the
unused fields and the doc comments that imply they are load-bearing.
**Resolution:** Resolved 2026-05-22 — `DeviceState.EffectiveCipPath` applies `DefaultCipPath` when the parsed host address has an empty CIP path; `InitializeAsync` validates `Long`/`String` tag types against `SupportsLongFile`/`SupportsStringFile` and throws early; `MaxTagBytes` tracked as a follow-up (string/array chunking requires broader design work).
### Driver.AbLegacy-013
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `AbLegacyDriver.cs:340-345`, `AbLegacyDriver.cs:238-264` |
| Status | Resolved |
**Description:** Two minor organisational issues:
1. `ResolveHost` returns `_options.Devices.FirstOrDefault()?.HostAddress ??
DriverInstanceId` when the reference is unknown and no devices are configured.
`DriverInstanceId` is not a host address (ab://...), so a downstream
`IHostConnectivityProbe` / host lookup keyed on the returned value never matches a
real device. Returning the instance id as a fake host masks a configuration error.
2. `DiscoverAsync` always emits `IsArray: false` / `ArrayDim: null`. PCCC files are
inherently arrays of elements; a tag that genuinely addresses a multi-element
region cannot be represented. This is consistent with the PR-staged scope (the doc
says array coverage is thin) but should be tracked rather than silently shipped.
**Recommendation:** For (1), either throw / return a sentinel the caller can detect, or
document why falling back to the instance id is acceptable. For (2), record the
array-addressing gap as a tracked follow-up.
**Resolution:** Resolved 2026-05-23 —
(1) `ResolveHost` carries a new XML-doc block that documents the three-step fallback
chain (known tag → first device → `DriverInstanceId`) and explicitly cites the
`IPerCallHostResolver` contract which requires implementations to return the driver's
default-host string rather than throw on an unknown reference. The instance-id
fallback is therefore the documented single-host behaviour, not a leaky fake. Three
regression tests in `AbLegacyDisposeAndResolveHostTests` pin each branch of the chain
(`ResolveHost_known_reference_returns_tag_device`,
`ResolveHost_unknown_reference_with_devices_returns_first_device`,
`ResolveHost_unknown_reference_no_devices_returns_driver_instance_id`).
(2) `DiscoverAsync` now carries an inline tracked-follow-up comment that calls out
the PCCC-file-as-array gap, notes the consistency with the PR-staged scope in
`docs/v2/driver-specs.md`, and points to the Modbus `ArrayCount` flow as the pattern
to mirror when multi-element addressing lands.
---
## Re-review 2026-06-19 (commit 7286d320)
New code since 76d35d1 adds: multi-element PCCC file (array) read support
(`DecodeArray`, `ElementCount` in `AbLegacyTagCreateParams`), equipment-tag reference
resolution via `EquipmentTagRefResolver<AbLegacyTagDefinition>` + `AbLegacyEquipmentTagParser`,
B/I/O-file bit RMW (`WriteBitInWordAsync` extended), the `AbLegacyDriverProbe`
Test-Connect (two-phase TCP + libplctag PCCC session), extraction of
`AbLegacyDriverOptions` / `AbLegacyDataType` / `AbLegacyPlcFamilyProfile` into a
`.Contracts` project, and the `AbLegacyDriverFactoryExtensions` factory. All 13
prior findings confirmed Resolved. Two new findings recorded (Driver.AbLegacy-014,
-015).
#### Re-review checklist
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.AbLegacy-014 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | No issues found |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Driver.AbLegacy-015 |
| 10 | Documentation & comments | No issues found |
### Driver.AbLegacy-014
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `LibplctagLegacyTagRuntime.cs:145` |
| Status | Resolved |
**Description:** The fix for Driver.AbLegacy-004 corrected the *decode* path for a
`Bit`-typed tag with no bit-index suffix (`DecodeValue` now calls `GetInt16(0) & 1`
instead of `GetInt8(0)`), but the symmetric *encode* path was not updated:
`EncodeValue` for `Bit` with `bitIndex == null` still calls
`_tag.SetInt8(0, (sbyte)0/1)`. A PCCC B-file element is a 16-bit word; `SetInt8`
writes only the low byte (8 bits), leaving bits 815 of the tag buffer unchanged from
the previous read. On a round-trip, `DecodeValue` reads the full 16-bit word via
`GetInt16(0)`, so the high byte that `SetInt8` did not overwrite contributes to the
decoded value. In the worst case, writing `false` (`SetInt8(0, 0)`) clears byte 0 but
leaves byte 1 intact, so `GetInt16(0)` may decode a non-zero value and the node
reports `true` after a `false` write. The encode and decode paths are asymmetric.
**Recommendation:** Replace `_tag.SetInt8(0, …)` with `_tag.SetInt16(0, …)` so the
encode writes the full 16-bit word, matching the 16-bit decode. This makes `SetInt16(0, 1)`
and `GetInt16(0) & 1` a symmetric pair.
**Resolution:** Resolved 2026-06-19 — `EncodeValue` for `Bit` with no `bitIndex` changed
from `SetInt8(0, …)` to `SetInt16(0, (short)0/(short)1)`, making the encode symmetric
with `DecodeValue`'s `GetInt16(0) & 1` decode. A regression test
`Bit_tag_without_suffix_writes_via_EncodeValue_not_RMW` in `AbLegacyReadWriteTests`
pins the encode routing (no parent-word runtime created, tag's own runtime receives
the write) and that `EncodeValue` is called with the correct value.
### Driver.AbLegacy-015
| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `AbLegacyCapabilityTests.cs:92`, `AbLegacyCapabilityTests.cs:174` |
| Status | Resolved |
**Description:** Two `Task.Delay` calls in `AbLegacyCapabilityTests` use
`CancellationToken.None` (implicit, no argument) rather than
`TestContext.Current.CancellationToken`, triggering xUnit1051 analyzer warnings
("Calls to methods which accept CancellationToken should use
TestContext.Current.CancellationToken to allow test cancellation to be more
responsive"). Under test-runner cancellation (timeout or `dotnet test --cancel`) the
300 ms and 200 ms delays in `Unsubscribe_halts_polling` and
`Probe_disabled_when_ProbeAddress_is_null` would run to completion instead of
aborting immediately, making the test suite slower to cancel.
**Recommendation:** Pass `TestContext.Current.CancellationToken` to both `Task.Delay`
calls.
**Resolution:** Resolved 2026-06-19 — both `Task.Delay` calls updated to pass
`TestContext.Current.CancellationToken`; xUnit1051 warnings no longer emitted.