From 71473f329b8a59734766321c23a8f57ff1e0586d Mon Sep 17 00:00:00 2001 From: binwenw Date: Thu, 12 Nov 2026 15:12:51 +0800 Subject: [PATCH] Draw hot-reloaded code the moment the delta lands An idle CuiSharp window sleeps inside the OS message loop, so a delta applied by dotnet watch was live in the process and invisible: the window kept presenting the frame it drew before the edit until the user moved the mouse. The runtime does report the reload - a type named by [assembly: MetadataUpdateHandler] is called ClearCache before the delta and UpdateApplication once it is live - so Hosting/HotReloadWake registers for it and wakes the windows the loop already recovered with a posted WM_NULL, which the pinned backend's MsgWaitForMultipleObjectsEx is already waiting for. The callback runs on the delta applier's thread, so it touches no cui_ctx and never blocks. No polling: a window that is neither animating nor reloaded still draws nothing. A backend that cannot be woken from another thread keeps the cheap fallback of a 500 ms idle cap under dotnet watch, and docs/PLAN.md records the upstream wake API that would retire it. --- CHANGELOG.md | 13 ++ docs/ARCHITECTURE.md | 16 ++ docs/PLAN.md | 5 +- skills/cui-dotnet/SKILL.md | 5 +- src/CuiSharp.Tests/HotReloadWakeTests.cs | 209 +++++++++++++++++++++++ src/CuiSharp/CuiApp.cs | 10 ++ src/CuiSharp/Hosting/HotReloadWake.cs | 136 +++++++++++++++ src/CuiSharp/Interop/Win32.cs | 3 + 8 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 src/CuiSharp.Tests/HotReloadWakeTests.cs create mode 100644 src/CuiSharp/Hosting/HotReloadWake.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b41fb..aeadb8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ Every user-visible change, newest first. Pre-1.0 the minor number carries new su carries fixes. The section for a tag is what the GitHub release publishes, so the two cannot say different things. +## [Unreleased] + +### Fixed + +- **A hot reload reaches the screen without touching the window.** An idle CuiSharp window sleeps + inside the OS message loop, so an edit applied by `dotnet watch` was live in the process and + invisible: the window kept presenting the frame it drew before the edit until the user moved the + mouse. The library now registers itself with the runtime's `MetadataUpdateHandler` — `ClearCache` + before the delta, `UpdateApplication` once it is live — and wakes the window with a posted message + that the win32 backend's wait already listens for. No polling and no idle cost: a window that is + neither animating nor reloaded still draws nothing. A backend that cannot be woken from another + thread keeps a 500 ms idle cap while `dotnet watch` runs the app. + ## [0.4.0] — 2026-11-12 CUI 1.3.1 → 1.4.0. Four functions, four components, and one option array the generator had never diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 938e6bc..8a00b17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,6 +229,22 @@ loop starts. A process-wide queue — what this used to be — is applied by whi first, which can run another thread's callback against the wrong UI and make that frame collect its allocations. +Hot reload rides the same sleep, and the runtime does report it. A type named by +`[assembly: MetadataUpdateHandler]` is called `ClearCache` before a delta is applied and +`UpdateApplication` once it is live, and that is the notification surface desktop .NET has — +`MetadataUpdater` itself only applies deltas (`ApplyUpdate`, `IsSupported`) and raises nothing. The +callback arrives on the thread that applied the delta, so it may touch no `cui_ctx`; +`Hosting/HotReloadWake` posts `WM_NULL` to the window the loop already recovered instead, which is +enough because the win32 backend waits on `QS_POSTMESSAGE`: the wait returns, the queue drains, and +the next frame draws the reloaded code. Nothing polled to find out, and a window that is neither +animating nor reloaded still draws nothing. + +A backend that cannot be woken from another thread keeps the cheaper compromise instead: under +`dotnet watch`, which marks its children with `DOTNET_WATCH=1`, the window caps its own idle sleep at +500 ms so an edit lands within half a second. That is the only run that polls, and only on those +platforms — a plain `dotnet run`, a Release binary and a shipped app keep sleep-until-input. The +upstream wake API that would retire the fallback is in [PLAN.md](PLAN.md). + ## Native callbacks A `void (*)(…)` argument crosses into native code from managed code in exactly one way: diff --git a/docs/PLAN.md b/docs/PLAN.md index 5351509..ad8f465 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -47,5 +47,8 @@ in [ARCHITECTURE.md](ARCHITECTURE.md). Every item here passes the same gates as - **Cross-platform live-window control.** Windows live attach is opt-in and uses the pinned backend's between-frame message pump, thread/context-owned callback guards and destruction hooks. Other backends still need an upstream post-at-frame-boundary/wake and destroy-lifecycle - API; do not substitute a background render loop or silently start a headless app. + API; do not substitute a background render loop or silently start a headless app. The same wake + is what a hot reload needs off Windows: `Hosting/HotReloadWake` posts a message to the window on + the pinned backend and falls back to a 500 ms idle cap under `dotnet watch` everywhere else, so a + backend that can be woken from another thread retires that fallback. - **macOS signing and notarization** for a distributed `.app`. diff --git a/skills/cui-dotnet/SKILL.md b/skills/cui-dotnet/SKILL.md index 455165f..2331ebe 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -510,8 +510,9 @@ evidence behind. Do not build a workflow on it: a command costs a process, and a suite of them cannot be rerun by anyone else. 4. **Edit and look, repeatedly.** For a running window, `dotnet watch` in one terminal and edit in - the other; the app restarts or reloads and you look at it. That is a human loop — an agent should - prefer the test, because a test says pass or fail without anybody looking. + the other; the app restarts or reloads, and a reloaded frame is drawn on its own — you do not + have to click the window to make it appear. That is a human loop — an agent should prefer the + test, because a test says pass or fail without anybody looking. The rule underneath all four: **one reproduction per task, in a file.** If a check mattered once, it belongs in the test suite, not in a shell history. diff --git a/src/CuiSharp.Tests/HotReloadWakeTests.cs b/src/CuiSharp.Tests/HotReloadWakeTests.cs new file mode 100644 index 0000000..91c3a4d --- /dev/null +++ b/src/CuiSharp.Tests/HotReloadWakeTests.cs @@ -0,0 +1,209 @@ +using CuiSharp.Hosting; +using CuiSharp.Interop; + +namespace CuiSharp.Tests; + +/// +/// A window must draw the frame a hot reload invalidates without the user touching it, and must +/// still draw nothing when nothing reloaded. The wake is observed the way a user observes it — on +/// the frame counter of a real window that no input ever reaches — and +/// the notification is delivered through , the method +/// the runtime itself calls. +/// +public class HotReloadWakeTests +{ + private static bool CanOpenWindow => OperatingSystem.IsWindows(); + + [Fact] + public void AReloadNotificationDrawsAFrameWithoutAnyInput() + { + if (!CanOpenWindow) return; + + using var window = new IdleWindow(); + int idle = window.WaitUntilIdle(TimeSpan.FromSeconds(30)); + + // Nothing animates, nothing is clicked and nothing is reloaded: the loop must sleep. A + // window that kept drawing here would be polling, which is what the wake exists to avoid. + Thread.Sleep(TimeSpan.FromSeconds(2)); + int idleDrift = window.Frames - idle; + Assert.True(idleDrift <= 1, $"an idle window drew {idleDrift} frames in two seconds — it is polling, not sleeping"); + + long reloads = HotReloadWake.Reloads; + HotReloadWake.UpdateApplication(null); + + Assert.True(window.WaitForFrames(idle + 1, TimeSpan.FromSeconds(10)), + "the reload notification did not produce a frame: a reloaded UI would stay invisible until the user moved the mouse"); + Assert.True(HotReloadWake.Reloads > reloads, "the notification was not counted as a reload"); + } + + [Fact] + public void AWindowsWindowNeverPollsEvenUnderAWatchSession() + { + if (!CanOpenWindow) return; + + using var surface = CuiSurface.Create(160, 120); + surface.Draw(_ => { }, new byte[160 * 120 * 4]); + + WithWatchSession(watch: true, () => + { + Assert.True(HotReloadWake.WatchSession); + + HotReloadWake.ForFrame(surface.Handle); + + // Windows is woken by the notification, so it must not also poll: the frame loop's + // sleep stays unbounded and an idle developer window costs no CPU. + Assert.True(Native.NextWakeup(surface.Handle) < 0f, + "a watched window armed a wake-up it does not need on this platform"); + }); + } + + [Fact] + public void APlainRunSleepsUntilInput() + { + if (!CanOpenWindow) return; + + using var surface = CuiSurface.Create(160, 120); + surface.Draw(_ => { }, new byte[160 * 120 * 4]); + + WithWatchSession(watch: false, () => + { + Assert.False(HotReloadWake.WatchSession); + + HotReloadWake.ForFrame(surface.Handle); + + Assert.True(Native.NextWakeup(surface.Handle) < 0f, + "a plain window armed a wake-up: an idle app would pay for a feature it cannot use"); + }); + } + + [Theory] + [InlineData(true, false, true)] // watching, and the loop cannot be woken: poll + [InlineData(true, true, false)] // watching, and it can be: the reload wakes it + [InlineData(false, false, false)] // not watching: sleep until something real happens + [InlineData(false, true, false)] + public void TheHeartbeatOnlyArmsWhereTheLoopCannotBeWoken(bool watchSession, bool windows, bool expected) => + Assert.Equal(expected, HotReloadWake.HeartbeatArmedFor(watchSession, windows)); + + /// + /// Runs the check with DOTNET_WATCH forced either way. The variable is what + /// dotnet watch itself sets, and the answer is cached like the process would cache it, + /// so both the variable and the cache are put back afterwards. + /// + private static void WithWatchSession(bool watch, Action check) + { + var saved = Environment.GetEnvironmentVariable("DOTNET_WATCH"); + try + { + Environment.SetEnvironmentVariable("DOTNET_WATCH", watch ? "1" : null); + HotReloadWake.Reset(); + check(); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_WATCH", saved); + HotReloadWake.Reset(); + } + } + + /// + /// A real window on its own thread that draws a still page and counts its frames. It is + /// hidden and no test sends it input, so every frame after the first is either a wake or the + /// window polling on its own — the difference this file is about. + /// + private sealed class IdleWindow : IDisposable + { + private readonly Thread _thread; + private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private volatile Exception? _error; + private nint _hwnd; + private int _frames; + + internal IdleWindow() + { + _thread = new Thread(() => + { + try + { + CuiApp.Run( + new CuiAppOptions + { + Title = $"CuiSharp reload proof {Guid.NewGuid():N}"[..34], + Width = 420, + Height = 320, + Hidden = true, + WindowCreated = window => Volatile.Write(ref _hwnd, window.NativeWindow), + }, + ui => + { + ui.Heading("Reload proof", 2); + ui.Secondary("nothing animates here"); + Interlocked.Increment(ref _frames); + }); + } + catch (Exception error) + { + _error = error; + } + finally + { + _closed.TrySetResult(); + } + }) + { IsBackground = true }; + _thread.Start(); + } + + internal int Frames => Volatile.Read(ref _frames); + + /// Waits for the loop to go quiet on its own, and answers the count it stopped at. + internal int WaitUntilIdle(TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + int seen = Frames; + if (seen > 0) + { + Thread.Sleep(TimeSpan.FromMilliseconds(600)); + if (Frames == seen) return seen; + } + else + { + Thread.Sleep(TimeSpan.FromMilliseconds(50)); + } + + if (_closed.Task.IsCompleted) + throw new InvalidOperationException("The proof window closed before it settled.", _error); + } + + throw new TimeoutException($"The proof window never settled; it drew {Frames} frames."); + } + + internal bool WaitForFrames(int target, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (Frames >= target) return true; + if (_closed.Task.IsCompleted) return Frames >= target; + Thread.Sleep(TimeSpan.FromMilliseconds(20)); + } + + return Frames >= target; + } + + public void Dispose() + { + if (!_closed.Task.IsCompleted) + { + nint hwnd = Volatile.Read(ref _hwnd); + if (hwnd != 0) Win32.RequestClose(hwnd); + } + + _closed.Task.Wait(TimeSpan.FromSeconds(15)); + _thread.Join(TimeSpan.FromSeconds(5)); + if (_error is not null) + throw new InvalidOperationException("The proof window died before it closed.", _error); + } + } +} diff --git a/src/CuiSharp/CuiApp.cs b/src/CuiSharp/CuiApp.cs index 57e3323..9ce30fd 100644 --- a/src/CuiSharp/CuiApp.cs +++ b/src/CuiSharp/CuiApp.cs @@ -150,6 +150,7 @@ public Ui Ui app.Window.FileDialog = options.FileDialog; LiveResize.Install(handle == 0 ? 0 : app.NativeWindow, options.NoVsync); WindowIcon.Install(app.NativeWindow); + HotReloadWake.Attach(app.NativeWindow); options.WindowCreated?.Invoke(app.Window); return app; } @@ -306,6 +307,9 @@ internal static int Run(CuiAppOptions options, DrawFn draw, string[]? args) finally { if (Features.Live) state.Live?.Dispose(); + + // The window is gone now (cui_app_run destroyed it), so a reload must stop posting to it. + HotReloadWake.Detach(state.WindowHandle); LiveResize.Uninstall(state.WindowHandle); state.Window?.Release(); handle.Free(); @@ -341,6 +345,7 @@ internal void Hooking(nint ui) Window = new CuiWindow(app) { FileDialog = Options.FileDialog }; LiveResize.Install(WindowHandle, Options.NoVsync); WindowIcon.Install(WindowHandle); + HotReloadWake.Attach(WindowHandle); Options.WindowCreated?.Invoke(Window); if (Features.Live) Live?.Bind(app, ui, WindowHandle); } @@ -373,6 +378,10 @@ private static void RunThunk(nint ui, void* user) // reset on the next callback, so its copies are taken now, before that happens. if (Features.Live) state.Live?.SealFrame(); } + + // A frame just happened. A reload wakes the loop itself (HotReloadWake.UpdateApplication), + // so this only matters on a platform that cannot be woken and has to poll instead. + HotReloadWake.ForFrame(ui); } private static NativeAppDesc Describe(CuiAppOptions options) => new() @@ -396,6 +405,7 @@ private static void RunThunk(nint ui, void* user) public void Dispose() { if (_handle == 0) return; + HotReloadWake.Detach(NativeWindow); Native.AppDestroy(_handle); _handle = 0; _frame.Reset(); diff --git a/src/CuiSharp/Hosting/HotReloadWake.cs b/src/CuiSharp/Hosting/HotReloadWake.cs new file mode 100644 index 0000000..d6661b8 --- /dev/null +++ b/src/CuiSharp/Hosting/HotReloadWake.cs @@ -0,0 +1,136 @@ +using System.Reflection.Metadata; +using CuiSharp.Interop; + +// The runtime finds this handler by name, on the thread that applied the delta. Trimming is not a +// worry for it: a delta can only be applied to a JIT build, and the one trimmed build this library +// shapes — a self-contained AOT publish — has MetadataUpdaterSupport off, so the handler is +// unreachable there by construction. +[assembly: MetadataUpdateHandler(typeof(CuiSharp.Hosting.HotReloadWake))] + +namespace CuiSharp.Hosting; + +/// +/// Draws the frame a hot reload invalidates, so an edit reaches the screen without the user +/// touching the window. +/// +/// +/// A CUI window waits inside the OS message loop when nothing is animating, and a delta applied by +/// dotnet watch only rewrites method bodies: nothing about the sleeping process says "draw +/// again", so the reloaded UI stays invisible until the user moves the mouse. The runtime does +/// report the reload — a type named by [assembly: MetadataUpdateHandler] gets +/// before the delta and once it is live. +/// +/// runs on the thread that applied the delta, so it may run +/// concurrently with a frame and must not touch a cui_ctx. It wakes each window the process +/// owns the way the rest of this framework does, by posting a message: the win32 backend waits in +/// MsgWaitForMultipleObjectsEx on QS_POSTMESSAGE, so the wait returns, the queue +/// drains and the next frame draws the reloaded code. No polling, and an idle window still costs +/// nothing. +/// +/// +/// A backend that cannot be woken from another thread gets the fallback instead — see +/// , and docs/PLAN.md for the upstream wake API that would +/// retire it. +/// +/// +internal static class HotReloadWake +{ + /// Longest a window without a wake mechanism sleeps between frames, in seconds. + internal const float HeartbeatSeconds = 0.5f; + + /// Guards : the delta thread writes nothing else here. + private static readonly object Gate = new(); + + /// + /// Every window this process owns. An app has one (docs/PLAN.md: multi-window is + /// upstream's question), but a test process opens several at once, and a reload has to reach + /// all of them rather than whichever attached last. + /// + private static readonly List s_windows = []; + + private static long s_reloads; + private static bool? s_watchSession; + + /// How many times a reload has been applied to this process. + internal static long Reloads => Interlocked.Read(ref s_reloads); + + /// Whether this process was started by dotnet watch, which marks its children. + internal static bool WatchSession => + s_watchSession ??= Environment.GetEnvironmentVariable("DOTNET_WATCH") == "1"; + + /// + /// Whether the frame loop has to poll to notice a reload, because this platform offers no way + /// to wake it: only under dotnet watch, and only where the wake is missing. + /// + internal static bool HeartbeatArmed => HeartbeatArmedFor(WatchSession, OperatingSystem.IsWindows()); + + /// + /// The decision behind , written so the four answers it can give + /// are checkable on any machine rather than only on the platform that needs the fallback. + /// + internal static bool HeartbeatArmedFor(bool watchSession, bool windows) => watchSession && !windows; + + /// + /// The runtime calls this before the delta is applied. An immediate-mode UI has nothing to + /// invalidate: the next frame rebuilds the whole tree in call order, from the app's own fields. + /// + public static void ClearCache(Type[]? updatedTypes) + { + } + + /// + /// The runtime calls this once the delta is live: the next frame draws the reloaded code. + /// + public static void UpdateApplication(Type[]? updatedTypes) => Notify(); + + /// + /// Records the reload and asks every window for a frame. Called from the delta applier's thread, + /// so it only posts messages and never blocks; returns whether a wake was actually posted, + /// which is false when no window is attached or none of them is left to post to. + /// + internal static bool Notify() + { + Interlocked.Increment(ref s_reloads); + + // Posting under the lock keeps a reload landing at the same moment as a detach from + // waking a handle the loop no longer owns. The message is WM_NULL: the smallest thing + // that sets QS_POSTMESSAGE, dispatched to DefWindowProc and dropped without effect. + lock (Gate) + { + bool posted = false; + foreach (nint window in s_windows) + posted |= Win32.PostMessage(window, Win32.WmNull, 0, 0) != 0; + + return posted; + } + } + + /// Remembers a window to wake. Called on the UI thread once the window exists. + internal static void Attach(nint hwnd) + { + if (hwnd == 0 || !OperatingSystem.IsWindows()) return; // nowhere to post, so nothing to remember + lock (Gate) + { + if (!s_windows.Contains(hwnd)) s_windows.Add(hwnd); + } + } + + /// Forgets a window that is going away, so a late reload cannot post to it. + internal static void Detach(nint hwnd) + { + lock (Gate) s_windows.Remove(hwnd); + } + + /// + /// End-of-frame housekeeping for the window loop: on a platform that cannot be woken, promise + /// the loop a next frame while a developer is watching, so a reloaded body still gets drawn. + /// Asked from the UI thread only — the same thread that owns the cui_ctx. + /// + internal static void ForFrame(nint ui) + { + if (HeartbeatArmed) Native.RequestWakeup(ui, HeartbeatSeconds); + } + + /// Re-reads the environment, the seam the tests simulate a watch session through. + internal static void Reset() => s_watchSession = null; +} diff --git a/src/CuiSharp/Interop/Win32.cs b/src/CuiSharp/Interop/Win32.cs index dc8843f..52808d9 100644 --- a/src/CuiSharp/Interop/Win32.cs +++ b/src/CuiSharp/Interop/Win32.cs @@ -11,6 +11,9 @@ internal static unsafe partial class Win32 internal const int SwMinimize = 6; internal const int SwRestore = 9; + /// Posts nothing and does nothing: the wake a waiting loop needs, with no effect of its own. + internal const uint WmNull = 0x0000; + internal const uint WmDestroy = 0x0002; internal const uint WmSize = 0x0005; internal const uint WmTimer = 0x0113;