using Grpc.Net.Client; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// /// Arch-review 2026-08-19 P3-2 — a channel replaced by the 60 s DB address refresh (or an admin /// edit) must not be disposed out from under an in-flight /// call. ExecuteAsync reads the channel under the pair gate but runs the RPC outside it, so an /// inline dispose aborted whatever was running — a multi-minute DeployArtifactsCommand /// included — with a status the cross-node retry deliberately never engages on. /// public class SitePairChannelProviderDisposalTests { private static SitePairChannelProvider Build() => new( new NoKeyProvider(), Options.Create(new CommunicationOptions()), NullLogger.Instance); [Fact] public async Task UpdateSite_WhileCallInFlight_ParksTheOldChannelAndLetsTheCallComplete() { using var provider = Build(); provider.UpdateSite("site-1", "http://localhost:15101", nodeBEndpoint: null); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); GrpcChannel? inFlightChannel = null; // A long-running command (deploy) against the site's current channel. var call = provider.ExecuteAsync("site-1", async (channel, ct) => { inFlightChannel = channel; entered.SetResult(); await release.Task; // A disposed GrpcChannel throws here — this is the assertion that the address // refresh did not pull the channel out from under the call. _ = channel.CreateCallInvoker(); return 42; }, CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // The DB refresh tick re-points the site at a new address, mid-call. provider.UpdateSite("site-1", "http://localhost:15102", nodeBEndpoint: null); // The superseded channel is PARKED, not disposed synchronously. Assert.Equal(1, provider.ParkedChannelCount); release.SetResult(); Assert.Equal(42, await call.WaitAsync(TimeSpan.FromSeconds(5))); // Once the grace window elapses the park drains and the channel really is disposed — // deferred disposal, not a leak. provider.SweepGraveyard(force: true); Assert.Equal(0, provider.ParkedChannelCount); Assert.Throws(() => inFlightChannel!.CreateCallInvoker()); } [Fact] public void RemoveSite_ParksBothChannelsRatherThanDisposingThemInline() { using var provider = Build(); provider.UpdateSite("site-1", "http://localhost:15111", "http://localhost:15112"); provider.RemoveSite("site-1"); // Same hazard as UpdateSite: an admin deleting a site while a command is in flight. Assert.Equal(2, provider.ParkedChannelCount); provider.SweepGraveyard(force: true); Assert.Equal(0, provider.ParkedChannelCount); } [Fact] public void UpdateSite_UnchangedEndpoints_ParksNothing() { using var provider = Build(); provider.UpdateSite("site-1", "http://localhost:15121", "http://localhost:15122"); provider.UpdateSite("site-1", "http://localhost:15121", "http://localhost:15122"); Assert.Equal(0, provider.ParkedChannelCount); } [Fact] public void Dispose_DrainsParkedChannelsRegardlessOfRemainingGrace() { var provider = Build(); provider.UpdateSite("site-1", "http://localhost:15131", nodeBEndpoint: null); provider.UpdateSite("site-1", "http://localhost:15132", nodeBEndpoint: null); Assert.Equal(1, provider.ParkedChannelCount); provider.Dispose(); Assert.Equal(0, provider.ParkedChannelCount); } private sealed class NoKeyProvider : ISitePskProvider { public ValueTask GetAsync(string siteId, CancellationToken ct) => new("k"); public void Invalidate(string siteId) { } } }