test(opcuaclient): slow server-side subscription delete keeps the stale-BadNoSubscription grace running from delete completion (lmxopcua#764)
v2-ci / unit-tests (push) Has been cancelled

Unsubscribe paths now delete through the existing DeleteSubscriptionAsync seam so a test can hold the delete open >grace. Mutation: dropping the post-delete restamp fails both slow-delete tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 05:05:18 -05:00
parent f201f16fd4
commit 70050c9d7e
2 changed files with 106 additions and 3 deletions
@@ -1242,7 +1242,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary>
internal TimeSpan TeardownBudget { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>Test seam: the wire delete of one subscription. Production calls the SDK's silent delete.</summary>
/// <summary>Test seam: the wire delete of one subscription (unsubscribe + shutdown). Production calls the SDK's silent delete.</summary>
internal Func<Subscription, CancellationToken, Task> DeleteSubscriptionAsync { get; set; } =
static (subscription, ct) => subscription.DeleteAsync(silent: true, ct);
@@ -2050,7 +2050,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// RemoteSubscription is the exact instance that was added, so `-=` removes it
// cleanly.
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
try { await DeleteSubscriptionAsync(rs.Subscription, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — the subscription may already be gone on reconnect */ }
}
finally
@@ -2555,7 +2555,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// closure.
try { rs.EventItem.Notification -= rs.Handler; }
catch { /* best-effort */ }
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
try { await DeleteSubscriptionAsync(rs.Subscription, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — session may already be gone across a reconnect */ }
}
finally
@@ -184,3 +184,106 @@ public sealed class OpcUaClientStaleNoSubscriptionTests
ShouldBeDeclaredLost(drv, seen, "the delete belonged to the released session");
}
}
/// <summary>
/// Gitea #764 residual: the server's DeleteSubscriptions can be slow (&gt; <see cref="OpcUaClientDriver.StaleNoSubscriptionGrace"/>).
/// The server answers the stale publish only once the delete is done, so the grace runs from the delete's
/// COMPLETION, not its start.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientSlowDeleteTests
{
private const string SpeedRef = "Plant/dev/Speed";
private const string AlarmRef = "Plant/dev/TempHi";
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
public void Advance(TimeSpan by) => Now += by;
}
private static (OpcUaClientDriver Driver, ManualClock Clock, List<DriverConnectionLossEventArgs> Lost,
TaskCompletionSource Gate, TaskCompletionSource Started) New()
{
RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764");
var session = new Mock<ISession>(MockBehavior.Loose);
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero));
drv.Clock = clock;
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), tags));
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
var lost = new List<DriverConnectionLossEventArgs>();
drv.ConnectionLost += (_, e) => lost.Add(e);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.DeleteSubscriptionAsync = async (_, _) => { started.TrySetResult(); await gate.Task; };
return (drv, clock, lost, gate, started);
}
[Fact]
public async Task A_slow_data_delete_still_attributes_the_stale_error_to_the_delete()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddSubscriptionForTest([SpeedRef]);
var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30)); // the delete takes far longer than the grace
gate.SetResult();
await unsub;
drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(TimeSpan.FromMilliseconds(850));
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_slow_alarm_delete_still_attributes_the_stale_error_to_the_delete()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
var unsub = drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30));
gate.SetResult();
await unsub;
drv.AddAlarmSubscriptionForTest([AlarmRef]);
clock.Advance(TimeSpan.FromMilliseconds(850));
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Positive control: a slow delete does not extend the window beyond grace after completion.</summary>
[Fact]
public async Task After_a_slow_delete_completes_the_grace_still_expires()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddSubscriptionForTest([SpeedRef]);
var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30));
gate.SetResult();
await unsub;
drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(OpcUaClientDriver.StaleNoSubscriptionGrace);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldHaveSingleItem().Reason.ShouldContain("subscription lost");
}
}