fix(modbus): lifecycle gate + non-vacuous reprobe regression test
This commit is contained in:
@@ -87,6 +87,25 @@ public sealed class ModbusDriver
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private CancellationTokenSource? _reprobeCts;
|
||||
|
||||
/// <summary>
|
||||
/// Guards <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> /
|
||||
/// <see cref="ShutdownAsync"/> / <see cref="DisposeAsync"/> against each other. The three
|
||||
/// fields above are published by one lifecycle operation and released by the next, so two
|
||||
/// concurrent operations would dispose each other's freshly-built transport or null
|
||||
/// <c>_transport</c> out from under an in-flight connect. Not hypothetical:
|
||||
/// <c>DriverInstanceActor</c> dispatches initialize on a fire-and-forget <c>Task.Run</c> and
|
||||
/// its <c>AdoptConfigDuringInit</c> can fire twice back to back. Same shape as
|
||||
/// <c>MqttDriver._lifecycleGate</c>.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Bounded wait the teardown paths take on <see cref="_lifecycleGate"/>. A wedged initialize
|
||||
/// (a connect that never returns) must not turn shutdown or dispose into a hang — past the
|
||||
/// timeout they tear down anyway, which is the lesser evil of the two.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LifecycleGateTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag value changes.</summary>
|
||||
@@ -251,6 +270,20 @@ public sealed class ModbusDriver
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
|
||||
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
@@ -261,10 +294,11 @@ public sealed class ModbusDriver
|
||||
// background loops that no later dispose could reach. Reachable without any teardown of
|
||||
// ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta
|
||||
// lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit).
|
||||
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, the PollGroupEngine
|
||||
// constructed once in the ctor and never rebuilt, which would leave the re-initialized
|
||||
// driver unable to serve subscriptions. A first initialize sees three nulls, so this is a
|
||||
// no-op on the initial call.
|
||||
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
|
||||
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
|
||||
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
|
||||
// that genuinely wants the subscriptions gone calls ShutdownAsync. A first initialize
|
||||
// sees three nulls, so this block is a no-op on the initial call.
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
@@ -333,12 +367,48 @@ public sealed class ModbusDriver
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||
// Holds the gate across BOTH halves and calls the ungated cores: taking it once here is what
|
||||
// makes the shutdown-then-initialize pair atomic. Routing through the public (gated) methods
|
||||
// instead would both self-deadlock and leave a window between them for a concurrent
|
||||
// initialize to slip into.
|
||||
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ShutdownCoreAsync().ConfigureAwait(false);
|
||||
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ShutdownCoreAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleGate.Release();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Modbus lifecycle gate still held at shutdown; tearing the transport down anyway. Driver={DriverInstanceId}",
|
||||
_driverInstanceId);
|
||||
await ShutdownCoreAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gate-held body of <see cref="ShutdownAsync"/>, shared with
|
||||
/// <see cref="ReinitializeAsync"/>.</summary>
|
||||
private async Task ShutdownCoreAsync()
|
||||
{
|
||||
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
@@ -1693,6 +1763,23 @@ public sealed class ModbusDriver
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (await _lifecycleGate.WaitAsync(LifecycleGateTimeout).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleGate.Release();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Modbus lifecycle gate still held at dispose; tearing the transport down anyway. Driver={DriverInstanceId}",
|
||||
_driverInstanceId);
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
+158
-9
@@ -264,12 +264,13 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The probe / re-probe CancellationTokenSources are re-assigned by every initialize, so a
|
||||
/// second initialize must cancel the previous pair or their background loops run forever
|
||||
/// against a transport that is already gone.
|
||||
/// <c>_probeCts</c> is re-assigned by every initialize, so a second initialize must cancel the
|
||||
/// previous one. <c>ProbeLoopAsync</c> CAPTURES its transport reference at loop entry, so a
|
||||
/// leaked probe loop keeps hammering the transport the first initialize published — which is
|
||||
/// exactly what this measures. Re-probe is off here so every send is attributable to the probe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_second_InitializeAsync_stops_the_first_probe_and_reprobe_loops()
|
||||
public async Task A_second_InitializeAsync_stops_the_first_probe_loop()
|
||||
{
|
||||
var first = new FakeTransport();
|
||||
var second = new FakeTransport();
|
||||
@@ -280,10 +281,10 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
Interval = TimeSpan.FromMilliseconds(25),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
},
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(50),
|
||||
AutoProhibitReprobeInterval = null, // re-probe off: isolate the probe loop
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)]),
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "modbus-1", _ => Interlocked.Increment(ref handed) == 1 ? first : second);
|
||||
@@ -291,16 +292,164 @@ public sealed class ModbusLifecycleHygieneTests
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
Interlocked.CompareExchange(ref first.SendCount, 0, 0).ShouldBeGreaterThan(0,
|
||||
"the first initialize's probe loop should have issued at least one send");
|
||||
"precondition: the first initialize's probe loop must actually be sending");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// The first transport's loops must stop; tolerate one in-flight send straddling the cancel.
|
||||
// Tolerate one in-flight send straddling the cancel; a leaked loop produces ~16 in 400ms.
|
||||
var atReinit = Interlocked.CompareExchange(ref first.SendCount, 0, 0);
|
||||
await Task.Delay(400);
|
||||
var atRest = Interlocked.CompareExchange(ref first.SendCount, 0, 0);
|
||||
(atRest - atReinit).ShouldBeLessThanOrEqualTo(1,
|
||||
"the previous initialize's probe/re-probe loops must be cancelled by the next initialize");
|
||||
"the previous initialize's probe loop must be cancelled by the next initialize");
|
||||
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport for the re-probe test: counts every send and fails one address permanently, so a
|
||||
/// seeded prohibition on that address is never cleared and the re-probe loop keeps re-probing
|
||||
/// it tick after tick. A transport that answered normally would let the very first re-probe
|
||||
/// CLEAR the prohibition, emptying the candidate set and making the test vacuous.
|
||||
/// </summary>
|
||||
private sealed class ReprobeCountingTransport : IModbusTransport
|
||||
{
|
||||
/// <summary>Register address whose reads always fail.</summary>
|
||||
public ushort FailAddress { get; init; }
|
||||
/// <summary>Total sends this transport has been asked to perform.</summary>
|
||||
public int SendCount;
|
||||
|
||||
/// <summary>Establishes a connection asynchronously.</summary>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Counts the send, failing reads aimed at <see cref="FailAddress"/>.</summary>
|
||||
/// <param name="unitId">The Modbus unit identifier.</param>
|
||||
/// <param name="pdu">The Protocol Data Unit to send.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref SendCount);
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
if (addr == FailAddress)
|
||||
return Task.FromException<byte[]>(new IOException($"address {addr} is unreadable"));
|
||||
return Task.FromResult(new byte[] { pdu[0], 2, 0, 0 });
|
||||
}
|
||||
|
||||
/// <summary>Disposes the transport asynchronously.</summary>
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Reads a private instance field off the driver.</summary>
|
||||
private static object GetPrivateField(ModbusDriver drv, string name) =>
|
||||
typeof(ModbusDriver).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(drv)!;
|
||||
|
||||
/// <summary>
|
||||
/// <c>_reprobeCts</c> is re-assigned by every initialize, so a second initialize must cancel the
|
||||
/// previous one. Two things make this measurable where the probe test's approach would not:
|
||||
/// the re-probe loop only sends when the prohibition set is NON-EMPTY (so one is seeded, on an
|
||||
/// address the transport fails permanently), and it re-reads the <c>_transport</c> FIELD every
|
||||
/// pass rather than capturing it — so a leaked loop hits the transport the SECOND initialize
|
||||
/// published. The second initialize is therefore given re-probe-less options: any send at all on
|
||||
/// the second transport is the leaked loop from the first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_second_InitializeAsync_stops_the_first_reprobe_loop()
|
||||
{
|
||||
const ushort prohibited = 100;
|
||||
var first = new ReprobeCountingTransport { FailAddress = prohibited };
|
||||
var second = new ReprobeCountingTransport { FailAddress = prohibited };
|
||||
var handed = 0;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Probe = new ModbusProbeOptions { Enabled = false }, // probe off: isolate the re-probe loop
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(25),
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)]),
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "modbus-1", _ => Interlocked.Increment(ref handed) == 1 ? first : second);
|
||||
|
||||
// Seed a single-register prohibition BEFORE the first initialize, so no loop is running while
|
||||
// the (unsynchronised) dictionary is mutated. Start == End keeps it off the bisection path, so
|
||||
// each tick is exactly one StraightReprobeAsync send.
|
||||
var prohibitions = (System.Collections.IDictionary)GetPrivateField(drv, "_autoProhibited");
|
||||
var stateType = typeof(ModbusDriver).GetNestedType("ProhibitionState", BindingFlags.NonPublic)!;
|
||||
prohibitions.Add(
|
||||
((byte)1, ModbusRegion.HoldingRegisters, prohibited, prohibited),
|
||||
Activator.CreateInstance(stateType, nonPublic: true)!);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(200);
|
||||
Interlocked.CompareExchange(ref first.SendCount, 0, 0).ShouldBeGreaterThan(0,
|
||||
"precondition: the seeded prohibition must make the re-probe loop actually send");
|
||||
|
||||
// The second initialize must NOT start a re-probe loop of its own, or a leaked first loop and a
|
||||
// healthy second one would be indistinguishable by send count. "{}" keeps these options.
|
||||
typeof(ModbusDriver).GetField("_options", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(drv, new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
AutoProhibitReprobeInterval = null,
|
||||
RawTags = opts.RawTags,
|
||||
});
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Non-vacuity guard: the candidate set the leaked loop would iterate must still be non-empty,
|
||||
// so a zero send count is attributable to the cancel and not to an emptied prohibition set.
|
||||
prohibitions.Count.ShouldBe(1, "a re-initialize must not clear the prohibition set");
|
||||
|
||||
var atReinit = Interlocked.CompareExchange(ref second.SendCount, 0, 0);
|
||||
await Task.Delay(400);
|
||||
var atRest = Interlocked.CompareExchange(ref second.SendCount, 0, 0);
|
||||
(atRest - atReinit).ShouldBeLessThanOrEqualTo(1,
|
||||
"the previous initialize's re-probe loop must be cancelled by the next initialize");
|
||||
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two initializes racing on the same driver — the shape <c>DriverInstanceActor</c> produces by
|
||||
/// dispatching initialize on a fire-and-forget <c>Task.Run</c> with <c>AdoptConfigDuringInit</c>
|
||||
/// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
|
||||
/// initialize disposes the transport the other just published (or nulls <c>_transport</c>
|
||||
/// mid-connect), leaving the driver holding a disposed transport.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_InitializeAsync_calls_serialize_and_leave_exactly_one_live_transport()
|
||||
{
|
||||
var handed = new ConcurrentQueue<FakeTransport>();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16)]),
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "modbus-1", _ =>
|
||||
{
|
||||
var t = new FakeTransport();
|
||||
handed.Enqueue(t);
|
||||
return t;
|
||||
});
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => drv.InitializeAsync("{}", CancellationToken.None)),
|
||||
Task.Run(() => drv.InitializeAsync("{}", CancellationToken.None)));
|
||||
|
||||
var built = handed.ToArray();
|
||||
built.Length.ShouldBe(2);
|
||||
// Serialized: the earlier initialize's transport is disposed by the later one, and exactly one
|
||||
// survives — never two live transports, and never a disposed transport left installed.
|
||||
built.Count(t => t.DisposeCount == 0).ShouldBe(1, "exactly one transport must be left live");
|
||||
built.Count(t => t.DisposeCount == 1).ShouldBe(1, "the superseded transport must be disposed once");
|
||||
|
||||
// The survivor is the installed one and the driver still works through it.
|
||||
var live = built.Single(t => t.DisposeCount == 0);
|
||||
live.HoldingRegisters[0] = 9;
|
||||
var r = await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[0].Value).ShouldBe(9);
|
||||
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user