From 50f96c82392e2e9f3b9fef5c93b14cea6d38c2b6 Mon Sep 17 00:00:00 2001 From: BDisp Date: Wed, 8 Jul 2026 14:33:35 +0100 Subject: [PATCH 1/5] Fixes #5579. SynchronizationContext is not correctly implemented in v2 --- Terminal.Gui/App/ApplicationImpl.Lifecycle.cs | 5 +- Terminal.Gui/App/ApplicationImpl.Run.cs | 3 + .../App/MainLoop/MainLoopCoordinator.cs | 3 +- .../App/MainLoop/MainLoopSyncContext.cs | 117 ++++++++++++------ .../Application/ApplicationImplTests.cs | 116 +++++++++++++++++ 5 files changed, 200 insertions(+), 44 deletions(-) diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index 9ee447ad89..8d5a77faa0 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -17,6 +17,8 @@ internal partial class ApplicationImpl /// public event EventHandler>? InitializedChanged; + internal SynchronizationContext SynchronizationContext; + /// public IApplication Init (string? driverName = null) { @@ -79,7 +81,8 @@ public IApplication Init (string? driverName = null) RaiseInitializedChanged (this, new EventArgs (true)); SubscribeDriverEvents (); - SynchronizationContext.SetSynchronizationContext (new SynchronizationContext ()); + SynchronizationContext = new MainLoopSyncContext (this); + SynchronizationContext.SetSynchronizationContext (SynchronizationContext); _result = null; diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 3b4cbfbd1f..ae46f50c92 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -136,6 +136,9 @@ public void Invoke (Action action) // Set the application reference in the runnable runnable.SetApp (this); + // Set the synchronization context to MainLoopSyncContext for this application + SynchronizationContext.SetSynchronizationContext (SynchronizationContext); + // Ensure the mouse is ungrabbed Mouse.UngrabMouse (); diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index 42819ecbd9..9894e3f648 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -71,8 +71,7 @@ public async Task StartInputTaskAsync (IApplication? app) Task waitForSemaphore = _startupSemaphore.WaitAsync (); // Wait for either the semaphore to be released or the input task to crash. - // ReSharper disable once UseConfigureAwaitFalse - Task completedTask = await Task.WhenAny (waitForSemaphore, _inputTask); + Task completedTask = await Task.WhenAny (waitForSemaphore, _inputTask).ConfigureAwait (false); // Check if the task was the input task and if it has failed. if (completedTask == _inputTask) diff --git a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs index b9375ccca4..f0bfdc27b3 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs @@ -1,43 +1,78 @@ -#nullable disable namespace Terminal.Gui.App; -///// -///// provides the sync context set while executing code in Terminal.Gui, to let -///// users use async/await on their code -///// -//internal sealed class MainLoopSyncContext : SynchronizationContext -//{ -// public override SynchronizationContext CreateCopy () { return new MainLoopSyncContext (); } - -// public override void Post (SendOrPostCallback d, object state) -// { -// // Queue the task using the modern architecture -// ApplicationImpl.Instance.Invoke (() => { d (state); }); -// } - -// //_mainLoop.Driver.Wakeup (); -// public override void Send (SendOrPostCallback d, object state) -// { -// if (Thread.CurrentThread.ManagedThreadId == Application.MainThreadId) -// { -// d (state); -// } -// else -// { -// var wasExecuted = false; - -// ApplicationImpl.Instance.Invoke ( -// () => -// { -// d (state); -// wasExecuted = true; -// } -// ); - -// while (!wasExecuted) -// { -// Thread.Sleep (15); -// } -// } -// } -//} +/// +/// Provides the sync context set while executing code in Terminal.Gui, to let +/// users use async/await on their code +/// +internal sealed class MainLoopSyncContext : SynchronizationContext +{ + private readonly IApplication _app; + + /// + /// Initializes a new instance of the class. + /// + /// The application instance that owns the main loop. + public MainLoopSyncContext (IApplication app) => _app = app; + + /// + public override SynchronizationContext CreateCopy () => new MainLoopSyncContext (_app); + + /// + public override void Post (SendOrPostCallback d, object? state) + { + ArgumentNullException.ThrowIfNull (d); + + // Queue the task using the modern architecture + _app.Invoke (() => d (state)); + } + + /// + public override void Send (SendOrPostCallback d, object? state) + { + ArgumentNullException.ThrowIfNull (d); + + if (_app.MainThreadId == Thread.CurrentThread.ManagedThreadId) + { + d (state); + + return; + } + + object gate = new (); + bool wasExecuted = false; + Exception? error = null; + + _app.Invoke (() => + { + try + { + d (state); + } + catch (Exception ex) + { + error = ex; + } + finally + { + lock (gate) + { + wasExecuted = true; + Monitor.Pulse (gate); + } + } + }); + + lock (gate) + { + while (!wasExecuted) + { + Monitor.Wait (gate); + } + } + + if (error is { }) + { + throw error; + } + } +} diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs index 5febabda5f..8ca3e639c4 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs @@ -53,6 +53,122 @@ public void Dispose_Resets_SyncContext () Assert.Null (SynchronizationContext.Current); } + [Fact] + public void Init_Posts_To_The_App_Main_Thread () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + int mainThreadId = app.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; + int? callbackThreadId = null; + ManualResetEventSlim callbackCalled = new (false); + + SynchronizationContext.Current!.Post (_ => + { + callbackThreadId = Thread.CurrentThread.ManagedThreadId; + callbackCalled.Set (); + }, + null); + + app.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + app.RequestStop (); + + return false; + }); + + app.Run (new Window ()); + + Assert.True (callbackCalled.Wait (TimeSpan.FromMilliseconds (200), TestContext.Current.CancellationToken)); + Assert.Equal (mainThreadId, callbackThreadId); + + app.Dispose (); + } + + [Fact] + public void Init_Posts_To_The_App_Instance_Main_Thread () + { + IApplication app1 = Application.Create (); + app1.Init (DriverRegistry.Names.ANSI); + + int mainThreadId1 = app1.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; + int? callbackThreadId1 = null; + SynchronizationContext? synchronizationContext1 = null; + + ManualResetEventSlim callbackCalled = new (false); + + app1.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + if (synchronizationContext1 is null) + { + synchronizationContext1 = SynchronizationContext.Current; + synchronizationContext1?.Post (_ => + { + callbackThreadId1 = Thread.CurrentThread.ManagedThreadId; + callbackCalled.Set (); + }, + null); + + return true; + } + else + { + app1.RequestStop (); + + return false; + } + }); + + app1.Run (new Window ()); + + Assert.True (callbackCalled.Wait (TimeSpan.FromMilliseconds (200), TestContext.Current.CancellationToken)); + Assert.Equal (mainThreadId1, callbackThreadId1); + Assert.Equal (synchronizationContext1, SynchronizationContext.Current); + + IApplication app2 = Application.Create (); + app2.Init (DriverRegistry.Names.ANSI); + + int mainThreadId2 = app2.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; + int? callbackThreadId2 = null; + SynchronizationContext? synchronizationContext2 = null; + + app2.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + if (synchronizationContext2 is null) + { + synchronizationContext2 = SynchronizationContext.Current; + synchronizationContext2?.Post (_ => + { + callbackThreadId2 = Thread.CurrentThread.ManagedThreadId; + callbackCalled.Set (); + }, + null); + + return true; + } + else + { + app2.RequestStop (); + + return false; + } + }); + + app2.Run (new Window ()); + + Assert.True (callbackCalled.Wait (TimeSpan.FromMilliseconds (200), TestContext.Current.CancellationToken)); + Assert.Equal (mainThreadId2, callbackThreadId2); + Assert.NotEqual (synchronizationContext1, SynchronizationContext.Current); + Assert.Equal (synchronizationContext2, SynchronizationContext.Current); + Assert.NotEqual (synchronizationContext1, synchronizationContext2); + + app1.Dispose (); + app2.Dispose (); + } + [Fact] public void Dispose_Alone_Does_Nothing () { From d60a665879b0aad6f4a175f41220a89f09a1db8f Mon Sep 17 00:00:00 2001 From: BDisp Date: Wed, 8 Jul 2026 14:54:39 +0100 Subject: [PATCH 2/5] Enforces that apps can be crested before call Run and the Begin method guarantee set the sync context for the current running app. --- .../Application/ApplicationImplTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs index 8ca3e639c4..9c14fd2ea8 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs @@ -92,6 +92,9 @@ public void Init_Posts_To_The_App_Instance_Main_Thread () IApplication app1 = Application.Create (); app1.Init (DriverRegistry.Names.ANSI); + IApplication app2 = Application.Create (); + app2.Init (DriverRegistry.Names.ANSI); + int mainThreadId1 = app1.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; int? callbackThreadId1 = null; SynchronizationContext? synchronizationContext1 = null; @@ -127,9 +130,6 @@ public void Init_Posts_To_The_App_Instance_Main_Thread () Assert.Equal (mainThreadId1, callbackThreadId1); Assert.Equal (synchronizationContext1, SynchronizationContext.Current); - IApplication app2 = Application.Create (); - app2.Init (DriverRegistry.Names.ANSI); - int mainThreadId2 = app2.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; int? callbackThreadId2 = null; SynchronizationContext? synchronizationContext2 = null; From 007ba80281ddfc88edf75721069cc0bde34f0dab Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Jul 2026 07:57:21 +0100 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs index f0bfdc27b3..7fbe940851 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs @@ -72,7 +72,7 @@ public override void Send (SendOrPostCallback d, object? state) if (error is { }) { - throw error; + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (error).Throw (); } } } From 19aa5b048856e3b47890bdebd24ef8175742d6ec Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Jul 2026 13:43:48 +0100 Subject: [PATCH 4/5] Fix nullable warning --- Terminal.Gui/App/ApplicationImpl.Lifecycle.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index 8d5a77faa0..928cf29cee 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -14,11 +14,11 @@ internal partial class ApplicationImpl /// public bool Initialized { get; set; } + internal SynchronizationContext? SynchronizationContext { get; private set; } + /// public event EventHandler>? InitializedChanged; - internal SynchronizationContext SynchronizationContext; - /// public IApplication Init (string? driverName = null) { From 309c393e71dfbe82c0d2dbc03fd4d17013c3766e Mon Sep 17 00:00:00 2001 From: BDisp Date: Thu, 9 Jul 2026 15:17:30 +0100 Subject: [PATCH 5/5] Reset callbackCalled before reusing it --- .../UnitTestsParallelizable/Application/ApplicationImplTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs index 9c14fd2ea8..99f9274063 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs @@ -130,6 +130,8 @@ public void Init_Posts_To_The_App_Instance_Main_Thread () Assert.Equal (mainThreadId1, callbackThreadId1); Assert.Equal (synchronizationContext1, SynchronizationContext.Current); + callbackCalled.Reset (); + int mainThreadId2 = app2.MainThreadId ?? Thread.CurrentThread.ManagedThreadId; int? callbackThreadId2 = null; SynchronizationContext? synchronizationContext2 = null;