Skip to content

Commit b2e4700

Browse files
committed
feat: report resolved async mode + handler breadcrumbs (issue #35 follow-up)
Part A — SynchronizationContextInstalled: - New public read-only bool on ConsoleWindowSystem reflecting the resolved async model. Set in Run(), reset on shutdown. Lets user/library code query which mode is active instead of inferring from options. Part B — Lazy-formatted BlockedIn breadcrumbs (zero per-dispatch allocation): - Replace private volatile _currentCallback with separate volatile frame fields (window/control/op/label) + UiCallbackScope ref struct guard and watchdog-side FormatCurrentCallback(). - Instrument all three chokepoints: input (key/click/scroll/capture in WindowEventDispatcher), drain (per-action label), render (per-window in WindowRenderer.PaintDOM — window-level only to keep DOM paint allocation-free). - EnqueueOnUIThread/InvokeAsync gain optional label overloads that surface in BlockedIn (e.g. "Click on 'Editor' / ButtonControl", "UIAction: SaveTimer"). Docs: THREADING_AND_ASYNC.md covers the new property, the IsOnUIThread / InvokeRequired equivalence, label overloads, and richer BlockedIn content. Backward compatible: only one new public member; UiOp/UiCallbackScope are internal; existing overloads preserved. Tests added; full suite 3329 green.
1 parent 81715ce commit b2e4700

8 files changed

Lines changed: 478 additions & 25 deletions

File tree

SharpConsoleUI.Tests/Core/ConsoleWindowSystemWatchdogTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,102 @@ public void Unresponsive_Raised_WithDrainPhase_WhenLoopBlocks()
4141
Assert.True(ok, "Unresponsive event did not fire");
4242
Assert.Equal(MainLoopPhase.Drain, captured!.Phase);
4343
}
44+
45+
[Fact]
46+
public void Unresponsive_BlockedIn_NamesLabelledQueuedAction()
47+
{
48+
var opts = new ConsoleWindowSystemOptions() with
49+
{
50+
Watchdog = new WatchdogOptions(StaleThresholdMs: 50, UnresponsiveThresholdMs: 120, PollIntervalMs: 40, ShowUnresponsiveBanner: false)
51+
};
52+
var sys = new ConsoleWindowSystem(RenderMode.Buffer, options: opts);
53+
54+
UnresponsiveEventArgs? captured = null;
55+
sys.Unresponsive += (s, e) => captured = e;
56+
57+
var t = new Thread(() => { try { sys.Run(); } catch { } }) { IsBackground = true };
58+
t.Start();
59+
// Labelled queued action that blocks the Drain phase — label should surface in BlockedIn.
60+
sys.EnqueueOnUIThread(() => Thread.Sleep(400), label: "SaveTimer");
61+
62+
var ok = WaitFor(() => captured != null, timeoutMs: 3000);
63+
sys.Shutdown(0);
64+
t.Join(2000);
65+
66+
Assert.True(ok, "Unresponsive event did not fire");
67+
Assert.Equal(MainLoopPhase.Drain, captured!.Phase);
68+
Assert.Equal("UIAction: SaveTimer", captured.BlockedIn);
69+
}
70+
71+
// ---- FormatCurrentCallback formatting matrix (internal, exercised directly) ----
72+
73+
[Fact]
74+
public void FormatCurrentCallback_ReturnsNull_WhenNoFrameSet()
75+
{
76+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
77+
Assert.Null(sys.FormatCurrentCallback());
78+
}
79+
80+
[Fact]
81+
public void FormatCurrentCallback_FreeFormLabel_FormatsAsOpColonLabel()
82+
{
83+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
84+
sys.SetFrameLabel("SaveTimer");
85+
Assert.Equal("UIAction: SaveTimer", sys.FormatCurrentCallback());
86+
}
87+
88+
[Fact]
89+
public void FormatCurrentCallback_StructuredWindowAndControl_NamesBoth()
90+
{
91+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
92+
var window = new Window(sys) { Title = "Editor" };
93+
var control = new SharpConsoleUI.Controls.MarkupControl(new System.Collections.Generic.List<string> { "x" });
94+
sys.SetFrame(window, control, UiOp.Click);
95+
Assert.Equal("Click on 'Editor' / MarkupControl", sys.FormatCurrentCallback());
96+
}
97+
98+
[Fact]
99+
public void FormatCurrentCallback_WindowOnly_OmitsControlClause()
100+
{
101+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
102+
var window = new Window(sys) { Title = "Dashboard" };
103+
sys.SetFrame(window, null, UiOp.Render);
104+
Assert.Equal("Render on 'Dashboard'", sys.FormatCurrentCallback());
105+
}
106+
107+
// ---- UiCallbackScope restore semantics ----
108+
109+
[Fact]
110+
public void UiCallbackScope_RestoresPreviousFrame_OnDispose()
111+
{
112+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
113+
var window = new Window(sys) { Title = "Outer" };
114+
sys.SetFrame(window, null, UiOp.Key);
115+
116+
using (new UiCallbackScope(sys, "Inner"))
117+
{
118+
Assert.Equal("UIAction: Inner", sys.FormatCurrentCallback());
119+
}
120+
121+
// Outer frame restored after the scope exits.
122+
Assert.Equal("Key on 'Outer'", sys.FormatCurrentCallback());
123+
}
124+
125+
[Fact]
126+
public void UiCallbackScope_Nested_InnermostWins_OuterRestored()
127+
{
128+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
129+
130+
using (new UiCallbackScope(sys, "A"))
131+
{
132+
Assert.Equal("UIAction: A", sys.FormatCurrentCallback());
133+
using (new UiCallbackScope(sys, "B"))
134+
{
135+
Assert.Equal("UIAction: B", sys.FormatCurrentCallback());
136+
}
137+
Assert.Equal("UIAction: A", sys.FormatCurrentCallback());
138+
}
139+
140+
Assert.Null(sys.FormatCurrentCallback());
141+
}
44142
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// -----------------------------------------------------------------------
2+
// ConsoleEx - A simple console window system for .NET Core
3+
//
4+
// Author: Nikolaos Protopapas
5+
// Email: nikolaos.protopapas@gmail.com
6+
// License: MIT
7+
// -----------------------------------------------------------------------
8+
9+
using System;
10+
using System.Threading;
11+
using SharpConsoleUI;
12+
using SharpConsoleUI.Configuration;
13+
using SharpConsoleUI.Drivers;
14+
using Xunit;
15+
16+
namespace SharpConsoleUI.Tests.Core;
17+
18+
[Collection("TimingSensitive")]
19+
public class SynchronizationContextInstalledTests
20+
{
21+
private static bool WaitFor(Func<bool> condition, int timeoutMs = 3000)
22+
{
23+
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
24+
while (DateTime.UtcNow < deadline) { if (condition()) return true; Thread.Sleep(25); }
25+
return condition();
26+
}
27+
28+
[Fact]
29+
public void False_BeforeRun()
30+
{
31+
var sys = new ConsoleWindowSystem(RenderMode.Buffer);
32+
Assert.False(sys.SynchronizationContextInstalled);
33+
}
34+
35+
[Fact]
36+
public void True_DuringRun_WhenOptedIn_AndFalse_AfterShutdown()
37+
{
38+
var opts = new ConsoleWindowSystemOptions() with { InstallSynchronizationContext = true };
39+
var sys = new ConsoleWindowSystem(RenderMode.Buffer, options: opts);
40+
41+
var t = new Thread(() => { try { sys.Run(); } catch { } }) { IsBackground = true };
42+
t.Start();
43+
44+
var becameTrue = WaitFor(() => sys.SynchronizationContextInstalled, timeoutMs: 3000);
45+
sys.Shutdown(0);
46+
t.Join(2000);
47+
48+
Assert.True(becameTrue, "SynchronizationContextInstalled never became true during Run()");
49+
Assert.False(sys.SynchronizationContextInstalled, "Should reset to false after Run() returns");
50+
}
51+
52+
[Fact]
53+
public void False_DuringRun_WhenNotOptedIn()
54+
{
55+
var opts = new ConsoleWindowSystemOptions() with { InstallSynchronizationContext = false };
56+
var sys = new ConsoleWindowSystem(RenderMode.Buffer, options: opts);
57+
58+
bool? observedDuringRun = null;
59+
var t = new Thread(() => { try { sys.Run(); } catch { } }) { IsBackground = true };
60+
t.Start();
61+
62+
// Give the loop a moment to start, then observe the resolved state from a queued action.
63+
sys.EnqueueOnUIThread(() => observedDuringRun = sys.SynchronizationContextInstalled);
64+
WaitFor(() => observedDuringRun.HasValue, timeoutMs: 3000);
65+
66+
sys.Shutdown(0);
67+
t.Join(2000);
68+
69+
Assert.True(observedDuringRun.HasValue, "Queued observation never ran");
70+
Assert.False(observedDuringRun!.Value);
71+
}
72+
}

0 commit comments

Comments
 (0)