From 1046b47be7bd7369439a5204cd1c853e9b20a05e Mon Sep 17 00:00:00 2001 From: BDisp Date: Sun, 26 Oct 2025 02:15:13 +0000 Subject: [PATCH] Fixes #4325. ApplicationImpl.Invoke is sometimes running on UI thread when Application.Top is null (#4339) --- Terminal.Gui/App/ApplicationImpl.cs | 2 +- Terminal.Gui/Drivers/DotNetDriver/NetInput.cs | 5 + Tests/StressTests/ApplicationStressTests.cs | 11 ++- .../UnitTests/Application/ApplicationTests.cs | 3 +- Tests/UnitTests/Application/MainLoopTests.cs | 98 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/Terminal.Gui/App/ApplicationImpl.cs b/Terminal.Gui/App/ApplicationImpl.cs index 426d7d238..0eb2a35f8 100644 --- a/Terminal.Gui/App/ApplicationImpl.cs +++ b/Terminal.Gui/App/ApplicationImpl.cs @@ -499,7 +499,7 @@ public class ApplicationImpl : IApplication public void Invoke (Action action) { // If we are already on the main UI thread - if (_mainThreadId == Thread.CurrentThread.ManagedThreadId) + if (Application.Top is { Running: true } && _mainThreadId == Thread.CurrentThread.ManagedThreadId) { action (); return; diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs index bdc7c6fc1..c9a0e5b27 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs @@ -86,6 +86,11 @@ public class NetInput : ConsoleInput, INetInput { base.Dispose (); + if (ConsoleDriver.RunningUnitTests) + { + return; + } + // Disable mouse events first Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents); diff --git a/Tests/StressTests/ApplicationStressTests.cs b/Tests/StressTests/ApplicationStressTests.cs index 848725a3f..8e85bf0d5 100644 --- a/Tests/StressTests/ApplicationStressTests.cs +++ b/Tests/StressTests/ApplicationStressTests.cs @@ -17,9 +17,7 @@ public class ApplicationStressTests : TestsAllViews private const int NUM_PASSES = 50; private const int NUM_INCREMENTS = 500; - - // Use longer timeout when running under debugger to account for slower iterations - private static readonly int POLL_MS = System.Diagnostics.Debugger.IsAttached ? 500 : 100; + private const int POLL_MS = 100; /// /// Stress test for Application.Invoke to verify that invocations from background threads @@ -79,6 +77,13 @@ public class ApplicationStressTests : TestsAllViews while (_tbCounter != (j + 1) * numIncrements) // Wait for tbCounter to reach expected value { int tbNow = _tbCounter; + + // Wait for Application.Top to be running to ensure timed events can be processed + while (Application.Top is null || Application.Top is { Running: false }) + { + Thread.Sleep (1); + } + _wakeUp.Wait (pollMs); if (_tbCounter != tbNow) diff --git a/Tests/UnitTests/Application/ApplicationTests.cs b/Tests/UnitTests/Application/ApplicationTests.cs index 6b820f6a8..4c3cc4acb 100644 --- a/Tests/UnitTests/Application/ApplicationTests.cs +++ b/Tests/UnitTests/Application/ApplicationTests.cs @@ -586,11 +586,10 @@ public class ApplicationTests { var top = new Toplevel (); RunState rs = Application.Begin (top); - var firstIteration = false; var actionCalled = 0; Application.Invoke (() => { actionCalled++; }); - Application.RunIteration (ref rs, firstIteration); + ApplicationImpl.Instance.TimedEvents!.RunTimers (); Assert.Equal (1, actionCalled); top.Dispose (); Application.Shutdown (); diff --git a/Tests/UnitTests/Application/MainLoopTests.cs b/Tests/UnitTests/Application/MainLoopTests.cs index 43797c8ba..0a04f3c4f 100644 --- a/Tests/UnitTests/Application/MainLoopTests.cs +++ b/Tests/UnitTests/Application/MainLoopTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Xunit.Abstractions; // Alias Console to MockConsole so we don't accidentally use Console @@ -7,6 +8,14 @@ namespace UnitTests.ApplicationTests; /// Tests MainLoop using the FakeMainLoop. public class MainLoopTests { + private readonly ITestOutputHelper _output; + + public MainLoopTests (ITestOutputHelper output) + { + _output = output; + ConsoleDriver.RunningUnitTests = true; + } + private static Button btn; private static string cancel; private static string clickMe; @@ -708,6 +717,95 @@ public class MainLoopTests Assert.Equal (10, functionCalled); } + [Theory] + [InlineData ("fake")] + [InlineData ("windows")] + [InlineData ("dotnet")] + [InlineData ("unix")] + public void Application_Invoke_Run_TimedEvents (string driverName) + { + // Arrange + Application.Init (driverName: driverName); + var functionCalled = 0; + var stopwatch = new Stopwatch (); + + // Act + Application.Invoke (() => + { + // Stop the stopwatch *after* the function is called. + functionCalled++; + stopwatch.Stop (); + Application.RequestStop (); + }); + + // Start timing just before running the application loop. + stopwatch.Start (); + Application.Run (); + + // Assert + Assert.NotNull (Application.Top); + Application.Top.Dispose (); + Application.Shutdown (); + Assert.Equal (1, functionCalled); + + // Output the elapsed time for this test case. + // ReSharper disable once Xunit.XunitTestWithConsoleOutput + // ReSharper disable once LocalizableElement + Console.WriteLine ($"[{driverName}] Duration: {stopwatch.Elapsed.TotalMilliseconds:F2} ms"); + + // Output elapsed duration to xUnit's test output + _output.WriteLine ($"[{driverName}] Duration: {stopwatch.Elapsed.TotalMilliseconds:F2} ms"); + } + + [Theory] + [InlineData ("fake")] + [InlineData ("windows")] + [InlineData ("dotnet")] + [InlineData ("unix")] + public void Application_AddTimeout_Run_TimedEvents (string driverName) + { + // Arrange + Application.Init (driverName: driverName); + var functionCalled = 0; + var stopwatch = new Stopwatch (); + + // Act + bool Function () + { + functionCalled++; + + if (functionCalled == 10 && Application.Top is { Running: true }) + { + stopwatch.Stop (); + Application.RequestStop (); + + return false; + } + + return true; + } + + Application.AddTimeout (TimeSpan.FromMilliseconds (1), Function); + + // Start timing just before running the application loop. + stopwatch.Start (); + Application.Run (); + + // Assert + Assert.NotNull (Application.Top); + Application.Top.Dispose (); + Application.Shutdown (); + Assert.Equal (10, functionCalled); + + // Output the elapsed time for this test case. + // ReSharper disable once Xunit.XunitTestWithConsoleOutput + // ReSharper disable once LocalizableElement + Console.WriteLine ($"[{driverName}] Duration: {stopwatch.Elapsed.TotalMilliseconds:F2} ms"); + + // Output elapsed duration to xUnit's test output + _output.WriteLine ($"[{driverName}] Duration: {stopwatch.Elapsed.TotalMilliseconds:F2} ms"); + } + public static IEnumerable TestAddTimeout { get