diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 0f72b885ca..2b11a6f369 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -227,15 +227,30 @@ public void Invoke (Action action) return null; } - try + // Loop to handle the case where End is cancelled by an IsRunningChanging handler. + // When End is cancelled, IsRunning remains true; we reset StopRequested and re-run the loop. + while (true) { - // All runnables block until RequestStop() is called - RunLoop (runnable, errorHandler); - } - finally - { - // End the session (raises IsRunningChanging/IsRunningChanged, pops from stack) - End (token); + try + { + // All runnables block until RequestStop() is called + RunLoop (runnable, errorHandler); + } + finally + { + // End the session (raises IsRunningChanging/IsRunningChanged, pops from stack) + End (token); + } + + // If End succeeded IsRunning is now false — we are done + if (!runnable.IsRunning) + { + break; + } + + // End was cancelled by an IsRunningChanging handler (e.g., "Are you sure?" veto). + // Reset StopRequested so RunLoop can re-enter its while condition correctly. + runnable.StopRequested = false; } return token.Result; diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs index 4e54200d36..5bec1075fe 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs @@ -392,6 +392,34 @@ public void Run_IsRunningChanging_Cancel_IsRunningChanged_Not_Raised () Assert.Equal (0, isRunningChanged); } + // Copilot + /// + /// Regression test for: https://github.com/gui-cs/Terminal.Gui/issues/4920 + /// StopRequested not reset when IsRunningChanging cancels a stop. + /// When a handler vetoes the stop (by cancelling IsRunningChanging), Run() must re-enter + /// the RunLoop instead of returning prematurely with IsRunning still true. + /// + [Fact] + public void Run_WhenStopCancelledByIsRunningChanging_RunLoopResumes () + { + IApplication app = NewMockedApplicationImpl ()!; + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + OnceCancelStopRunnable runnable = new (); + + // Act — must complete without hanging + app.Run (runnable); + + // The first stop attempt was cancelled (IsRunning stayed true temporarily). + // After the fix, StopRequested was reset and RunLoop re-entered; the second stop + // attempt succeeded so IsRunning is now false. + Assert.False (runnable.IsRunning); + Assert.Equal (1, runnable.CancelCount); + + app.Dispose (); + } + private bool IdleExit (IApplication app) { if (app.TopRunnableView != null) @@ -490,4 +518,29 @@ public void ApplicationImpl_UsesInstanceFields_NotStaticReferences () Assert.Null (v2.TopRunnableView); Assert.Empty (v2.SessionStack!); } + + /// + /// A runnable that cancels only the first stop attempt. + /// Used to simulate a "Are you sure?" veto pattern. + /// + private class OnceCancelStopRunnable : Runnable + { + private bool _firstStop = true; + + /// Gets how many times the stop was cancelled. + public int CancelCount { get; private set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + if (!newIsRunning && _firstStop) + { + _firstStop = false; + CancelCount++; + + return true; // Cancel this stop attempt + } + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } + } }