Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStaleCursorTests.cs
T
Joseph Doherty 77bfadbdda fix(mtconnect): generation guard on the pump's cursor publish, chunks and re-baseline (R06:T-1)
A pump the lifecycle retired or abandoned that finished after the restart
installed the new session published its stale cursor onto it (PublishAgentCursor
only checked for a null or identical session), so the next pump read its first
chunk as an Agent restart: index cleared, a Bad flicker on every subscribed tag,
a needless re-baseline. The pump now carries the session generation it was
started against; the cursor publish is checked under _subscriptionLock (where the
session install and generation bump are atomic) and a superseded pump also stops
folding late chunks / re-baselines into the new session's index.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:36:24 -04:00

111 lines
4.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Review 2026-10-21 R06:T-1 — a pump the lifecycle abandoned (its teardown wait timed out) that finished after
/// the restart installed the NEW session published its stale cursor onto it: the old Agent's instanceId and
/// sequence replaced the new session's, so the next pump to start treated its first chunk as an Agent restart
/// (index cleared, a Bad flicker on every subscribed tag, a needless re-baseline). The pump now carries the
/// session generation it was started against and its cursor publish is dropped once that generation is gone.
/// </summary>
public sealed class MTConnectStaleCursorTests
{
private const long InstanceB = 1655000099L;
private const long NextSequenceB = 500L;
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>The abandoned pump's late cursor publish leaves the new session's cursor alone.</summary>
[Fact]
public async Task An_abandoned_pump_does_not_publish_its_cursor_onto_the_new_session()
{
var clientA = CannedAgentClient.FromFixtures();
var clientB = CannedAgentClient.FromFixtures();
clientB.Current = new MTConnectStreamsResult(InstanceB, NextSequenceB, NextSequenceB - 10, clientB.Current.Observations);
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
},
"mt-t1",
_ => clients.Dequeue());
await driver.InitializeAsync("{}", Ct);
var wedgeOnce = 0;
var wedge = new ManualResetEventSlim(false);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.OnDataChange += (_, e) =>
{
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref wedgeOnce, 1) != 0) return;
entered.TrySetResult();
wedge.Wait();
};
try
{
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
var pumpA = driver.SampleStreamTask.ShouldNotBeNull();
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
await entered.Task.WaitAsync(Watchdog, Ct);
// The restart gives up on the wedged pump (bounded teardown wait), retires clientA and installs B.
await driver.InitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
driver.AgentInstanceId.ShouldBe(InstanceB, "positive control: the new session is installed");
pumpA.IsCompleted.ShouldBeFalse("pump A must still be alive — it is the late writer under test");
// Now let the abandoned pump finish: its finally publishes (instanceA, 113).
wedge.Set();
await pumpA.WaitAsync(Watchdog, Ct);
driver.AgentInstanceId.ShouldBe(InstanceB, "a superseded pump must not write the old Agent's instanceId");
driver.AgentNextSequence.ShouldBe(NextSequenceB, "nor its cursor");
}
finally
{
wedge.Set();
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Positive control: the CURRENT pump's own cursor publish still lands — a last unsubscribe stops it, and the
/// session's cursor moves to where that pump got to.
/// </summary>
[Fact]
public async Task The_current_pump_still_publishes_its_cursor_when_it_stops()
{
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
},
"mt-t1-ok",
_ => client);
await driver.InitializeAsync("{}", Ct);
try
{
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(Watchdog, Ct);
await driver.UnsubscribeAsync(handle, Ct);
driver.AgentNextSequence.ShouldBe(113);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}