fix(twincat): probe recycle honors cancellation — no uncancellable gate wait (05/STAB-12 compounded part); B6 complete (TwinCAT suite 189 green)
This commit is contained in:
@@ -216,7 +216,7 @@
|
|||||||
{
|
{
|
||||||
"id": "B6.5",
|
"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",
|
"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": [
|
"blockedBy": [
|
||||||
"B6.4"
|
"B6.4"
|
||||||
]
|
]
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
{
|
{
|
||||||
"id": "B6.6",
|
"id": "B6.6",
|
||||||
"subject": "B6: full TwinCAT suite green (fake-client authoritative, no TC3 fixture); merge PR 6",
|
"subject": "B6: full TwinCAT suite green (fake-client authoritative, no TC3 fixture); merge PR 6",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"B6.5"
|
"B6.5"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
// this the IsConnected fast-path would keep handing back the dead client forever
|
||||||
// (the compounding bug — native pushes would never recover).
|
// (the compounding bug — native pushes would never recover).
|
||||||
if (!success)
|
if (!success)
|
||||||
await RecycleClientAsync(state).ConfigureAwait(false);
|
await RecycleClientAsync(state, ct).ConfigureAwait(false);
|
||||||
else
|
else
|
||||||
// 05/STAB-16: on a healthy tick, re-register any notifications whose replay
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// place, so force a recycle to guarantee the next tick rebuilds + replays. A shutdown
|
||||||
await RecycleClientAsync(state).ConfigureAwait(false);
|
// 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);
|
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
|
/// liveness, so without this forced recycle a wire-dead-but-port-open client would keep
|
||||||
/// being reused and native pushes would never recover.
|
/// being reused and native pushes would never recover.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
if (device.Client is { } c)
|
if (device.Client is { } c)
|
||||||
|
|||||||
@@ -328,6 +328,42 @@ public sealed class TwinCATReconnectReplayTests
|
|||||||
factory.Clients[1].Notifications.ShouldBeEmpty("the fresh handle must be disposed — no orphan ADS notification");
|
factory.Clients[1].Notifications.ShouldBeEmpty("the fresh handle must be disposed — no orphan ADS notification");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// STAB-12 (compounded part): <c>RecycleClientAsync</c> must honor cancellation — it must
|
||||||
|
/// take the probe token into <c>ConnectGate.WaitAsync(ct)</c> rather than
|
||||||
|
/// <c>CancellationToken.None</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<object>().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<OperationCanceledException>(async () => await task.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||||
|
|
||||||
|
device.ConnectGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
{
|
{
|
||||||
var deadline = DateTime.UtcNow + timeout;
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
|||||||
Reference in New Issue
Block a user