using Grpc.Core; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// ScadaBridge#140 — open streaming gRPC calls end on ApplicationStopping instead of holding /// Kestrel's graceful drain for the whole shutdown timeout, and new streams are refused while stopping. /// The end-to-end measurement (real Kestrel, real LocalDb sync peer, real Akka node) lives in /// IntegrationTests/GracefulStopWithOpenSyncStreamTests. /// public sealed class ShutdownStreamCancellationInterceptorTests { private static (ShutdownStreamCancellationInterceptor Interceptor, CancellationTokenSource Stopping) Create() { var stopping = new CancellationTokenSource(); var lifetime = Substitute.For(); lifetime.ApplicationStopping.Returns(stopping.Token); return (new ShutdownStreamCancellationInterceptor( lifetime, NullLogger.Instance), stopping); } /// An open server stream's token fires when the host starts stopping, and the call ends. /// A task that represents the asynchronous operation. [Fact] public async Task OpenServerStream_EndsOnApplicationStopping() { var (interceptor, stopping) = Create(); using var callCts = new CancellationTokenSource(); var context = new FakeServerCallContext("/svc/Stream", callCts.Token); var entered = new TaskCompletionSource(); ServerCallContext? seen = null; var call = interceptor.ServerStreamingServerHandler( "req", null!, context, async (_, _, ctx) => { seen = ctx; entered.SetResult(); await Task.Delay(Timeout.Infinite, ctx.CancellationToken).ContinueWith(_ => { }); }); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(call.IsCompleted); Assert.Equal("/svc/Stream", seen!.Method); stopping.Cancel(); await call.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(callCts.IsCancellationRequested, "the call's own token is not what ended it"); } /// The handler still ends when the client goes away (the call token), as before. /// A task that represents the asynchronous operation. [Fact] public async Task OpenDuplexStream_StillEndsOnTheCallToken() { var (interceptor, _) = Create(); using var callCts = new CancellationTokenSource(); var context = new FakeServerCallContext("/svc/Duplex", callCts.Token); var call = interceptor.DuplexStreamingServerHandler( null!, null!, context, (_, _, ctx) => Task.Delay(Timeout.Infinite, ctx.CancellationToken).ContinueWith(_ => { })); callCts.Cancel(); await call.WaitAsync(TimeSpan.FromSeconds(5)); } /// A client stream returns its handler's response through the wrapper. /// A task that represents the asynchronous operation. [Fact] public async Task ClientStream_ReturnsTheHandlerResponse() { var (interceptor, _) = Create(); var context = new FakeServerCallContext("/svc/Upload", CancellationToken.None); var response = await interceptor.ClientStreamingServerHandler( null!, context, (_, _) => Task.FromResult("done")); Assert.Equal("done", response); } /// Once stopping has begun, a new stream is refused with the retryable Unavailable. /// A task that represents the asynchronous operation. [Fact] public async Task NewStream_WhileStopping_IsRefusedUnavailable() { var (interceptor, stopping) = Create(); stopping.Cancel(); var invoked = false; var ex = await Assert.ThrowsAsync(() => interceptor.DuplexStreamingServerHandler( null!, null!, new FakeServerCallContext("/localdb_sync.v1.LocalDbSync/Sync", CancellationToken.None), (_, _, _) => { invoked = true; return Task.CompletedTask; })); Assert.Equal(StatusCode.Unavailable, ex.StatusCode); Assert.False(invoked); } /// Unary calls are left alone even while stopping — finishing them is what the drain is for. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryCall_WhileStopping_PassesThrough() { var (interceptor, stopping) = Create(); stopping.Cancel(); var reply = await interceptor.UnaryServerHandler( "req", new FakeServerCallContext("/svc/Unary", CancellationToken.None), (_, _) => Task.FromResult("ok")); Assert.Equal("ok", reply); } /// /// Program.cs registers the interceptor LAST (innermost, after the auth gates) on BOTH the /// central and the site AddGrpc pipelines. /// [Fact] public void Program_RegistersTheInterceptorInnermost_OnBothGrpcPipelines() { var program = Path.Combine(FindRepositoryRoot(), "src", "ZB.MOM.WW.ScadaBridge.Host", "Program.cs"); var root = CSharpSyntaxTree.ParseText(File.ReadAllText(program)).GetRoot(); var addGrpcLambdas = root.DescendantNodes().OfType() .Where(i => i.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddGrpc" }) .Select(i => i.ArgumentList.Arguments.Single().Expression) .OfType() .ToList(); Assert.Equal(2, addGrpcLambdas.Count); foreach (var lambda in addGrpcLambdas) { var added = lambda.DescendantNodes().OfType() .Where(n => n.Identifier.ValueText == "Add" && n.Parent is MemberAccessExpressionSyntax m && m.Expression.ToString().EndsWith("Interceptors", StringComparison.Ordinal)) .Select(n => n.TypeArgumentList.Arguments.Single().ToString()) .ToList(); Assert.True(added.Count >= 2, $"expected an auth gate and the shutdown interceptor, got [{string.Join(", ", added)}]"); Assert.Equal(nameof(ShutdownStreamCancellationInterceptor), added[^1]); } } private static string FindRepositoryRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.ScadaBridge.slnx"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("repository root not found"); } /// Minimal hand-rolled (grpc-dotnet ships no test context). private sealed class FakeServerCallContext(string method, CancellationToken token) : ServerCallContext { /// protected override string MethodCore => method; /// protected override string HostCore => "localhost"; /// protected override string PeerCore => "ipv4:127.0.0.1:12345"; /// protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); /// protected override Metadata RequestHeadersCore { get; } = []; /// protected override CancellationToken CancellationTokenCore => token; /// protected override Metadata ResponseTrailersCore { get; } = []; /// protected override Status StatusCore { get; set; } /// protected override WriteOptions? WriteOptionsCore { get; set; } /// protected override AuthContext AuthContextCore { get; } = new(null, new Dictionary>()); /// protected override IDictionary UserStateCore { get; } = new Dictionary(); /// protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotSupportedException(); /// protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } }