Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
5 changes: 3 additions & 2 deletions skills/cui-dotnet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
209 changes: 209 additions & 0 deletions src/CuiSharp.Tests/HotReloadWakeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
using CuiSharp.Hosting;
using CuiSharp.Interop;

namespace CuiSharp.Tests;

/// <summary>
/// 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 <see cref="CuiApp.Run"/> window that no input ever reaches β€” and
/// the notification is delivered through <see cref="HotReloadWake.UpdateApplication"/>, the method
/// the runtime itself calls.
/// </summary>
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));

/// <summary>
/// Runs the check with <c>DOTNET_WATCH</c> forced either way. The variable is what
/// <c>dotnet watch</c> itself sets, and the answer is cached like the process would cache it,
/// so both the variable and the cache are put back afterwards.
/// </summary>
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();
}
}

/// <summary>
/// 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.
/// </summary>
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);

/// <summary>Waits for the loop to go quiet on its own, and answers the count it stopped at.</summary>
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);
}
}
}
10 changes: 10 additions & 0 deletions src/CuiSharp/CuiApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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()
Expand All @@ -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();
Expand Down
Loading
Loading