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);
+ }
+ }
}