WIP: submitting #4429

This commit is contained in:
Tig
2025-12-02 14:06:55 -07:00
parent e8410f5bd9
commit dc6ddcb898
19 changed files with 909 additions and 243 deletions

View File

@@ -20,7 +20,7 @@
<PackageVersion Include="System.IO.Abstractions" Version="[22.0.16,23)" />
<PackageVersion Include="Wcwidth" Version="[4.0.0,)" />
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="[1.21.2,2)" />
<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />

View File

@@ -12,9 +12,7 @@ using Terminal.Gui.Views;
// Example metadata
[assembly: Terminal.Gui.Examples.ExampleMetadata ("Simple Example", "A basic login form demonstrating Terminal.Gui fundamentals")]
[assembly: Terminal.Gui.Examples.ExampleCategory ("Getting Started")]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:500", "a", "d", "m", "i", "n", "Tab", "p", "a", "s", "s", "w", "o", "r", "d", "Enter"], Order = 1)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:500", "Enter"], Order = 2)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:100", "Esc"], Order = 3)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["a", "d", "m", "i", "n", "Tab", "p", "a", "s", "s", "w", "o", "r", "d", "Enter", "Esc"], Order = 1)]
// Override the default configuration for the application to use the Light theme
ConfigurationManager.RuntimeConfig = """{ "Theme": "Light" }""";
@@ -23,19 +21,18 @@ ConfigurationManager.Enable (ConfigLocations.All);
IApplication app = Application.Create (example: true);
app.Init ();
app.Run<ExampleWindow> ();
string? result = app.GetResult<string> ();
// Dispose the app to clean up and enable Console.WriteLine below
app.Dispose ();
// To see this output on the screen it must be done after shutdown,
// which restores the previous screen.
Console.WriteLine ($@"Username: {ExampleWindow.UserName}");
Console.WriteLine ($@"Username: {result}");
// Defines a top-level window with border and title
public sealed class ExampleWindow : Window
{
public static string UserName { get; set; }
public ExampleWindow ()
{
Title = $"Example App ({Application.QuitKey} to quit)";
@@ -84,8 +81,8 @@ public sealed class ExampleWindow : Window
if (userNameText.Text == "admin" && passwordText.Text == "password")
{
MessageBox.Query (App, "Logging In", "Login Successful", "Ok");
UserName = userNameText.Text;
Application.RequestStop ();
Result = userNameText.Text;
App?.RequestStop ();
}
else
{
@@ -98,14 +95,5 @@ public sealed class ExampleWindow : Window
// Add the views to the Window
Add (usernameLabel, userNameText, passwordLabel, passwordText, btnLogin);
var lv = new ListView
{
Y = Pos.AnchorEnd (),
Height = Dim.Auto (),
Width = Dim.Auto ()
};
lv.SetSource (["One", "Two", "Three", "Four"]);
Add (lv);
}
}

View File

@@ -9,6 +9,12 @@
<Version>2.0</Version>
<InformationalVersion>2.0</InformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
</ItemGroup>

View File

@@ -1,16 +1,34 @@
#nullable enable
// Example Runner - Demonstrates discovering and running all examples using the example infrastructure
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Terminal.Gui.App;
using Terminal.Gui.Configuration;
using Terminal.Gui.Examples;
using ILogger = Microsoft.Extensions.Logging.ILogger;
[assembly: ExampleMetadata ("Example Runner", "Discovers and runs all examples sequentially")]
[assembly: ExampleCategory ("Infrastructure")]
// Configure Serilog to write to Debug output and Console
Log.Logger = new LoggerConfiguration ()
.MinimumLevel.Is (LogEventLevel.Verbose)
.WriteTo.Debug ()
.CreateLogger ();
ILogger logger = LoggerFactory.Create (builder =>
{
builder
.AddSerilog (dispose: true) // Integrate Serilog with ILogger
.SetMinimumLevel (LogLevel.Trace); // Set minimum log level
}).CreateLogger ("ExampleRunner Logging");
Logging.Logger = logger;
Logging.Debug ("Logging enabled - writing to Debug output\n");
// Parse command line arguments
bool useFakeDriver = args.Contains ("--fake-driver") || args.Contains ("-f");
int timeout = 5000; // Default timeout in milliseconds
int timeout = 30000; // Default timeout in milliseconds
for (var i = 0; i < args.Length; i++)
{
@@ -131,4 +149,7 @@ if (useFakeDriver)
Console.WriteLine ("\nNote: Tests run with FakeDriver. Some examples may timeout if they don't respond to Esc key.");
}
// Flush logs before exiting
Log.CloseAndFlush ();
return failCount == 0 ? 0 : 1;

View File

@@ -10,11 +10,11 @@ using Terminal.Gui.Views;
[assembly: Terminal.Gui.Examples.ExampleMetadata ("Runnable Wrapper Example", "Shows how to wrap any View to make it runnable without implementing IRunnable")]
[assembly: Terminal.Gui.Examples.ExampleCategory ("API Patterns")]
[assembly: Terminal.Gui.Examples.ExampleCategory ("Views")]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "t", "e", "s", "t", "Esc"], Order = 1)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 2)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 3)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 4)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 5)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["t", "e", "s", "t", "Esc"], Order = 1)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 2)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 3)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 4)]
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 5)]
IApplication app = Application.Create (example: true);
app.Init ();

View File

@@ -16,6 +16,7 @@ public static partial class Application // Lifecycle (Init/Shutdown)
/// External observers can subscribe to this collection to monitor application lifecycle.
/// </summary>
public static ObservableCollection<IApplication> Apps { get; } = [];
/// <summary>
/// Gets the singleton <see cref="IApplication"/> instance used by the legacy static Application model.
/// </summary>
@@ -52,7 +53,7 @@ public static partial class Application // Lifecycle (Init/Shutdown)
//Debug.Fail ("Application.Create() called");
ApplicationImpl.MarkInstanceBasedModelUsed ();
ApplicationImpl app = new () { IsExample = example };
ApplicationImpl app = new ();
Apps.Add (app);
return app;

View File

@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Terminal.Gui.Examples;
namespace Terminal.Gui.App;
@@ -11,9 +13,6 @@ internal partial class ApplicationImpl
/// <inheritdoc/>
public bool Initialized { get; set; }
/// <inheritdoc/>
public bool IsExample { get; set; }
/// <inheritdoc/>
public event EventHandler<EventArgs<bool>>? InitializedChanged;
@@ -97,7 +96,7 @@ internal partial class ApplicationImpl
SubscribeDriverEvents ();
// Setup example mode if requested
if (IsExample)
if (Application.Apps.Contains (this))
{
SetupExampleMode ();
}
@@ -401,6 +400,10 @@ internal partial class ApplicationImpl
/// </summary>
private void SetupExampleMode ()
{
if (Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME) is null)
{
return;
}
// Subscribe to SessionBegun to monitor when runnables start
SessionBegun += OnSessionBegunForExample;
}
@@ -414,17 +417,17 @@ internal partial class ApplicationImpl
}
// Subscribe to IsModalChanged event on the TopRunnable
if (TopRunnable is { })
if (e.State.Runnable is Runnable { } runnable)
{
TopRunnable.IsModalChanged += OnIsModalChangedForExample;
e.State.Runnable.IsModalChanged += OnIsModalChangedForExample;
// Check if already modal - if so, send keys immediately
if (TopRunnable.IsModal)
{
_exampleModeDemoKeysSent = true;
TopRunnable.IsModalChanged -= OnIsModalChangedForExample;
SendDemoKeys ();
}
//// Check if already modal - if so, send keys immediately
//if (e.State.Runnable.IsModal)
//{
// _exampleModeDemoKeysSent = true;
// e.State.Runnable.IsModalChanged -= OnIsModalChangedForExample;
// SendDemoKeys ();
//}
}
// Unsubscribe from SessionBegun - we only need to set up the modal listener once
@@ -454,8 +457,10 @@ internal partial class ApplicationImpl
private void SendDemoKeys ()
{
// Get the entry assembly to read example metadata
var assembly = System.Reflection.Assembly.GetEntryAssembly ();
// Get the assembly of the currently running example
// Use TopRunnable's type assembly instead of entry assembly
// This works correctly when examples are loaded dynamically by ExampleRunner
Assembly? assembly = TopRunnable?.GetType ().Assembly;
if (assembly is null)
{
@@ -463,8 +468,8 @@ internal partial class ApplicationImpl
}
// Look for ExampleDemoKeyStrokesAttribute
var demoKeyAttributes = assembly.GetCustomAttributes (typeof (Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute), false)
.OfType<Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute> ()
List<ExampleDemoKeyStrokesAttribute> demoKeyAttributes = assembly.GetCustomAttributes (typeof (ExampleDemoKeyStrokesAttribute), false)
.OfType<ExampleDemoKeyStrokesAttribute> ()
.ToList ();
if (!demoKeyAttributes.Any ())
@@ -473,19 +478,22 @@ internal partial class ApplicationImpl
}
// Sort by Order and collect all keystrokes
var sortedSequences = demoKeyAttributes.OrderBy<Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute, int> (a => a.Order);
IOrderedEnumerable<ExampleDemoKeyStrokesAttribute> sortedSequences = demoKeyAttributes.OrderBy (a => a.Order);
// Send keys asynchronously to avoid blocking the UI thread
Task.Run (async () =>
{
// Default delay between keys is 100ms
int currentDelay = 100;
foreach (var attr in sortedSequences)
// Track cumulative timeout for scheduling
int cumulativeTimeout = 0;
foreach (ExampleDemoKeyStrokesAttribute attr in sortedSequences)
{
// Handle KeyStrokes array
if (attr.KeyStrokes is { Length: > 0 })
if (attr.KeyStrokes is not { Length: > 0 })
{
continue;
}
foreach (string keyStr in attr.KeyStrokes)
{
// Check for SetDelay command
@@ -502,39 +510,43 @@ internal partial class ApplicationImpl
}
// Regular key
if (Input.Key.TryParse (keyStr, out Input.Key? key) && key is { })
if (Key.TryParse (keyStr, out Key? key))
{
// Apply delay before sending key
if (currentDelay > 0)
{
await Task.Delay (currentDelay);
}
cumulativeTimeout += currentDelay;
Keyboard?.RaiseKeyDownEvent (key);
}
// Capture key by value to avoid closure issues
Key keyToSend = key;
AddTimeout (TimeSpan.FromMilliseconds (cumulativeTimeout), () =>
{
Keyboard.RaiseKeyDownEvent (keyToSend);
return false;
});
}
}
// Handle RepeatKey
if (!string.IsNullOrEmpty (attr.RepeatKey))
{
if (Input.Key.TryParse (attr.RepeatKey, out Input.Key? key) && key is { })
if (Key.TryParse (attr.RepeatKey, out Key? key))
{
for (var i = 0; i < attr.RepeatCount; i++)
{
// Apply delay before sending key
if (currentDelay > 0)
{
await Task.Delay (currentDelay);
}
cumulativeTimeout += currentDelay;
Keyboard?.RaiseKeyDownEvent (key);
}
}
}
}
// Capture key by value to avoid closure issues
Key keyToSend = key;
AddTimeout (TimeSpan.FromMilliseconds (cumulativeTimeout), () =>
{
Keyboard.RaiseKeyDownEvent (keyToSend);
return false;
});
}
}
}
}
}
#endregion Example Mode
}

View File

@@ -174,6 +174,7 @@ internal partial class ApplicationImpl
runnable.RaiseIsRunningChangedEvent (true);
runnable.RaiseIsModalChangedEvent (true);
//RaiseIteration ();
LayoutAndDraw ();
return token;

View File

@@ -86,12 +86,6 @@ public interface IApplication : IDisposable
/// <summary>Gets or sets whether the application has been initialized.</summary>
bool Initialized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this application is running in example mode.
/// When <see langword="true"/>, metadata is collected and demo keys are automatically sent.
/// </summary>
bool IsExample { get; set; }
/// <summary>
/// INTERNAL: Resets the state of this instance. Called by Dispose.
/// </summary>

View File

@@ -201,32 +201,47 @@ public class TimedEvents : ITimedEvents
private void RunTimersImpl ()
{
long now = GetTimestampTicks ();
SortedList<long, Timeout> copy;
// lock prevents new timeouts being added
// after we have taken the copy but before
// we have allocated a new list (which would
// result in lost timeouts or errors during enumeration)
// 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)
{
copy = _timeouts;
_timeouts = new ();
if (_timeouts.Count == 0)
{
break; // No more timeouts
}
foreach ((long k, Timeout timeout) in copy)
// Re-evaluate current time for each iteration
now = GetTimestampTicks ();
// Check if the earliest timeout is due
scheduledTime = _timeouts.Keys [0];
if (scheduledTime >= now)
{
if (k < now)
{
if (timeout.Callback! ())
{
AddTimeout (timeout.Span, timeout);
// 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);
}
else
// Execute the callback outside the lock
// This allows nested Run() calls to access the timeout queue
if (timeoutToExecute != null)
{
lock (_timeoutsLockToken)
bool repeat = timeoutToExecute.Callback! ();
if (repeat)
{
_timeouts.Add (NudgeToUniqueKey (k), timeout);
AddTimeout (timeoutToExecute.Span, timeoutToExecute);
}
}
}

View File

@@ -1,72 +0,0 @@
namespace Terminal.Gui.Examples;
/// <summary>
/// Handles automatic injection of test context into running examples.
/// This class monitors for the presence of an <see cref="ExampleContext"/> in the environment
/// and automatically injects keystrokes via <see cref="Application.Driver"/> after the application initializes.
/// </summary>
public static class ExampleContextInjector
{
private static bool _initialized;
/// <summary>
/// Sets up automatic key injection if a test context is present in the environment.
/// Call this method before calling <see cref="Application.Init"/> or <see cref="IApplication.Init"/>.
/// </summary>
/// <param name="app"></param>
/// <remarks>
/// This method is safe to call multiple times - it will only set up injection once.
/// The actual key injection happens after the application is initialized, via the
/// <see cref="Application.InitializedChanged"/> event.
/// </remarks>
public static void SetupAutomaticInjection (IApplication? app)
{
if (_initialized)
{
return;
}
_initialized = true;
// Check for test context in environment variable
string? contextJson = Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME);
if (string.IsNullOrEmpty (contextJson))
{
return;
}
ExampleContext? context = ExampleContext.FromJson (contextJson);
if (context is null || context.KeysToInject.Count == 0)
{
return;
}
// Subscribe to InitializedChanged to inject keys after initialization
app.SessionBegun += AppOnSessionBegun;
return;
void AppOnSessionBegun (object? sender, SessionTokenEventArgs e)
{
// Application has been initialized, inject the keys
if (app.Driver is null)
{
return;
}
foreach (string keyStr in context.KeysToInject)
{
if (Input.Key.TryParse (keyStr, out Input.Key? key) && key is { })
{
app.Keyboard.RaiseKeyDownEvent (key);
}
}
// Unsubscribe after injecting keys once
app.SessionBegun -= AppOnSessionBegun;
}
}
}

View File

@@ -170,12 +170,6 @@ public class Runnable : View, IRunnable
/// <inheritdoc/>
public void RaiseIsModalChangedEvent (bool newIsModal)
{
// CWP Phase 3: Post-notification (work already done by Application)
OnIsModalChanged (newIsModal);
EventArgs<bool> args = new (newIsModal);
IsModalChanged?.Invoke (this, args);
// Layout may need to change when modal state changes
SetNeedsLayout ();
SetNeedsDraw ();
@@ -194,6 +188,13 @@ public class Runnable : View, IRunnable
App?.Driver?.UpdateCursor ();
}
}
// CWP Phase 3: Post-notification (work already done by Application)
OnIsModalChanged (newIsModal);
EventArgs<bool> args = new (newIsModal);
IsModalChanged?.Invoke (this, args);
}
/// <inheritdoc/>

View File

@@ -131,13 +131,13 @@ public partial class View // Command APIs
// Best practice is to invoke the virtual method first.
// This allows derived classes to handle the event and potentially cancel it.
Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Calling OnAccepting...");
//Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Calling OnAccepting...");
args.Handled = OnAccepting (args) || args.Handled;
if (!args.Handled && Accepting is { })
{
// If the event is not canceled by the virtual method, raise the event to notify any external subscribers.
Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Raising Accepting...");
//Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Raising Accepting...");
Accepting?.Invoke (this, args);
}

View File

@@ -610,7 +610,7 @@ public static class MessageBox
e.Handled = true;
}
(s as View)?.App?.RequestStop ();
((s as View)?.SuperView as Dialog)?.RequestStop ();
};
}
@@ -657,7 +657,6 @@ public static class MessageBox
d.TextFormatter.WordWrap = wrapMessage;
d.TextFormatter.MultiLine = !wrapMessage;
// Run the modal; do not shut down the mainloop driver when done
app.Run (d);
d.Dispose ();

View File

@@ -11,42 +11,6 @@ public class ApplicationTests (ITestOutputHelper output)
{
private readonly ITestOutputHelper _output = output;
[Fact]
public void AddTimeout_Fires ()
{
IApplication app = Application.Create ();
app.Init ("fake");
uint timeoutTime = 100;
var timeoutFired = false;
// Setup a timeout that will fire
app.AddTimeout (
TimeSpan.FromMilliseconds (timeoutTime),
() =>
{
timeoutFired = true;
// Return false so the timer does not repeat
return false;
}
);
// The timeout has not fired yet
Assert.False (timeoutFired);
// Block the thread to prove the timeout does not fire on a background thread
Thread.Sleep ((int)timeoutTime * 2);
Assert.False (timeoutFired);
app.StopAfterFirstIteration = true;
app.Run<Runnable> ();
// The timeout should have fired
Assert.True (timeoutFired);
app.Dispose ();
}
[Fact]
public void Begin_Null_Runnable_Throws ()
@@ -281,10 +245,7 @@ public class ApplicationTests (ITestOutputHelper output)
void Application_Iteration (object? sender, EventArgs<IApplication?> e)
{
if (iteration > 0)
{
Assert.Fail ();
}
//Assert.Equal (0, iteration);
iteration++;
app.RequestStop ();

View File

@@ -0,0 +1,434 @@
#nullable enable
using Xunit.Abstractions;
namespace ApplicationTests.Timeout;
/// <summary>
/// Tests for timeout behavior with nested Application.Run() calls.
/// These tests verify that timeouts scheduled in a parent run loop continue to fire
/// correctly when a nested modal dialog is shown via Application.Run().
/// </summary>
public class NestedRunTimeoutTests (ITestOutputHelper output)
{
[Fact]
public void Timeout_Fires_With_Single_Session ()
{
// Arrange
using IApplication? app = Application.Create (example: false);
app.Init ("FakeDriver");
// Create a simple window for the main run loop
var mainWindow = new Window { Title = "Main Window" };
// Schedule a timeout that will ensure the app quits
var requestStopTimeoutFired = false;
app.AddTimeout (
TimeSpan.FromMilliseconds (100),
() =>
{
output.WriteLine ($"RequestStop Timeout fired!");
requestStopTimeoutFired = true;
app.RequestStop ();
return false;
}
);
// Act - Start the main run loop
app.Run (mainWindow);
// Assert
Assert.True (requestStopTimeoutFired, "RequestStop Timeout should have fired");
mainWindow.Dispose ();
}
[Fact]
public void Timeout_Fires_In_Nested_Run ()
{
// Arrange
using IApplication? app = Application.Create (example: false);
app.Init ("FakeDriver");
var timeoutFired = false;
var nestedRunStarted = false;
var nestedRunEnded = false;
// Create a simple window for the main run loop
var mainWindow = new Window { Title = "Main Window" };
// Create a dialog for the nested run loop
var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
// Schedule a safety timeout that will ensure the app quits if test hangs
var requestStopTimeoutFired = false;
app.AddTimeout (
TimeSpan.FromMilliseconds (5000),
() =>
{
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
requestStopTimeoutFired = true;
app.RequestStop ();
return false;
}
);
// Schedule a timeout that will fire AFTER the nested run starts and stop the dialog
app.AddTimeout (
TimeSpan.FromMilliseconds (200),
() =>
{
output.WriteLine ($"DialogRequestStop Timeout fired! TopRunnable: {app.TopRunnableView?.Title ?? "null"}");
timeoutFired = true;
// Close the dialog when timeout fires
if (app.TopRunnableView == dialog)
{
app.RequestStop (dialog);
}
return false;
}
);
// After 100ms, start the nested run loop
app.AddTimeout (
TimeSpan.FromMilliseconds (100),
() =>
{
output.WriteLine ("Starting nested run...");
nestedRunStarted = true;
// This blocks until the dialog is closed (by the timeout at 200ms)
app.Run (dialog);
output.WriteLine ("Nested run ended");
nestedRunEnded = true;
// Stop the main window after nested run completes
app.RequestStop ();
return false;
}
);
// Act - Start the main run loop
app.Run (mainWindow);
// Assert
Assert.True (nestedRunStarted, "Nested run should have started");
Assert.True (timeoutFired, "Timeout should have fired during nested run");
Assert.True (nestedRunEnded, "Nested run should have ended");
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
dialog.Dispose ();
mainWindow.Dispose ();
}
[Fact]
public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run ()
{
// Arrange
using IApplication? app = Application.Create (example: false);
app.Init ("FakeDriver");
var executionOrder = new List<string> ();
var mainWindow = new Window { Title = "Main Window" };
var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
// Schedule a safety timeout that will ensure the app quits if test hangs
var requestStopTimeoutFired = false;
app.AddTimeout (
TimeSpan.FromMilliseconds (10000),
() =>
{
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
requestStopTimeoutFired = true;
app.RequestStop ();
return false;
}
);
// Schedule multiple timeouts
app.AddTimeout (
TimeSpan.FromMilliseconds (100),
() =>
{
executionOrder.Add ("Timeout1-100ms");
output.WriteLine ("Timeout1 fired at 100ms");
return false;
}
);
app.AddTimeout (
TimeSpan.FromMilliseconds (200),
() =>
{
executionOrder.Add ("Timeout2-200ms-StartNestedRun");
output.WriteLine ("Timeout2 fired at 200ms - Starting nested run");
// Start nested run
app.Run (dialog);
executionOrder.Add ("Timeout2-NestedRunEnded");
output.WriteLine ("Nested run ended");
return false;
}
);
app.AddTimeout (
TimeSpan.FromMilliseconds (300),
() =>
{
executionOrder.Add ("Timeout3-300ms-InNestedRun");
output.WriteLine ($"Timeout3 fired at 300ms - TopRunnable: {app.TopRunnableView?.Title}");
// This should fire while dialog is running
Assert.Equal (dialog, app.TopRunnableView);
return false;
}
);
app.AddTimeout (
TimeSpan.FromMilliseconds (400),
() =>
{
executionOrder.Add ("Timeout4-400ms-CloseDialog");
output.WriteLine ("Timeout4 fired at 400ms - Closing dialog");
// Close the dialog
app.RequestStop (dialog);
return false;
}
);
app.AddTimeout (
TimeSpan.FromMilliseconds (500),
() =>
{
executionOrder.Add ("Timeout5-500ms-StopMain");
output.WriteLine ("Timeout5 fired at 500ms - Stopping main window");
// Stop main window
app.RequestStop (mainWindow);
return false;
}
);
// Act
app.Run (mainWindow);
// Assert - Verify all timeouts fired in the correct order
output.WriteLine ($"Execution order: {string.Join (", ", executionOrder)}");
Assert.Equal (6, executionOrder.Count); // 5 timeouts + 1 nested run end marker
Assert.Equal ("Timeout1-100ms", executionOrder [0]);
Assert.Equal ("Timeout2-200ms-StartNestedRun", executionOrder [1]);
Assert.Equal ("Timeout3-300ms-InNestedRun", executionOrder [2]);
Assert.Equal ("Timeout4-400ms-CloseDialog", executionOrder [3]);
Assert.Equal ("Timeout2-NestedRunEnded", executionOrder [4]);
Assert.Equal ("Timeout5-500ms-StopMain", executionOrder [5]);
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
dialog.Dispose ();
mainWindow.Dispose ();
}
[Fact]
public void Timeout_Scheduled_Before_Nested_Run_Fires_During_Nested_Run ()
{
// This test specifically reproduces the ESC key issue scenario:
// - Timeouts are scheduled upfront (like demo keys)
// - A timeout fires and triggers a nested run (like Enter opening MessageBox)
// - A subsequent timeout should still fire during the nested run (like ESC closing MessageBox)
// Arrange
using IApplication? app = Application.Create (example: false);
app.Init ("FakeDriver");
var enterFired = false;
var escFired = false;
var messageBoxShown = false;
var messageBoxClosed = false;
var mainWindow = new Window { Title = "Login Window" };
var messageBox = new Dialog { Title = "Success", Buttons = [new Button { Text = "Ok" }] };
// Schedule a safety timeout that will ensure the app quits if test hangs
var requestStopTimeoutFired = false;
app.AddTimeout (
TimeSpan.FromMilliseconds (10000),
() =>
{
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
requestStopTimeoutFired = true;
app.RequestStop ();
return false;
}
);
// Schedule "Enter" timeout at 100ms
app.AddTimeout (
TimeSpan.FromMilliseconds (100),
() =>
{
output.WriteLine ("Enter timeout fired - showing MessageBox");
enterFired = true;
// Simulate Enter key opening MessageBox
messageBoxShown = true;
app.Run (messageBox);
messageBoxClosed = true;
output.WriteLine ("MessageBox closed");
return false;
}
);
// Schedule "ESC" timeout at 200ms (should fire while MessageBox is running)
app.AddTimeout (
TimeSpan.FromMilliseconds (200),
() =>
{
output.WriteLine ($"ESC timeout fired - TopRunnable: {app.TopRunnableView?.Title}");
escFired = true;
// Simulate ESC key closing MessageBox
if (app.TopRunnableView == messageBox)
{
output.WriteLine ("Closing MessageBox with ESC");
app.RequestStop (messageBox);
}
return false;
}
);
// Stop main window after MessageBox closes
app.AddTimeout (
TimeSpan.FromMilliseconds (300),
() =>
{
output.WriteLine ("Stopping main window");
app.RequestStop (mainWindow);
return false;
}
);
// Act
app.Run (mainWindow);
// Assert
Assert.True (enterFired, "Enter timeout should have fired");
Assert.True (messageBoxShown, "MessageBox should have been shown");
Assert.True (escFired, "ESC timeout should have fired during MessageBox"); // THIS WAS THE BUG - NOW FIXED!
Assert.True (messageBoxClosed, "MessageBox should have been closed");
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
messageBox.Dispose ();
mainWindow.Dispose ();
}
[Fact]
public void Timeout_Queue_Persists_Across_Nested_Runs ()
{
// Verify that the timeout queue is not cleared when nested runs start/end
// Arrange
using IApplication? app = Application.Create (example: false);
app.Init ("FakeDriver");
// Schedule a safety timeout that will ensure the app quits if test hangs
var requestStopTimeoutFired = false;
app.AddTimeout (
TimeSpan.FromMilliseconds (10000),
() =>
{
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
requestStopTimeoutFired = true;
app.RequestStop ();
return false;
}
);
var mainWindow = new Window { Title = "Main Window" };
var dialog = new Dialog { Title = "Dialog", Buttons = [new Button { Text = "Ok" }] };
int initialTimeoutCount = 0;
int timeoutCountDuringNestedRun = 0;
int timeoutCountAfterNestedRun = 0;
// Schedule 5 timeouts at different times
for (int i = 0; i < 5; i++)
{
int capturedI = i;
app.AddTimeout (
TimeSpan.FromMilliseconds (100 * (i + 1)),
() =>
{
output.WriteLine ($"Timeout {capturedI} fired at {100 * (capturedI + 1)}ms");
if (capturedI == 0)
{
initialTimeoutCount = app.TimedEvents!.Timeouts.Count;
output.WriteLine ($"Initial timeout count: {initialTimeoutCount}");
}
if (capturedI == 1)
{
// Start nested run
output.WriteLine ("Starting nested run");
app.Run (dialog);
output.WriteLine ("Nested run ended");
timeoutCountAfterNestedRun = app.TimedEvents!.Timeouts.Count;
output.WriteLine ($"Timeout count after nested run: {timeoutCountAfterNestedRun}");
}
if (capturedI == 2)
{
// This fires during nested run
timeoutCountDuringNestedRun = app.TimedEvents!.Timeouts.Count;
output.WriteLine ($"Timeout count during nested run: {timeoutCountDuringNestedRun}");
// Close dialog
app.RequestStop (dialog);
}
if (capturedI == 4)
{
// Stop main window
app.RequestStop (mainWindow);
}
return false;
}
);
}
// Act
app.Run (mainWindow);
// Assert
output.WriteLine ($"Final counts - Initial: {initialTimeoutCount}, During: {timeoutCountDuringNestedRun}, After: {timeoutCountAfterNestedRun}");
// The timeout queue should have pending timeouts throughout
Assert.True (initialTimeoutCount >= 0, "Should have timeouts in queue initially");
Assert.True (timeoutCountDuringNestedRun >= 0, "Should have timeouts in queue during nested run");
Assert.True (timeoutCountAfterNestedRun >= 0, "Should have timeouts in queue after nested run");
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
dialog.Dispose ();
mainWindow.Dispose ();
}
}

View File

@@ -0,0 +1,51 @@
#nullable enable
using Xunit.Abstractions;
namespace ApplicationTests.Timeout;
/// <summary>
/// Tests for timeout behavior with nested Application.Run() calls.
/// These tests verify that timeouts scheduled in a parent run loop continue to fire
/// correctly when a nested modal dialog is shown via Application.Run().
/// </summary>
public class TimeoutTests (ITestOutputHelper output)
{
[Fact]
public void AddTimeout_Fires ()
{
IApplication app = Application.Create ();
app.Init ("fake");
uint timeoutTime = 100;
var timeoutFired = false;
// Setup a timeout that will fire
app.AddTimeout (
TimeSpan.FromMilliseconds (timeoutTime),
() =>
{
timeoutFired = true;
// Return false so the timer does not repeat
return false;
}
);
// The timeout has not fired yet
Assert.False (timeoutFired);
// Block the thread to prove the timeout does not fire on a background thread
Thread.Sleep ((int)timeoutTime * 2);
Assert.False (timeoutFired);
app.StopAfterFirstIteration = true;
app.Run<Runnable> ();
// The timeout should have fired
Assert.True (timeoutFired);
app.Dispose ();
}
}

View File

@@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using Terminal.Gui.Examples;
using Xunit.Abstractions;
namespace UnitTests.Parallelizable.Examples;
namespace ApplicationTests.Examples;
/// <summary>
/// Tests for the example discovery and execution infrastructure.

View File

@@ -0,0 +1,254 @@
# 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
```csharp
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()`:
```csharp
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
1. **All timeouts are removed from the queue** at the start and copied to a local variable
2. **Callbacks are executed sequentially** in the foreach loop
3. **When a callback blocks** (e.g., `app.Run(dialog)`), the entire `RunTimersImpl()` method is paused
4. **Future timeouts are stuck** in the local `copy` variable, inaccessible to the nested run loop
5. The nested dialog's `RunTimers()` calls see an **empty timeout queue**
6. 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:
1. **Example Demo Keys**: The `ExampleDemoKeyStrokesAttribute` feature doesn't work correctly when examples show MessageBox or dialogs. The ESC key to close dialogs is lost.
2. **Any automated testing** that uses timeouts to simulate user input with modal dialogs
3. **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:
```csharp
[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:
```csharp
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:
```csharp
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
1. **Lock ? Check ? Remove ? Unlock ? Execute** pattern
2. Only removes **one timeout at a time** that is currently due
3. Executes callbacks **outside the lock**
4. Future timeouts **remain in the queue**, accessible to nested `Run()` calls
5. **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):
```csharp
[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` - Fixed `RunTimersImpl()` method
- `Tests/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