Skip to content

Draw hot-reloaded code the moment the delta lands - #21

Merged
albertwoo merged 1 commit into
mainfrom
fix/hot-reload-wake
Nov 12, 2026
Merged

albertwoo merged 1 commit into
mainfrom
fix/hot-reload-wake

Conversation

@albertwoo

Copy link
Copy Markdown
Contributor

The symptom

An idle CuiSharp window did not show a hot reload. Under dotnet watch, an edit applied and the
delta went live in the process — 🔥 C# and Razor changes applied in ~2s — but the window kept
presenting the frame it had drawn before the edit until the user clicked or moved the mouse over
it. It looked like hot reload was broken; only the redraw was missing.

Root cause

cui_app_frame sleeps in cui__plat_wait when nothing is animating (cui_needs_redraw false, no
WantsFrames, no work in flight), and on Windows that is
MsgWaitForMultipleObjectsEx(…, INFINITE, QS_KEY | QS_MOUSE | QS_POSTMESSAGE | QS_TIMER | …). A
delta rewrites method bodies in memory; nothing about it is a message, a timer or input, so a
sleeping loop never learns it should draw again.

The runtime does report the reload. A type named by [assembly: MetadataUpdateHandler] is called
ClearCache before the delta is applied and UpdateApplication once it is live — that is the
notification surface desktop .NET has, and the MetadataUpdater type itself only applies deltas
(ApplyUpdate, IsSupported) and raises no event.

The fix

src/CuiSharp/Hosting/HotReloadWake.cs registers for that notification and wakes the loop the way
the rest of the framework already does — a posted message:

  • ClearCache does nothing on purpose: an immediate-mode UI has nothing to invalidate, the next
    frame rebuilds the tree in call order from the app's own fields.
  • UpdateApplication runs on the thread that applied the delta, so it may touch no cui_ctx and
    must not block. It posts WM_NULL to every window the loop recovered, which is exactly what the
    backend's wait is listening for: the wait returns, the queue drains, the next frame draws the
    reloaded code.
  • CuiApp attaches the window on creation (Run and Create) and detaches it on teardown, so a
    late reload cannot post to a destroyed handle.

Nothing polls, so an idle window still draws nothing and costs no CPU. The previous 500 ms idle cap
survives only as a fallback for a backend that cannot be woken from another thread, armed by
DOTNET_WATCH there and nowhere else on Windows; PLAN.md records the upstream wake
API that would retire even that.

Evidence

Check Result
dotnet build -warnaserror, dotnet format --verify-no-changes clean
dotnet test 981 + 28 passed
Real dotnet watch, real edit, no input at all idle 3 s → 0 frames; edit → the frame carrying the reloaded text appeared ~2.08 s after the edit (watch reported 1.90 s applying it); idle 3 s after → 0 frames
AOT gallery publish 0 IL2xxx/IL3xxx warnings
--fps 900 0.83 ms mean, 0.95 ms p95, 0 B/frame — unchanged, inside the 0.90 ms budget
tools/regen.ps1 -NoNative -NoDiff, tools/api-surface.ps1 generated output fresh; public API unchanged
tools/verify-template.ps1 passes (pack, generate, build, test, AOT, dotnet cui, file-based app)

The new test drives a real CuiApp.Run window on its own thread, hidden, and asserts on its frame
counter with no input ever sent: it must not drift while idle, and must produce a frame after
UpdateApplication. Three separate mutations confirm it can fail — dropping Attach, making
UpdateApplication a no-op, and making the end-of-frame cap unconditional each failed it
independently.

What this does not prove

  • The non-Windows fallback is untested. There is no Linux/X11 session here. Only the decision
    (HeartbeatArmedFor(watch, windows)) is covered as a table; the cap itself was last exercised on
    Windows, which now ignores it.
  • Detach has no direct test. Asserting "no post to a dead handle" needs process-global state
    that the suite's other window tests share, which is how the first version of this change failed
    intermittently in the full suite — it posted to whichever window attached last. It now posts to
    every window the process owns.
  • An AOT gallery publish grew by 1 024 B (2 953 728 → 2 954 752, measured against a baseline
    rebuilt on the same tree). The cause is not attributed yet. The size profile already sets
    MetadataUpdaterSupport=false for that build, so the handler should be unreachable there; whether
    the delta is the MetadataUpdateHandler attribute's metadata or unrelated jitter needs a map-file
    diff to say, and I would rather leave it unexplained than guess in a commit message.

Files

File What
src/CuiSharp/Hosting/HotReloadWake.cs the handler, the window list, the fallback decision
src/CuiSharp/CuiApp.cs attach/detach the window, end-of-frame hook
src/CuiSharp/Interop/Win32.cs WmNull
src/CuiSharp.Tests/HotReloadWakeTests.cs idle-window wake, no-poll-on-Windows, plain-run sleep, decision table
docs/ARCHITECTURE.md Frame model entry: what wakes a sleeping window, and why not the cui_ctx
docs/PLAN.md the upstream wake API that retires the fallback
CHANGELOG.md, skills/cui-dotnet/SKILL.md the fix, and the human loop that stops needing a mouse nudge

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.
@albertwoo
albertwoo merged commit 9342284 into main Nov 12, 2026
24 checks passed
@albertwoo
albertwoo deleted the fix/hot-reload-wake branch November 12, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant