fix(modbus): lifecycle gate + non-vacuous reprobe regression test

This commit is contained in:
Joseph Doherty
2026-08-16 04:07:27 -04:00
parent 140bf0c131
commit 409a58b624
2 changed files with 251 additions and 15 deletions
@@ -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);
}