MainLoopCoordinatorTests.cs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. #nullable enable
  2. using System.Collections.Concurrent;
  3. using System.Diagnostics;
  4. // ReSharper disable AccessToDisposedClosure
  5. #pragma warning disable xUnit1031
  6. namespace ApplicationTests;
  7. /// <summary>
  8. /// Tests for <see cref="MainLoopCoordinator{TInputRecord}"/> to verify input loop lifecycle.
  9. /// These tests ensure that the input thread starts, runs, and stops correctly when applications
  10. /// are created, initialized, and disposed.
  11. /// </summary>
  12. public class MainLoopCoordinatorTests : IDisposable
  13. {
  14. private readonly List<IApplication> _createdApps = new ();
  15. public void Dispose ()
  16. {
  17. // Cleanup any apps that weren't disposed in tests
  18. foreach (IApplication app in _createdApps)
  19. {
  20. try
  21. {
  22. app.Dispose ();
  23. }
  24. catch
  25. {
  26. // Ignore cleanup errors
  27. }
  28. }
  29. _createdApps.Clear ();
  30. }
  31. private IApplication CreateApp ()
  32. {
  33. IApplication app = Application.Create ();
  34. _createdApps.Add (app);
  35. return app;
  36. }
  37. /// <summary>
  38. /// Verifies that Dispose() stops the input loop when using Application.Create().
  39. /// This is the key test that proves the input thread respects cancellation.
  40. /// </summary>
  41. [Fact]
  42. public void Application_Dispose_Stops_Input_Loop ()
  43. {
  44. // Arrange
  45. IApplication app = CreateApp ();
  46. app.Init ("fake");
  47. // The input thread should now be running
  48. Assert.NotNull (app.Driver);
  49. Assert.True (app.Initialized);
  50. // Act - Dispose the application
  51. var sw = Stopwatch.StartNew ();
  52. app.Dispose ();
  53. sw.Stop ();
  54. // Assert - Dispose should complete quickly (within 1 second)
  55. // If the input thread doesn't stop, this will hang and the test will timeout
  56. Assert.True (sw.ElapsedMilliseconds < 1000, $"Dispose() took {sw.ElapsedMilliseconds}ms - input thread may not have stopped");
  57. // Verify the application is properly disposed
  58. Assert.Null (app.Driver);
  59. Assert.False (app.Initialized);
  60. _createdApps.Remove (app);
  61. }
  62. /// <summary>
  63. /// Verifies that calling Dispose() multiple times doesn't cause issues.
  64. /// </summary>
  65. [Fact]
  66. public void Dispose_Called_Multiple_Times_Does_Not_Throw ()
  67. {
  68. // Arrange
  69. IApplication app = CreateApp ();
  70. app.Init ("fake");
  71. // Act - Call Dispose() multiple times
  72. Exception? exception = Record.Exception (() =>
  73. {
  74. app.Dispose ();
  75. app.Dispose ();
  76. app.Dispose ();
  77. });
  78. // Assert - Should not throw
  79. Assert.Null (exception);
  80. _createdApps.Remove (app);
  81. }
  82. /// <summary>
  83. /// Verifies that multiple applications can be created and disposed without thread leaks.
  84. /// This simulates the ColorPicker test scenario where multiple ApplicationImpl instances
  85. /// are created in parallel tests and must all be properly cleaned up.
  86. /// </summary>
  87. [Fact]
  88. public void Multiple_Applications_Dispose_Without_Thread_Leaks ()
  89. {
  90. const int COUNT = 5;
  91. IApplication [] apps = new IApplication [COUNT];
  92. // Arrange - Create multiple applications (simulating parallel test scenario)
  93. for (var i = 0; i < COUNT; i++)
  94. {
  95. apps [i] = Application.Create ();
  96. apps [i].Init ("fake");
  97. }
  98. // Act - Dispose all applications
  99. var sw = Stopwatch.StartNew ();
  100. for (var i = 0; i < COUNT; i++)
  101. {
  102. apps [i].Dispose ();
  103. }
  104. sw.Stop ();
  105. // Assert - All disposals should complete quickly
  106. // If input threads don't stop, this will hang or take a very long time
  107. Assert.True (sw.ElapsedMilliseconds < 5000, $"Disposing {COUNT} apps took {sw.ElapsedMilliseconds}ms - input threads may not have stopped");
  108. }
  109. /// <summary>
  110. /// Verifies that the 20ms throttle limits the input loop poll rate to prevent CPU spinning.
  111. /// This test proves throttling exists by verifying the poll rate is bounded (not millions of calls).
  112. /// The test uses an upper bound approach to avoid timing sensitivity issues during parallel execution.
  113. /// </summary>
  114. [Fact (Skip = "Can't get this to run reliably.")]
  115. public void InputLoop_Throttle_Limits_Poll_Rate ()
  116. {
  117. // Arrange - Create a FakeInput and manually run it with throttling
  118. FakeInput input = new FakeInput ();
  119. ConcurrentQueue<ConsoleKeyInfo> queue = new ConcurrentQueue<ConsoleKeyInfo> ();
  120. input.Initialize (queue);
  121. CancellationTokenSource cts = new CancellationTokenSource ();
  122. // Act - Run the input loop for 500ms
  123. // Short duration reduces test time while still proving throttle exists
  124. Task inputTask = Task.Run (() => input.Run (cts.Token), cts.Token);
  125. Thread.Sleep (500);
  126. int peekCount = input.PeekCallCount;
  127. cts.Cancel ();
  128. // Wait for task to complete
  129. bool completed = inputTask.Wait (TimeSpan.FromSeconds (10));
  130. Assert.True (completed, "Input task did not complete within timeout");
  131. // Assert - The key insight: throttle prevents CPU spinning
  132. // With 20ms throttle: ~25 calls in 500ms (but can be much less under load)
  133. // WITHOUT throttle: Would be 10,000+ calls minimum (tight spin loop)
  134. //
  135. // We use an upper bound test: verify it's NOT spinning wildly
  136. // This is much more reliable than testing exact timing under parallel load
  137. //
  138. // Max 500 calls = average 1ms between polls (still proves 20ms throttle exists)
  139. // Without throttle = millions of calls (tight loop)
  140. Assert.True (peekCount < 500, $"Poll count {peekCount} suggests no throttling (expected <500 with 20ms throttle)");
  141. // Also verify the thread actually ran (not immediately cancelled)
  142. Assert.True (peekCount > 0, $"Poll count was {peekCount} - thread may not have started");
  143. input.Dispose ();
  144. }
  145. /// <summary>
  146. /// Verifies that the 20ms throttle prevents CPU spinning even with many leaked applications.
  147. /// Before the throttle fix, 10+ leaked apps would saturate the CPU with tight spin loops.
  148. /// </summary>
  149. [Fact]
  150. public void Throttle_Prevents_CPU_Saturation_With_Leaked_Apps ()
  151. {
  152. const int COUNT = 10;
  153. IApplication [] apps = new IApplication [COUNT];
  154. // Arrange - Create multiple applications WITHOUT disposing them (simulating the leak)
  155. for (var i = 0; i < COUNT; i++)
  156. {
  157. apps [i] = Application.Create ();
  158. apps [i].Init ("fake");
  159. }
  160. // Let them run for a moment
  161. Thread.Sleep (100);
  162. // Act - Now dispose them all and measure how long it takes
  163. var sw = Stopwatch.StartNew ();
  164. for (var i = 0; i < COUNT; i++)
  165. {
  166. apps [i].Dispose ();
  167. }
  168. sw.Stop ();
  169. // Assert - Even with 10 leaked apps, disposal should be fast
  170. // Before the throttle fix, this would take many seconds due to CPU saturation
  171. // With the throttle, each thread does Task.Delay(20ms) and exits within ~20-40ms
  172. Assert.True (sw.ElapsedMilliseconds < 2000, $"Disposing {COUNT} apps took {sw.ElapsedMilliseconds}ms - CPU may be saturated");
  173. }
  174. }