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