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