fix(galaxy): seed the probe set at init, refuse pumps mid-teardown, bound the close
Review fix round 1 for lane F: - The ScanState probe watcher is seeded at InitializeAsync from a platform-only DiscoverHierarchy query (IGalaxyPlatformSource / GatewayGalaxyPlatformSource over the owned repository client) and re-seeded on every deploy-watcher rediscovery. The runtime never calls DiscoverAsync on a live driver, so per-platform host status was unreachable in production. A failed fetch logs a Warning and never fails init; re-seeds coalesce so a deploy during an in-flight pass still gets a pass that starts after it. - EnsureEventPumpStarted refuses while the runtime is stopping, BEFORE reusing the live pump (that pump belongs to the dying subscriber), and the production rebuild disposes any stale pump before wiring the new subscriber. - TeardownRuntimeAsync closes the MX session under the caller's token linked with a 5 s SessionCloseDeadline, so a blackholed gateway cannot hold DriverInstanceActor.PostStop for 30 s. - A runtime generation counter, bumped by every teardown, is re-checked before each Rebind in ResubscribeAllAsync, so a replay-gap re-snapshot that straddles a teardown cannot resurrect handles into the cleared registry. Refs #735 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
||||
|
||||
/// <summary>The platform-only query the driver seeds its ScanState probe watcher from.</summary>
|
||||
public sealed class GatewayGalaxyPlatformSourceTests
|
||||
{
|
||||
private static GalaxyObject Obj(string tag, params string[] chain)
|
||||
{
|
||||
var o = new GalaxyObject { TagName = tag };
|
||||
o.TemplateChain.Add(chain);
|
||||
return o;
|
||||
}
|
||||
|
||||
/// <summary>Every request carries the platform template filter, no attributes, and the page size; replies are re-filtered.</summary>
|
||||
[Fact]
|
||||
public async Task Requests_filter_on_platform_templates_without_attributes_and_replies_are_refiltered()
|
||||
{
|
||||
var requests = new List<DiscoverHierarchyRequest>();
|
||||
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
requests.Add(req);
|
||||
var reply = new DiscoverHierarchyReply();
|
||||
if (req.PageToken == "")
|
||||
{
|
||||
reply.Objects.Add([Obj("Plat01", "$WinPlatform"), Obj("Tank01", "$UserDefined")]);
|
||||
reply.NextPageToken = "p2";
|
||||
}
|
||||
else
|
||||
{
|
||||
reply.Objects.Add([Obj("Eng01", "$AppEngine", "$WinPlatform"), Obj("Plat01", "$WinPlatform"), Obj("", "$WinPlatform")]);
|
||||
}
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
var names = await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 50, CancellationToken.None);
|
||||
|
||||
names.ShouldBe(["Plat01", "Eng01"]);
|
||||
requests.Count.ShouldBe(2);
|
||||
requests.ShouldAllBe(r => r.PageSize == 50 && r.IncludeAttributes == false);
|
||||
requests.ShouldAllBe(r => r.TemplateChainContains.Contains("$WinPlatform") && r.TemplateChainContains.Contains("$AppEngine"));
|
||||
}
|
||||
|
||||
/// <summary>A gateway that strips the template chain is trusted to have filtered server-side.</summary>
|
||||
[Fact]
|
||||
public async Task Objects_without_a_template_chain_are_kept()
|
||||
{
|
||||
Task<DiscoverHierarchyReply> Discover(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
var reply = new DiscoverHierarchyReply();
|
||||
reply.Objects.Add(new GalaxyObject { TagName = "Plat01" });
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
(await GatewayGalaxyPlatformSource.QueryAsync(Discover, pageSize: 0, CancellationToken.None)).ShouldBe(["Plat01"]);
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Lane F fix round 1 (review of #735): the probe watcher is seeded at <c>InitializeAsync</c>
|
||||
/// and re-seeded on rediscovery (the runtime never calls <c>DiscoverAsync</c> on a live driver);
|
||||
/// a <c>SubscribeAsync</c> landing mid-teardown cannot create a pump on the dying subscriber; a
|
||||
/// gateway close that never answers cannot park <c>ShutdownAsync</c>; a re-subscribe that
|
||||
/// straddles a teardown never rebinds into the next runtime's registry.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverFixRound1Tests
|
||||
{
|
||||
private const int WaitMs = 10_000;
|
||||
|
||||
private static GalaxyDriverOptions Opts(bool watchDeploy = false) => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||||
new GalaxyMxAccessOptions("FixRound1"),
|
||||
new GalaxyRepositoryOptions(WatchDeployEvents: watchDeploy),
|
||||
new GalaxyReconnectOptions());
|
||||
|
||||
// ───────────────────────────── fakes ─────────────────────────────
|
||||
|
||||
private sealed class ScriptedPlatformSource : IGalaxyPlatformSource
|
||||
{
|
||||
private int _calls;
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
public Func<int, IReadOnlyList<string>> Script { get; init; } = _ => ["Plat01"];
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPlatformTagNamesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Script(Interlocked.Increment(ref _calls)));
|
||||
}
|
||||
|
||||
private sealed class ParkableSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
|
||||
private int _nextHandle;
|
||||
private int _subscribeCalls;
|
||||
|
||||
public Dictionary<string, int> HandleByRef { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<string> Subscribed { get; } = [];
|
||||
public int SubscribeCalls => Volatile.Read(ref _subscribeCalls);
|
||||
|
||||
/// <summary>When set, UnsubscribeBulk parks until <see cref="ReleaseUnsubscribe"/>.</summary>
|
||||
public bool ParkUnsubscribe { get; set; }
|
||||
public TaskCompletionSource UnsubscribeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _releaseUnsubscribe = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public void ReleaseUnsubscribe() => _releaseUnsubscribe.TrySetResult();
|
||||
|
||||
/// <summary>When > 0, that SubscribeBulk call (1-based) parks until <see cref="ReleaseSubscribe"/>.</summary>
|
||||
public int ParkSubscribeCall { get; set; }
|
||||
public TaskCompletionSource SubscribeEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _releaseSubscribe = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public void ReleaseSubscribe() => _releaseSubscribe.TrySetResult();
|
||||
|
||||
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
var call = Interlocked.Increment(ref _subscribeCalls);
|
||||
if (call == ParkSubscribeCall)
|
||||
{
|
||||
SubscribeEntered.TrySetResult();
|
||||
await _releaseSubscribe.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
var results = new List<SubscribeResult>();
|
||||
lock (Subscribed)
|
||||
{
|
||||
foreach (var r in fullReferences)
|
||||
{
|
||||
var h = Interlocked.Increment(ref _nextHandle);
|
||||
Subscribed.Add(r);
|
||||
HandleByRef[r] = h;
|
||||
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = h, WasSuccessful = true });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ParkUnsubscribe)
|
||||
{
|
||||
UnsubscribeEntered.TrySetResult();
|
||||
await _releaseUnsubscribe.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return ev;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask EmitBoolAsync(int handle, bool value) => _events.Writer.WriteAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = handle,
|
||||
Value = new MxValue { BoolValue = value },
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class ChannelDeployWatchSource : IGalaxyDeployWatchSource
|
||||
{
|
||||
public Channel<DeployEvent> Events { get; } = Channel.CreateUnbounded<DeployEvent>();
|
||||
public int Opened;
|
||||
|
||||
public async IAsyncEnumerable<DeployEvent> WatchAsync(
|
||||
DateTimeOffset? lastSeenDeployTime, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref Opened);
|
||||
await foreach (var ev in Events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return ev;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask PushAsync(ulong sequence, DateTimeOffset deployTime) => Events.Writer.WriteAsync(new DeployEvent
|
||||
{
|
||||
Sequence = sequence,
|
||||
TimeOfLastDeployPresent = true,
|
||||
TimeOfLastDeploy = Timestamp.FromDateTimeOffset(deployTime),
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(WaitMs);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new ShouldAssertException(because);
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static HostState? StateOf(GalaxyDriver driver, string host) =>
|
||||
driver.GetHostStatuses().FirstOrDefault(s => s.HostName == host)?.State;
|
||||
|
||||
// ───────────────────────────── IMPORTANT 1: seed at init, re-seed on rediscovery ─────────────────────────────
|
||||
|
||||
/// <summary>A fresh InitializeAsync with NO DiscoverAsync yields per-platform host statuses.</summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_seeds_the_probe_set_and_ScanState_updates_host_status_without_DiscoverAsync()
|
||||
{
|
||||
var sub = new ParkableSubscriber();
|
||||
var platforms = new ScriptedPlatformSource();
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, subscriber: sub, platformSource: platforms);
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
platforms.Calls.ShouldBe(1);
|
||||
sub.Subscribed.ShouldContain("Plat01.ScanState", "the probe watcher must be seeded at init");
|
||||
driver.ProbeWatcherForTests.ShouldNotBeNull().WatchedPlatforms.ShouldBe(["Plat01"]);
|
||||
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None); // starts the pump
|
||||
await sub.EmitBoolAsync(sub.HandleByRef["Plat01.ScanState"], true);
|
||||
await WaitUntilAsync(() => StateOf(driver, "Plat01") == HostState.Running,
|
||||
"a ScanState event must surface the seeded platform on GetHostStatuses");
|
||||
}
|
||||
|
||||
/// <summary>A failed seed does not fail init; the next deploy-watcher rediscovery re-seeds.</summary>
|
||||
[Fact]
|
||||
public async Task Failed_seed_does_not_fail_init_and_rediscovery_reseeds()
|
||||
{
|
||||
var sub = new ParkableSubscriber();
|
||||
var deploys = new ChannelDeployWatchSource();
|
||||
var platforms = new ScriptedPlatformSource
|
||||
{
|
||||
Script = call => call == 1 ? throw new IOException("repository unavailable") : ["Plat01", "Eng01"],
|
||||
};
|
||||
await using var driver = new GalaxyDriver("drv", Opts(watchDeploy: true), hierarchySource: null,
|
||||
subscriber: sub, platformSource: platforms, deployWatchSource: deploys);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a platform-seed failure must not fail init");
|
||||
sub.Subscribed.ShouldBeEmpty();
|
||||
|
||||
await WaitUntilAsync(() => Volatile.Read(ref deploys.Opened) == 1, "the deploy watch must open");
|
||||
await deploys.PushAsync(1, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // bootstrap (suppressed)
|
||||
await deploys.PushAsync(2, new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero)); // a real deploy
|
||||
|
||||
await WaitUntilAsync(() => Volatile.Read(ref raised) == 1, "positive control: the deploy must raise rediscovery");
|
||||
await WaitUntilAsync(() => driver.ProbeWatcherForTests?.WatchedPlatforms.Count == 2,
|
||||
"the rediscovery must re-seed the probe set");
|
||||
platforms.Calls.ShouldBe(2);
|
||||
sub.Subscribed.ShouldBe(["Plat01.ScanState", "Eng01.ScanState"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
private sealed class ParkingPlatformSource : IGalaxyPlatformSource
|
||||
{
|
||||
private int _calls;
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
public int ParkCall { get; init; }
|
||||
public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetPlatformTagNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var call = Interlocked.Increment(ref _calls);
|
||||
if (call == ParkCall)
|
||||
{
|
||||
Entered.TrySetResult();
|
||||
await _release.Task.WaitAsync(cancellationToken);
|
||||
return ["Plat01"]; // the pre-deploy view this pass fetched
|
||||
}
|
||||
return call == 1 ? [] : ["Plat01", "Eng02"];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deploy that lands while a re-seed pass is mid-fetch gets a pass of its own that starts
|
||||
/// after it — otherwise the probe set would stay on the stale view until the next deploy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_deploy_during_an_in_flight_reseed_gets_its_own_pass()
|
||||
{
|
||||
var sub = new ParkableSubscriber();
|
||||
var deploys = new ChannelDeployWatchSource();
|
||||
var platforms = new ParkingPlatformSource { ParkCall = 2 }; // call 1 = init seed, call 2 = first rediscovery
|
||||
await using var driver = new GalaxyDriver("drv", Opts(watchDeploy: true), hierarchySource: null,
|
||||
subscriber: sub, platformSource: platforms, deployWatchSource: deploys);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => Volatile.Read(ref deploys.Opened) == 1, "the deploy watch must open");
|
||||
await deploys.PushAsync(1, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // bootstrap
|
||||
await deploys.PushAsync(2, new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero));
|
||||
(await Task.WhenAny(platforms.Entered.Task, Task.Delay(WaitMs))).ShouldBe(platforms.Entered.Task,
|
||||
"the first rediscovery pass must be parked mid-fetch");
|
||||
|
||||
await deploys.PushAsync(3, new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero));
|
||||
await WaitUntilAsync(() => Volatile.Read(ref raised) == 2, "positive control: the second deploy raised rediscovery");
|
||||
}
|
||||
finally
|
||||
{
|
||||
platforms.Release();
|
||||
}
|
||||
|
||||
await WaitUntilAsync(() => platforms.Calls == 3, "the deploy that landed mid-pass must get its own re-seed");
|
||||
await WaitUntilAsync(() => driver.ProbeWatcherForTests?.WatchedPlatforms.Count == 2,
|
||||
"the final probe set must reflect the latest deploy, not the parked pass's stale view");
|
||||
}
|
||||
|
||||
/// <summary>The production path (owned runtime) queries the platform source at init, with no DiscoverAsync.</summary>
|
||||
[Fact]
|
||||
public async Task Production_path_queries_the_platform_source_at_init()
|
||||
{
|
||||
var platforms = new ScriptedPlatformSource();
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, platformSource: platforms)
|
||||
{
|
||||
SessionOpenOverrideForTests = _ => Task.CompletedTask,
|
||||
};
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
platforms.Calls.ShouldBe(1, "the owned runtime must seed its probe set at init");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ───────────────────────────── IMPORTANT 2: no pump on a dying subscriber ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A SubscribeAsync that lands while the teardown is parked (after the stopping flag, before
|
||||
/// the subscriber is released) is refused and leaves no pump; before the shutdown the same
|
||||
/// call works (positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_during_a_teardown_is_refused_and_leaves_no_pump()
|
||||
{
|
||||
var sub = new ParkableSubscriber();
|
||||
var platforms = new ScriptedPlatformSource(); // gives the probe watcher a handle to unsubscribe
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, subscriber: sub, platformSource: platforms);
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
driver.HasEventPumpForTests.ShouldBeTrue("positive control: subscribe works on a live runtime");
|
||||
|
||||
sub.ParkUnsubscribe = true;
|
||||
var shutdown = driver.ShutdownAsync(CancellationToken.None);
|
||||
var subscribeCallsBefore = sub.SubscribeCalls;
|
||||
try
|
||||
{
|
||||
(await Task.WhenAny(sub.UnsubscribeEntered.Task, Task.Delay(WaitMs))).ShouldBe(sub.UnsubscribeEntered.Task,
|
||||
"the teardown must be parked inside the probe watcher's unsubscribe");
|
||||
shutdown.IsCompleted.ShouldBeFalse();
|
||||
|
||||
// The pump is still live here (the teardown takes it after the probe watcher) — reusing it
|
||||
// is exactly the race: the call must be refused, not served on the dying subscriber.
|
||||
driver.HasEventPumpForTests.ShouldBeTrue("the interleaving under test: the old pump is not yet taken");
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.SubscribeAsync(["Tank02.Level"], TimeSpan.Zero, CancellationToken.None));
|
||||
sub.SubscribeCalls.ShouldBe(subscribeCallsBefore, "a refused subscribe must not reach the gateway");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Never leave the teardown parked: it holds the lifecycle gate the await-using dispose needs.
|
||||
sub.ReleaseUnsubscribe();
|
||||
}
|
||||
await shutdown.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
||||
driver.HasEventPumpForTests.ShouldBeFalse("no pump may survive the teardown");
|
||||
driver.TrackedSubscriptionCountForTests.ShouldBe(0);
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
driver.HasEventPumpForTests.ShouldBeFalse("the next runtime starts with no inherited pump");
|
||||
}
|
||||
|
||||
// ───────────────────────────── IMPORTANT 3: bounded gateway close ─────────────────────────────
|
||||
|
||||
/// <summary>A gateway CloseSession that never answers cannot park ShutdownAsync past the close deadline.</summary>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_is_bounded_when_the_gateway_close_never_answers()
|
||||
{
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, platformSource: new ScriptedPlatformSource())
|
||||
{
|
||||
SessionOpenOverrideForTests = _ => Task.CompletedTask,
|
||||
SessionCloseDeadline = TimeSpan.FromMilliseconds(300),
|
||||
};
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
var closeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var closeToken = CancellationToken.None;
|
||||
driver.OwnedMxSessionForTests.ShouldNotBeNull().CloseOverrideForTests = async ct =>
|
||||
{
|
||||
closeToken = ct;
|
||||
closeEntered.TrySetResult();
|
||||
await Task.Delay(Timeout.Infinite, ct); // a blackholed gateway
|
||||
};
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await driver.ShutdownAsync(CancellationToken.None).WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
||||
sw.Stop();
|
||||
|
||||
closeEntered.Task.IsCompleted.ShouldBeTrue("positive control: the close was attempted");
|
||||
closeToken.IsCancellationRequested.ShouldBeTrue("the close must have been cancelled by the deadline");
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5), "ShutdownAsync must return at the close deadline, not the SDK's 30 s");
|
||||
driver.OwnedMxSessionForTests.ShouldBeNull();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ───────────────────────────── MINOR: generation guard on a straddling re-subscribe ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A replay whose SubscribeBulk completes after a teardown must not Rebind into the cleared
|
||||
/// registry; the same replay without a teardown rebinds (positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_that_straddles_a_teardown_does_not_rebind()
|
||||
{
|
||||
// Positive control first.
|
||||
{
|
||||
var sub = new ParkableSubscriber();
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, subscriber: sub);
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
await driver.InvokeReplayForTestAsync(CancellationToken.None);
|
||||
driver.TrackedSubscriptionCountForTests.ShouldBe(1, "a replay on a live runtime rebinds the subscription");
|
||||
sub.SubscribeCalls.ShouldBe(2);
|
||||
}
|
||||
|
||||
{
|
||||
var sub = new ParkableSubscriber { ParkSubscribeCall = 2 };
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, subscriber: sub);
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
var replay = driver.InvokeReplayForTestAsync(CancellationToken.None);
|
||||
(await Task.WhenAny(sub.SubscribeEntered.Task, Task.Delay(WaitMs))).ShouldBe(sub.SubscribeEntered.Task,
|
||||
"the replay must be parked inside its SubscribeBulk");
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
driver.TrackedSubscriptionCountForTests.ShouldBe(0);
|
||||
|
||||
sub.ReleaseSubscribe();
|
||||
await replay.WaitAsync(TimeSpan.FromMilliseconds(WaitMs));
|
||||
|
||||
driver.TrackedSubscriptionCountForTests.ShouldBe(0, "a Rebind after the teardown would resurrect dead handles");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -125,6 +125,13 @@ public sealed class GalaxyDriverShutdownTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Canned platform set so a production-path init never dials the (fake) gateway for one.</summary>
|
||||
private sealed class NoPlatforms : IGalaxyPlatformSource
|
||||
{
|
||||
public Task<IReadOnlyList<string>> GetPlatformTagNamesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<string>>([]);
|
||||
}
|
||||
|
||||
private sealed class RecordingAlarmFeed : IGalaxyAlarmFeed
|
||||
{
|
||||
public int Starts;
|
||||
@@ -172,8 +179,9 @@ public sealed class GalaxyDriverShutdownTests
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
(await Task.WhenAny(sub.StreamCancelled.Task, Task.Delay(WaitMs))).ShouldBe(sub.StreamCancelled.Task,
|
||||
"ShutdownAsync must cancel the StreamEvents stream");
|
||||
// No wait: the pump's Cancel runs the stream's registration synchronously and ShutdownAsync
|
||||
// awaited the pump's dispose, so the signal is either already set or the stream was never cancelled.
|
||||
sub.StreamCancelled.Task.IsCompleted.ShouldBeTrue("ShutdownAsync must cancel the StreamEvents stream");
|
||||
driver.HasEventPumpForTests.ShouldBeFalse();
|
||||
driver.TrackedSubscriptionCountForTests.ShouldBe(0, "handles died with the session; the host re-subscribes after re-init");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
@@ -289,7 +297,8 @@ public sealed class GalaxyDriverShutdownTests
|
||||
{
|
||||
var opens = 0;
|
||||
var source = new CountingDeployWatchSource();
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, deployWatchSource: source)
|
||||
await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, deployWatchSource: source,
|
||||
platformSource: new NoPlatforms())
|
||||
{
|
||||
SessionOpenOverrideForTests = _ => { opens++; return Task.CompletedTask; },
|
||||
};
|
||||
@@ -338,7 +347,8 @@ public sealed class GalaxyDriverShutdownTests
|
||||
public async Task Failed_InitializeAsync_releases_the_partial_runtime_and_the_retry_rebuilds()
|
||||
{
|
||||
var attempts = 0;
|
||||
await using var driver = new GalaxyDriver("drv", Opts(watchDeploy: false), hierarchySource: null)
|
||||
await using var driver = new GalaxyDriver("drv", Opts(watchDeploy: false), hierarchySource: null,
|
||||
platformSource: new NoPlatforms())
|
||||
{
|
||||
SessionOpenOverrideForTests = _ => ++attempts == 1
|
||||
? throw new InvalidOperationException("gateway refused")
|
||||
|
||||
Reference in New Issue
Block a user