7.5 KiB
Bug: Timeouts Lost in Nested Application.Run() Calls
Summary
Timeouts scheduled via IApplication.AddTimeout() do not fire correctly when a nested modal dialog is shown using Application.Run(). This causes demo keys (and other scheduled timeouts) to be lost when MessageBox or other dialogs are displayed.
Environment
- Terminal.Gui Version: 2.0 (current main branch)
- OS: Windows/Linux/macOS (all platforms affected)
- .NET Version: .NET 8
Steps to Reproduce
Minimal Repro Code
using Terminal.Gui;
var app = Application.Create();
app.Init("FakeDriver");
var mainWindow = new Window { Title = "Main Window" };
var dialog = new Dialog { Title = "Dialog", Buttons = [new Button { Text = "Ok" }] };
// Schedule timeout at 100ms to show dialog
app.AddTimeout(TimeSpan.FromMilliseconds(100), () =>
{
Console.WriteLine("Enter timeout - showing dialog");
app.Run(dialog); // This blocks in a nested run loop
Console.WriteLine("Dialog closed");
return false;
});
// Schedule timeout at 200ms to close dialog (should fire while dialog is running)
app.AddTimeout(TimeSpan.FromMilliseconds(200), () =>
{
Console.WriteLine("ESC timeout - closing dialog");
app.RequestStop(dialog);
return false;
});
// Stop main window after dialog closes
app.AddTimeout(TimeSpan.FromMilliseconds(300), () =>
{
app.RequestStop();
return false;
});
app.Run(mainWindow);
app.Dispose();
Expected Behavior
- At 100ms: First timeout fires, shows dialog
- At 200ms: Second timeout fires while dialog is running, closes dialog
- At 300ms: Third timeout fires, closes main window
- Application exits cleanly
Actual Behavior
- At 100ms: First timeout fires, shows dialog
- At 200ms: Second timeout NEVER fires - dialog stays open indefinitely
- Application hangs waiting for dialog to close
Root Cause
The bug is in TimedEvents.RunTimersImpl():
private void RunTimersImpl()
{
long now = GetTimestampTicks();
SortedList<long, Timeout> copy;
lock (_timeoutsLockToken)
{
copy = _timeouts; // ? Copy ALL timeouts
_timeouts = new(); // ? Clear the queue
}
foreach ((long k, Timeout timeout) in copy)
{
if (k < now)
{
if (timeout.Callback!()) // ? This can block for a long time
{
AddTimeout(timeout.Span, timeout);
}
}
else
{
lock (_timeoutsLockToken)
{
_timeouts.Add(NudgeToUniqueKey(k), timeout);
}
}
}
}
The Problem
- All timeouts are removed from the queue at the start and copied to a local variable
- Callbacks are executed sequentially in the foreach loop
- When a callback blocks (e.g.,
app.Run(dialog)), the entireRunTimersImpl()method is paused - Future timeouts are stuck in the local
copyvariable, inaccessible to the nested run loop - The nested dialog's
RunTimers()calls see an empty timeout queue - Timeouts scheduled before the nested run never fire during the nested run
Why now is captured only once
Additionally, now = GetTimestampTicks() is captured once at the start. If a callback takes a long time, now becomes stale, and the time evaluation k < now uses outdated information.
Impact
This bug affects:
-
Example Demo Keys: The
ExampleDemoKeyStrokesAttributefeature doesn't work correctly when examples show MessageBox or dialogs. The ESC key to close dialogs is lost. -
Any automated testing that uses timeouts to simulate user input with modal dialogs
-
Application code that schedules timeouts expecting them to fire during nested
Application.Run()calls
Real-World Example
The bug was discovered in Examples/Example/Example.cs which has this demo key sequence:
[assembly: ExampleDemoKeyStrokes(
KeyStrokes = ["a", "d", "m", "i", "n", "Tab",
"p", "a", "s", "s", "w", "o", "r", "d",
"Enter", // ? Opens MessageBox
"Esc"], // ? Should close MessageBox, but never fires
Order = 1)]
When "Enter" is pressed, it triggers:
btnLogin.Accepting += (s, e) =>
{
if (userNameText.Text == "admin" && passwordText.Text == "password")
{
MessageBox.Query(App, "Logging In", "Login Successful", "Ok");
// ? This blocks in a nested Application.Run() call
// The ESC timeout scheduled for 1600ms never fires
}
};
Solution
Rewrite TimedEvents.RunTimersImpl() to process timeouts one at a time instead of batching them:
private void RunTimersImpl()
{
long now = GetTimestampTicks();
// Process due timeouts one at a time, without blocking the entire queue
while (true)
{
Timeout? timeoutToExecute = null;
long scheduledTime = 0;
// Find the next due timeout
lock (_timeoutsLockToken)
{
if (_timeouts.Count == 0)
{
break; // No more timeouts
}
// Re-evaluate current time for each iteration
now = GetTimestampTicks();
// Check if the earliest timeout is due
scheduledTime = _timeouts.Keys[0];
if (scheduledTime >= now)
{
// Earliest timeout is not yet due, we're done
break;
}
// This timeout is due - remove it from the queue
timeoutToExecute = _timeouts.Values[0];
_timeouts.RemoveAt(0);
}
// Execute the callback outside the lock
// This allows nested Run() calls to access the timeout queue
if (timeoutToExecute != null)
{
bool repeat = timeoutToExecute.Callback!();
if (repeat)
{
AddTimeout(timeoutToExecute.Span, timeoutToExecute);
}
}
}
}
Key Changes
- Lock ? Check ? Remove ? Unlock ? Execute pattern
- Only removes one timeout at a time that is currently due
- Executes callbacks outside the lock
- Future timeouts remain in the queue, accessible to nested
Run()calls - Re-evaluates current time on each iteration to handle long-running callbacks
Verification
The fix can be verified with these unit tests (all pass after fix):
[Fact]
public void Timeout_Fires_In_Nested_Run()
{
// Tests that a timeout fires during a nested Application.Run() call
}
[Fact]
public void Timeout_Scheduled_Before_Nested_Run_Fires_During_Nested_Run()
{
// Reproduces the exact ESC key issue scenario
}
[Fact]
public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run()
{
// Verifies timeout execution order with nested runs
}
See Tests/UnitTestsParallelizable/Application/NestedRunTimeoutTests.cs for complete test implementations.
Files Changed
Terminal.Gui/App/Timeout/TimedEvents.cs- FixedRunTimersImpl()methodTests/UnitTestsParallelizable/Application/NestedRunTimeoutTests.cs- Added comprehensive tests
Additional Notes
This is a critical bug for the Example infrastructure and any code that relies on timeouts working correctly with modal dialogs. The fix is non-breaking - all existing code continues to work, but nested run scenarios now work correctly.
Related Issues
- Demo keys not working when MessageBox is shown
- Timeouts appearing to "disappear" in complex UI flows
- Automated tests hanging when simulating input with dialogs