#nullable enable using System.Collections.Concurrent; using System.Diagnostics; // ReSharper disable AccessToDisposedClosure #pragma warning disable xUnit1031 namespace ApplicationTests; /// /// Tests for to verify input loop lifecycle. /// These tests ensure that the input thread starts, runs, and stops correctly when applications /// are created, initialized, and disposed. /// public class MainLoopCoordinatorTests : IDisposable { private readonly List _createdApps = new (); public void Dispose () { // Cleanup any apps that weren't disposed in tests foreach (IApplication app in _createdApps) { try { app.Dispose (); } catch { // Ignore cleanup errors } } _createdApps.Clear (); } private IApplication CreateApp () { IApplication app = Application.Create (); _createdApps.Add (app); return app; } /// /// Verifies that Dispose() stops the input loop when using Application.Create(). /// This is the key test that proves the input thread respects cancellation. /// [Fact] public void Application_Dispose_Stops_Input_Loop () { // Arrange IApplication app = CreateApp (); app.Init ("fake"); // The input thread should now be running Assert.NotNull (app.Driver); Assert.True (app.Initialized); // Act - Dispose the application var sw = Stopwatch.StartNew (); app.Dispose (); sw.Stop (); // Assert - Dispose should complete quickly (within 1 second) // If the input thread doesn't stop, this will hang and the test will timeout Assert.True (sw.ElapsedMilliseconds < 1000, $"Dispose() took {sw.ElapsedMilliseconds}ms - input thread may not have stopped"); // Verify the application is properly disposed Assert.Null (app.Driver); Assert.False (app.Initialized); _createdApps.Remove (app); } /// /// Verifies that calling Dispose() multiple times doesn't cause issues. /// [Fact] public void Dispose_Called_Multiple_Times_Does_Not_Throw () { // Arrange IApplication app = CreateApp (); app.Init ("fake"); // Act - Call Dispose() multiple times Exception? exception = Record.Exception (() => { app.Dispose (); app.Dispose (); app.Dispose (); }); // Assert - Should not throw Assert.Null (exception); _createdApps.Remove (app); } /// /// Verifies that multiple applications can be created and disposed without thread leaks. /// This simulates the ColorPicker test scenario where multiple ApplicationImpl instances /// are created in parallel tests and must all be properly cleaned up. /// [Fact] public void Multiple_Applications_Dispose_Without_Thread_Leaks () { const int COUNT = 5; IApplication [] apps = new IApplication [COUNT]; // Arrange - Create multiple applications (simulating parallel test scenario) for (var i = 0; i < COUNT; i++) { apps [i] = Application.Create (); apps [i].Init ("fake"); } // Act - Dispose all applications var sw = Stopwatch.StartNew (); for (var i = 0; i < COUNT; i++) { apps [i].Dispose (); } sw.Stop (); // Assert - All disposals should complete quickly // If input threads don't stop, this will hang or take a very long time Assert.True (sw.ElapsedMilliseconds < 5000, $"Disposing {COUNT} apps took {sw.ElapsedMilliseconds}ms - input threads may not have stopped"); } /// /// Verifies that the 20ms throttle limits the input loop poll rate to prevent CPU spinning. /// This test proves throttling exists by verifying the poll rate is bounded (not millions of calls). /// The test uses an upper bound approach to avoid timing sensitivity issues during parallel execution. /// [Fact (Skip = "Can't get this to run reliably.")] public void InputLoop_Throttle_Limits_Poll_Rate () { // Arrange - Create a FakeInput and manually run it with throttling FakeInput input = new FakeInput (); ConcurrentQueue queue = new ConcurrentQueue (); input.Initialize (queue); CancellationTokenSource cts = new CancellationTokenSource (); // Act - Run the input loop for 500ms // Short duration reduces test time while still proving throttle exists Task inputTask = Task.Run (() => input.Run (cts.Token), cts.Token); Thread.Sleep (500); int peekCount = input.PeekCallCount; cts.Cancel (); // Wait for task to complete bool completed = inputTask.Wait (TimeSpan.FromSeconds (10)); Assert.True (completed, "Input task did not complete within timeout"); // Assert - The key insight: throttle prevents CPU spinning // With 20ms throttle: ~25 calls in 500ms (but can be much less under load) // WITHOUT throttle: Would be 10,000+ calls minimum (tight spin loop) // // We use an upper bound test: verify it's NOT spinning wildly // This is much more reliable than testing exact timing under parallel load // // Max 500 calls = average 1ms between polls (still proves 20ms throttle exists) // Without throttle = millions of calls (tight loop) Assert.True (peekCount < 500, $"Poll count {peekCount} suggests no throttling (expected <500 with 20ms throttle)"); // Also verify the thread actually ran (not immediately cancelled) Assert.True (peekCount > 0, $"Poll count was {peekCount} - thread may not have started"); input.Dispose (); } /// /// Verifies that the 20ms throttle prevents CPU spinning even with many leaked applications. /// Before the throttle fix, 10+ leaked apps would saturate the CPU with tight spin loops. /// [Fact] public void Throttle_Prevents_CPU_Saturation_With_Leaked_Apps () { const int COUNT = 10; IApplication [] apps = new IApplication [COUNT]; // Arrange - Create multiple applications WITHOUT disposing them (simulating the leak) for (var i = 0; i < COUNT; i++) { apps [i] = Application.Create (); apps [i].Init ("fake"); } // Let them run for a moment Thread.Sleep (100); // Act - Now dispose them all and measure how long it takes var sw = Stopwatch.StartNew (); for (var i = 0; i < COUNT; i++) { apps [i].Dispose (); } sw.Stop (); // Assert - Even with 10 leaked apps, disposal should be fast // Before the throttle fix, this would take many seconds due to CPU saturation // With the throttle, each thread does Task.Delay(20ms) and exits within ~20-40ms Assert.True (sw.ElapsedMilliseconds < 2000, $"Disposing {COUNT} apps took {sw.ElapsedMilliseconds}ms - CPU may be saturated"); } }