diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json index 4ffe46a8..85f144c0 100644 --- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json +++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json @@ -216,7 +216,7 @@ { "id": "B6.5", "subject": "B6: RecycleClientAsync takes the probe ct (no CancellationToken.None gate wait) + in-catch OCE wrap, tests FAIL->PASS \u2014 STAB-12 compounded part", - "status": "pending", + "status": "completed", "blockedBy": [ "B6.4" ] @@ -224,7 +224,7 @@ { "id": "B6.6", "subject": "B6: full TwinCAT suite green (fake-client authoritative, no TC3 fixture); merge PR 6", - "status": "pending", + "status": "completed", "blockedBy": [ "B6.5" ] diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index fdb77852..7420e6a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -689,7 +689,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery // this the IsConnected fast-path would keep handing back the dead client forever // (the compounding bug — native pushes would never recover). if (!success) - await RecycleClientAsync(state).ConfigureAwait(false); + await RecycleClientAsync(state, ct).ConfigureAwait(false); else // 05/STAB-16: on a healthy tick, re-register any notifications whose replay // failed (a transient AddNotification blip) so they recover without a client swap. @@ -700,8 +700,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery { // Probe threw — the connect-failure path clears the client only when the CONNECT // failed; a throw from ProbeAsync on an already-connected client would leave it in - // place, so force a recycle to guarantee the next tick rebuilds + replays. - await RecycleClientAsync(state).ConfigureAwait(false); + // place, so force a recycle to guarantee the next tick rebuilds + replays. A shutdown + // racing this recycle cancels the gate wait (STAB-12) — exit the loop cleanly. + try { await RecycleClientAsync(state, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -981,9 +983,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// liveness, so without this forced recycle a wire-dead-but-port-open client would keep /// being reused and native pushes would never recover. /// - private async Task RecycleClientAsync(DeviceState device) + private async Task RecycleClientAsync(DeviceState device, CancellationToken ct) { - await device.ConnectGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + // 05/STAB-12 (compounded part): honor the probe token so a connect wedged under the gate + // (TwinCAT's blocking SDK Connect — the un-fixed root, carried) can't hang the probe loop + // beyond cancellation. Was CancellationToken.None. + await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { if (device.Client is { } c) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs index 0d3f2c4f..c22c60de 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs @@ -328,6 +328,42 @@ public sealed class TwinCATReconnectReplayTests factory.Clients[1].Notifications.ShouldBeEmpty("the fresh handle must be disposed — no orphan ADS notification"); } + /// + /// STAB-12 (compounded part): RecycleClientAsync must honor cancellation — it must + /// take the probe token into ConnectGate.WaitAsync(ct) rather than + /// CancellationToken.None, so a shutdown mid-recycle (or a connect wedged under the + /// gate) can't hang the probe loop uncancellably. Verified white-box: with the gate held and + /// a cancelled token, the call must throw promptly rather than block forever. + /// + [Fact] + public async Task RecycleClient_HonorsCancellation() + { + var (drv, _) = NewNativeDriver( + tags: new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Reach the single configured device + wedge its ConnectGate so a recycle must wait on it. + var devicesField = typeof(TwinCATDriver).GetField("_devices", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + var devicesObj = devicesField.GetValue(drv)!; + var values = (System.Collections.IEnumerable)devicesObj.GetType().GetProperty("Values")!.GetValue(devicesObj)!; + var device = (TwinCATDriver.DeviceState)values.Cast().First(); + await device.ConnectGate.WaitAsync(TestContext.Current.CancellationToken); + + var recycle = typeof(TwinCATDriver).GetMethod("RecycleClientAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var task = (Task)recycle.Invoke(drv, [device, cts.Token])!; + + // Must throw promptly (cancelled token), NOT hang on the held gate. The 2 s bound turns an + // uncancellable-wait regression into a failure rather than a hung suite. + await Should.ThrowAsync(async () => await task.WaitAsync(TimeSpan.FromSeconds(2))); + + device.ConnectGate.Release(); + } + private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout;