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;