using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Communication.Grpc; namespace ScadaLink.Communication.Tests.Grpc; /// /// Regression tests for Communication-007 — the factory's synchronous /// must not block on the /// async disposal path (sync-over-async). It must dispose each client through /// the client's synchronous . /// public class SiteStreamGrpcClientFactoryDisposeTests { /// /// Test client that records whether it was disposed via the sync or async path. /// private sealed class TrackingClient : SiteStreamGrpcClient { public bool SyncDisposeCalled { get; private set; } public bool AsyncDisposeCalled { get; private set; } public override void Dispose() => SyncDisposeCalled = true; public override ValueTask DisposeAsync() { AsyncDisposeCalled = true; return ValueTask.CompletedTask; } } /// /// Test factory that hands out instances while /// still exercising the base factory's real caching and disposal machinery. /// private sealed class TrackingFactory : SiteStreamGrpcClientFactory { private readonly ConcurrentBag _created = new(); public TrackingFactory() : base(NullLoggerFactory.Instance) { } public IReadOnlyCollection Created => _created.ToList(); protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint) { var client = new TrackingClient(); _created.Add(client); return client; } } [Fact] public void Dispose_DisposesClientsSynchronously_NotViaAsyncPath() { var factory = new TrackingFactory(); factory.GetOrCreate("site-a", "http://localhost:5100"); factory.GetOrCreate("site-b", "http://localhost:5200"); factory.Dispose(); Assert.NotEmpty(factory.Created); Assert.All(factory.Created, c => { Assert.True(c.SyncDisposeCalled, "client should be disposed via synchronous Dispose()"); Assert.False(c.AsyncDisposeCalled, "synchronous Dispose() must not route through DisposeAsync()"); }); } [Fact] public void Dispose_DoesNotDeadlock_UnderSingleThreadedSynchronizationContext() { // A strict single-threaded SynchronizationContext: continuations posted to // it are only pumped by the worker loop. Sync-over-async (blocking the only // thread on an async continuation that needs that same thread) deadlocks here. using var ctx = new SingleThreadSyncContext(); Exception? captured = null; var done = new ManualResetEventSlim(); ctx.Post(_ => { try { var factory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); factory.GetOrCreate("site-a", "http://localhost:5100"); factory.Dispose(); } catch (Exception ex) { captured = ex; } finally { done.Set(); } }, null); Assert.True(done.Wait(TimeSpan.FromSeconds(5)), "factory.Dispose() did not complete — likely a sync-over-async deadlock"); Assert.Null(captured); } /// Minimal single-threaded synchronization context for the deadlock test. private sealed class SingleThreadSyncContext : SynchronizationContext, IDisposable { private readonly BlockingCollection<(SendOrPostCallback cb, object? state)> _queue = new(); private readonly Thread _thread; public SingleThreadSyncContext() { _thread = new Thread(Run) { IsBackground = true }; _thread.Start(); } private void Run() { SetSynchronizationContext(this); foreach (var (cb, state) in _queue.GetConsumingEnumerable()) cb(state); } public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); public void Dispose() { _queue.CompleteAdding(); _thread.Join(TimeSpan.FromSeconds(2)); } } }