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.
This commit is contained in:
Joseph Doherty
2026-06-19 11:47:11 -04:00
parent be272d960f
commit 91e2609560
4 changed files with 131 additions and 7 deletions
+90 -4
View File
@@ -4,8 +4,8 @@
|---|---| |---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` | | Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` |
| Reviewer | Claude Code | | Reviewer | Claude Code |
| Review date | 2026-05-22 | | Review date | 2026-06-19 |
| Commit reviewed | `76d35d1` | | Commit reviewed | `7286d320` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 0 | | Open findings | 0 |
@@ -16,7 +16,7 @@ a category produced nothing rather than leaving it blank.
| # | Category | Result | | # | Category | Result |
|---|---|---| |---|---|---|
| 1 | Correctness & logic bugs | Driver.AbLegacy-001, Driver.AbLegacy-002, Driver.AbLegacy-003, Driver.AbLegacy-004 | | 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 | | 2 | OtOpcUa conventions | Driver.AbLegacy-005 |
| 3 | Concurrency & thread safety | Driver.AbLegacy-006, Driver.AbLegacy-007, Driver.AbLegacy-008 | | 3 | Concurrency & thread safety | Driver.AbLegacy-006, Driver.AbLegacy-007, Driver.AbLegacy-008 |
| 4 | Error handling & resilience | Driver.AbLegacy-009, Driver.AbLegacy-010 | | 4 | Error handling & resilience | Driver.AbLegacy-009, Driver.AbLegacy-010 |
@@ -24,7 +24,7 @@ a category produced nothing rather than leaving it blank.
| 6 | Performance & resource management | Driver.AbLegacy-011 | | 6 | Performance & resource management | Driver.AbLegacy-011 |
| 7 | Design-document adherence | Driver.AbLegacy-012 | | 7 | Design-document adherence | Driver.AbLegacy-012 |
| 8 | Code organization & conventions | Driver.AbLegacy-013 | | 8 | Code organization & conventions | Driver.AbLegacy-013 |
| 9 | Testing coverage | No issues found | | 9 | Testing coverage | Driver.AbLegacy-015 |
| 10 | Documentation & comments | No issues found | | 10 | Documentation & comments | No issues found |
## Findings ## Findings
@@ -394,3 +394,89 @@ regression tests in `AbLegacyDisposeAndResolveHostTests` pin each branch of the
the PCCC-file-as-array gap, notes the consistency with the PR-staged scope in 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 `docs/v2/driver-specs.md`, and points to the Modbus `ArrayCount` flow as the pattern
to mirror when multi-element addressing lands. 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.
@@ -142,7 +142,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
// silently clobbering the whole word. // silently clobbering the whole word.
throw new NotSupportedException( throw new NotSupportedException(
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync."); "Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
_tag.SetInt8(0, Convert.ToBoolean(value) ? (sbyte)1 : (sbyte)0); // Driver.AbLegacy-014 — use SetInt16 to match DecodeValue's GetInt16(0) decode.
// A PCCC B-file element is a 16-bit word; SetInt8 only writes 1 byte, leaving
// bits 8-15 from the previous read in the tag buffer and creating a
// decode/encode asymmetry. SetInt16(0, 0/1) writes the full 16-bit word.
_tag.SetInt16(0, Convert.ToBoolean(value) ? (short)1 : (short)0);
break; break;
case AbLegacyDataType.Int: case AbLegacyDataType.Int:
case AbLegacyDataType.AnalogInt: case AbLegacyDataType.AnalogInt:
@@ -89,7 +89,7 @@ public sealed class AbLegacyCapabilityTests
var afterUnsub = events.Count; var afterUnsub = events.Count;
tagRef.Value = 999; tagRef.Value = 999;
await Task.Delay(300); await Task.Delay(300, TestContext.Current.CancellationToken);
events.Count.ShouldBe(afterUnsub); events.Count.ShouldBe(afterUnsub);
} }
@@ -171,7 +171,7 @@ public sealed class AbLegacyCapabilityTests
Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null }, Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null },
}, "drv-1"); }, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(200); await Task.Delay(200, TestContext.Current.CancellationToken);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None);
@@ -189,6 +189,40 @@ public sealed class AbLegacyReadWriteTests
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
} }
/// <summary>
/// Driver.AbLegacy-014 — a Bit-typed tag with no bit suffix (e.g. B3:0, DataType=Bit)
/// takes the EncodeValue(Bit, bitIndex:null, …) path (not RMW). The encode must be
/// symmetric with the DecodeValue path, which reads the full 16-bit word via GetInt16.
/// Through the fake factory this verifies the driver dispatches through EncodeValue with
/// the right arguments; the SetInt16/SetInt8 delta is exercised against a live PLC.
/// </summary>
[Fact]
public async Task Bit_tag_without_suffix_writes_via_EncodeValue_not_RMW()
{
// A Bit-typed tag with NO /N bit suffix — bitIndex is null, so write must NOT
// route through WriteBitInWordAsync (which requires bitIndex). Instead it goes
// through EncodeValue(Bit, null, value) on the tag's own runtime, not a parent runtime.
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Flag", "ab://10.0.0.5/1,0", "B3:0", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Flag", true)], CancellationToken.None);
// Must succeed (Good) and route through the tag's own runtime, NOT a parent runtime.
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ShouldContainKey("B3:0");
factory.Tags.ShouldNotContainKey("B3"); // no parent-word runtime created
factory.Tags["B3:0"].WriteCount.ShouldBe(1);
// FakeAbLegacyTag.EncodeValue stores the raw value; verify it received true.
factory.Tags["B3:0"].Value.ShouldBe(true);
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError.</summary> /// <summary>Verifies that write exceptions surface as BadCommunicationError.</summary>
[Fact] [Fact]
public async Task Write_exception_surfaces_BadCommunicationError() public async Task Write_exception_surfaces_BadCommunicationError()