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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user