fix(driver-modbus): resolve Medium code-review finding (Driver.Modbus-002)

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>
This commit is contained in:
Joseph Doherty
2026-05-22 09:48:09 -04:00
parent f9dccaa732
commit c5f2d91bcb
2 changed files with 81 additions and 28 deletions

View File

@@ -170,24 +170,9 @@ public sealed class ModbusDriver
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
try { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
try { _reprobeCts?.Cancel(); } catch { }
_reprobeCts?.Dispose();
_reprobeCts = null;
// #151 — clear the prohibition set on shutdown so an explicit operator restart
// (ReinitializeAsync) starts with a clean slate. The re-probe loop already retries
// automatically when enabled; the restart path is the manual escape hatch.
lock (_autoProhibitedLock) _autoProhibited.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
var lastRead = _health.LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Unknown, lastRead, null);
}
public DriverHealth GetHealth() => _health;
@@ -327,6 +312,10 @@ public sealed class ModbusDriver
/// </summary>
private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
{
// Driver.Modbus-005: guard against empty bitmap (already validated upstream but defensive
// here so the IndexOutOfRangeException path is explicitly closed at decode time too).
if (bitmap.IsEmpty)
throw new InvalidDataException("Modbus bit response produced an empty bitmap — cannot decode coil value");
if (!isArray) return (bitmap[0] & 0x01) == 1;
var result = new bool[count];
for (var i = 0; i < count; i++)
@@ -525,6 +514,14 @@ public sealed class ModbusDriver
catch (OperationCanceledException) { return; }
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (ObjectDisposedException) when (ct.IsCancellationRequested)
{
// Driver.Modbus-006: ShutdownAsync disposes the transport while we may be
// mid-pass. An ObjectDisposedException from the disposed transport is the
// expected shutdown race — swallow it here so the fire-and-forget task
// exits cleanly rather than faulting with the wrong failure mode.
return;
}
}
}
@@ -785,7 +782,20 @@ public sealed class ModbusDriver
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count][data...]
// resp = [fc][byte-count][data...] — validate before indexing to surface a clean error
// rather than an IndexOutOfRangeException when a device returns a truncated PDU.
// Driver.Modbus-005: guard resp.Length >= 2 (fc + byte-count) and that the payload is
// at least as long as the declared byte-count, matching the quantity we requested.
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus register response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
if (resp.Length < 2 + resp[1])
throw new InvalidDataException(
$"Modbus register response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
var expectedByteCount = quantity * 2;
if (resp[1] != expectedByteCount)
throw new InvalidDataException(
$"Modbus register response byte-count mismatch: requested {quantity} registers ({expectedByteCount} bytes), got {resp[1]} bytes");
var data = new byte[resp[1]];
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
return data;
@@ -797,6 +807,17 @@ public sealed class ModbusDriver
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
(byte)(qty >> 8), (byte)(qty & 0xFF) };
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
// Driver.Modbus-005: validate the response is structurally sound before indexing.
if (resp.Length < 2)
throw new InvalidDataException(
$"Modbus bit response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
if (resp.Length < 2 + resp[1])
throw new InvalidDataException(
$"Modbus bit response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
var expectedByteCount = (qty + 7) / 8;
if (resp[1] < expectedByteCount)
throw new InvalidDataException(
$"Modbus bit response byte-count mismatch: requested {qty} bits ({expectedByteCount} bytes), got {resp[1]} bytes");
var bitmap = new byte[resp[1]];
Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]);
return bitmap;
@@ -1471,8 +1492,40 @@ public sealed class ModbusDriver
};
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// Driver.Modbus-004: DisposeAsync must perform the same teardown as ShutdownAsync so
/// callers that use <c>await using</c> (without an explicit <c>ShutdownAsync</c>) do not
/// leak the probe loop, re-probe loop, and poll-engine background tasks. Shares
/// <see cref="TeardownAsync"/> with <see cref="ShutdownAsync"/> to keep them in sync.
/// </summary>
public async ValueTask DisposeAsync()
{
await TeardownAsync().ConfigureAwait(false);
}
/// <summary>
/// Shared teardown helper used by both <see cref="ShutdownAsync"/> and
/// <see cref="DisposeAsync"/>. Cancels both background loops, disposes the poll engine,
/// and disposes the transport. Idempotent — safe to call more than once.
/// </summary>
private async Task TeardownAsync()
{
try { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
try { _reprobeCts?.Cancel(); } catch { }
_reprobeCts?.Dispose();
_reprobeCts = null;
_tagsByName.Clear();
_lastPublishedByRef.Clear();
lock (_lastWrittenLock) _lastWrittenByRef.Clear();
lock (_autoProhibitedLock) _autoProhibited.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
}