From a1ffe8676d90e08aa7155119f0f198ce4ffe05e7 Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 19:13:39 -0700 Subject: [PATCH 1/6] perf(windows): drive gpu frames off a high-resolution timer queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows synthesizes WM_TIMER only when the message queue is empty. The one-shot emit timer therefore starves under exactly the load that needs frames most, and #313's post-dispatch drain cannot reach it during a user-driven move/size drag: DefWindowProc runs its own modal message pump there, so the run loop — and both of the wake paths it owns, the waitable timer and the drain — stay parked for the whole drag. Schedule each deadline on a timer queue whose callback only posts kGpuEmitMessage. A posted message is an ordinary queued message that every pump delivers, including the modal one, and no queue-empty rule gates it. The callback does nothing else, so all app and runtime work stays on the UI thread, and a generation stamp lets a re-arm discard the message a superseded deadline already posted. Alongside that, the pieces the new cadence needs to be real: - Hold 1 ms system timer resolution for the loop's lifetime. Every pacing primitive here quantizes to the system timer, and the default ~15.6 ms granularity caps a 240 Hz grid near 64 Hz. Needs winmm. - Derive the frame interval from the monitor carrying the surface instead of a hardcoded 16.67 ms, memoized against its HMONITOR. - Coalesce pointer motion (latest-wins) and wheel deltas (accumulated) to one flush per frame, so an input storm cannot outrun the grid. - Set WS_CLIPCHILDREN on top-level windows and gpu-surface containers. Without it a parent repaint paints COLOR_WINDOW straight over child HWNDs, which reads as white strobing over a canvas mid-drag. - Coalesce WM_MOVE across the modal loop. A pure move changes no client size, but each one drove a full shell relayout AND a synchronous window-state file rewrite, hundreds of times a second during a drag. The settled frame emits once on WM_EXITSIZEMOVE. Measured on a 165 Hz Windows desktop, retained path, dragging a canvas window: 1.82 ms/frame at 1037x775 and 2.15 ms/frame at 3053x1175 — 4.5x the pixels for 18% more cost, worst single frame 2.7 ms. This overlaps #313 deliberately rather than replacing it. That change fixed the same starvation for the ordinary loop, where draining after each dispatch is sufficient; it cannot fix the modal loop, which never returns to the loop that drains. The drain still runs and still earns its keep — the two wakes are complementary. Co-Authored-By: Claude Opus 5 (1M context) --- build/app.zig | 4 + src/platform/windows/webview2_host.cpp | 367 +++++++++++++++++++++---- 2 files changed, 325 insertions(+), 46 deletions(-) diff --git a/build/app.zig b/build/app.zig index 03678f94c..f554cb3a6 100644 --- a/build/app.zig +++ b/build/app.zig @@ -2383,6 +2383,10 @@ fn linkPlatform(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Res app_mod.linkSystemLibrary("dwrite", .{}); app_mod.linkSystemLibrary("imm32", .{}); app_mod.linkSystemLibrary("comctl32", .{}); + // timeBeginPeriod: the host holds 1 ms system timer resolution for + // the message loop's lifetime, so the frame grid is not quantized + // to the default ~15.6 ms tick (which would cap it near 64 Hz). + app_mod.linkSystemLibrary("winmm", .{}); app_mod.linkSystemLibrary("ole32", .{}); app_mod.linkSystemLibrary("oleacc", .{}); app_mod.linkSystemLibrary("shell32", .{}); diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp index d81182377..7fa8f095e 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -176,6 +178,9 @@ constexpr UINT kAudioSpectrumMessage = WM_APP + 46; * menu on a fresh loop turn — the same deferral the macOS host's * dispatch_async performs before popUpMenuPositioningItem. */ constexpr UINT kShowContextMenuMessage = WM_APP + 47; +/* Timer-queue callbacks run off-loop; this message returns the high-resolution + * frame deadline to the owning child HWND's UI thread. */ +constexpr UINT kGpuEmitMessage = WM_APP + 48; constexpr const char *kAssetVirtualOrigin = "https://native-sdk-app.localhost"; constexpr int kViewWebView = 0; @@ -367,6 +372,12 @@ struct Window { * created. The per-window frame timer owns the one-second safety * reveal; immediate/already shown windows do not consult it. */ ULONGLONG deferred_show_started_ms = 0; + /* Inside the system modal move/size loop (WM_ENTERSIZEMOVE until + * WM_EXITSIZEMOVE), and whether a WM_MOVE was swallowed while there. + * Programmatic moves (SetWindowPos, snap gestures, Win+arrow) never + * enter the loop, so they keep emitting immediately. */ + bool in_modal_move_loop = false; + bool deferred_frame_event = false; /* Last DWMWA_CAPTION_COLOR pushed for the hidden styles (sampled * from the presented header pixels so the DWM caption material * behind the button cluster matches the app's header). */ @@ -470,6 +481,30 @@ struct NativeView { * frames are demand-driven, so an idle surface emits ZERO frame events * (the idle law the macOS host enforces). */ bool gpu_emission_scheduled = false; + /* One-shot timer-queue handle. Unlike SetTimer, timer queues can honor the + * 4.17 ms deadline of a 240 Hz display; the callback only posts + * kGpuEmitMessage, so all app/runtime work remains on the UI thread. */ + HANDLE gpu_emit_timer = nullptr; + PVOID gpu_emit_context = nullptr; + uint64_t gpu_emit_generation = 0; + HMONITOR gpu_frame_monitor = nullptr; + uint64_t gpu_frame_interval_ns = 16666667ull; + /* Pointer motion is latest-wins, wheel deltas accumulate. Both flush at + * most once per display frame, matching the AppKit surface policy. */ + bool gpu_pointer_motion_pending = false; + int gpu_pointer_motion_kind = 0; + double gpu_pointer_motion_x = 0; + double gpu_pointer_motion_y = 0; + int gpu_pointer_motion_button = 0; + uint32_t gpu_pointer_motion_modifiers = 0; + uint64_t gpu_pointer_motion_timestamp_ns = 0; + bool gpu_scroll_pending = false; + double gpu_scroll_x = 0; + double gpu_scroll_y = 0; + double gpu_scroll_delta_x = 0; + double gpu_scroll_delta_y = 0; + uint32_t gpu_scroll_modifiers = 0; + uint64_t gpu_scroll_timestamp_ns = 0; bool gpu_presented = false; /* One-shot: the next scheduled emission must fire at grid * promptness even while minimized. Two producers set it — an input @@ -574,6 +609,11 @@ constexpr UINT_PTR kAppTimerIdBase = 0x1000; /* The 16 ms per-window frame-pump timer (SetTimer id on each top-level * window; distinct from the app-timer id range). */ constexpr UINT_PTR kFrameTimerId = 1; +/* Placeholder pump timer on each gpu-surface child (repeating, retired + * by the first present or a hide; re-armed on reveal). Declared beside + * its window-level sibling because the view-state show path, which + * precedes the gpu section, arms it too. */ +constexpr UINT_PTR kGpuFrameTimerId = 1; constexpr ULONGLONG kDeferredShowDeadlineMs = 1000; struct AppTimer { @@ -1939,12 +1979,33 @@ static void applyNativeViewFrame(Host *host, NativeView &view) { if (!view.hwnd) return; const double scale = nativeViewFrameScale(host, view); RECT frame = nativeViewPhysicalFrame(host, view, scale); + /* An unchanged frame must not repaint. MoveWindow(..., TRUE) + * invalidates the child even when nothing moved, and the shell + * relayout that answers every window-frame event re-applies EVERY + * view's frame — so without this an event that moved nothing still + * flushed the whole window's pixels. */ + RECT current = {}; + if (GetWindowRect(view.hwnd, ¤t)) { + POINT origin = { current.left, current.top }; + if (HWND parent = GetParent(view.hwnd)) ScreenToClient(parent, &origin); + if (origin.x == frame.left && origin.y == frame.top && + current.right - current.left == frame.right - frame.left && + current.bottom - current.top == frame.bottom - frame.top) return; + } MoveWindow(view.hwnd, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, TRUE); } static void applyNativeViewState(NativeView &view, bool update_text, const std::string &text) { if (!view.hwnd) return; ShowWindow(view.hwnd, view.visible ? SW_SHOW : SW_HIDE); + /* The placeholder frame pump retires while a never-presented view is + * hidden (see the kGpuFrameTimerId handler); a reveal must re-arm it or + * a first-shown surface never establishes its frame channel. SetTimer + * with the same id resets the running timer, so a repeated show is + * harmless. */ + if (view.kind == kViewGpuSurface && view.visible && !view.gpu_presented) { + (void)SetTimer(view.hwnd, kGpuFrameTimerId, 16, nullptr); + } EnableWindow(view.hwnd, view.enabled ? TRUE : FALSE); if (update_text) applyNativeViewText(view, text); applyNativeViewAccessibility(view); @@ -2011,6 +2072,8 @@ static void applyNativeChildFrames(Host *host, uint64_t window_id, const std::st } } +static void cancelGpuSurfaceFrameEmission(NativeView &view); + static void destroyNativeViewAndChildren(Host *host, const std::string &key) { if (!host) return; auto found = host->native_views.find(key); @@ -2022,6 +2085,7 @@ static void destroyNativeViewAndChildren(Host *host, const std::string &key) { if (entry.second.window_id == window_id && entry.second.parent == label) children.push_back(entry.first); } for (const std::string &child : children) destroyNativeViewAndChildren(host, child); + if (found->second.kind == kViewGpuSurface) cancelGpuSurfaceFrameEmission(found->second); if (found->second.hwnd) DestroyWindow(found->second.hwnd); host->native_views.erase(found); gpuSurfaceRefreshFrameWakeTimer(host); @@ -2087,7 +2151,7 @@ constexpr int kGpuInputImeSetComposition = 8; constexpr int kGpuInputImeCommitComposition = 9; constexpr int kGpuInputImeCancelComposition = 10; constexpr int kGpuInputPointerCancel = 11; -constexpr uint64_t kGpuFrameIntervalNs = 16666667ull; +constexpr uint64_t kGpuDefaultFrameIntervalNs = 16666667ull; /* Pacing interval for logical frame completions while the top-level * window is MINIMIZED: a ~1 Hz heartbeat instead of the frame grid. A * minimized window's presents reach nothing (WM_PAINT never arrives for @@ -2108,11 +2172,10 @@ constexpr uint64_t kGpuFrameIntervalNs = 16666667ull; * presentation path, so covered-but-not-minimized windows keep full * cadence deliberately rather than guess. */ constexpr uint64_t kGpuOccludedHeartbeatNs = 1000000000ull; -/* Placeholder pump timer (repeating, retired by the first present). */ -constexpr UINT_PTR kGpuFrameTimerId = 1; -/* The one-shot scheduled-emission timer (the single frame-event gate). */ -constexpr UINT_PTR kGpuEmitTimerId = 2; - +/* GPU emission deadlines use the high-resolution timer queue below. + * (kGpuFrameTimerId, the placeholder pump, is declared beside + * kFrameTimerId near the top — the view-state show path arms it before + * this section.) */ static uint64_t gpuTimestampNs() { static LARGE_INTEGER frequency = {}; if (frequency.QuadPart == 0) QueryPerformanceFrequency(&frequency); @@ -2128,6 +2191,36 @@ static uint64_t gpuTimestampNs() { * resolution (dpiForWindow) over the 96-dpi baseline. In a DPI-unaware * process the resolved DPI is 96, so logical size == client pixels, * matching how the rest of this host treats coordinates. */ +/* Refresh interval for the monitor currently carrying the surface. + * EnumDisplaySettings reports the desktop mode's nominal rate; clamp only + * obviously invalid driver sentinels, not high-refresh panels. */ +constexpr uint64_t gpuFrameIntervalForHz(uint32_t refresh_hz) { + return refresh_hz > 0 ? 1000000000ull / static_cast(refresh_hz) : kGpuDefaultFrameIntervalNs; +} +static_assert(gpuFrameIntervalForHz(240) == 4166666ull, "240 Hz surfaces require a 4.17 ms frame grid"); + +static uint64_t gpuSurfaceFrameIntervalNs(NativeView &view) { + HMONITOR monitor = view.hwnd ? MonitorFromWindow(view.hwnd, MONITOR_DEFAULTTONEAREST) : nullptr; + if (monitor && monitor == view.gpu_frame_monitor && view.gpu_frame_interval_ns > 0) { + return view.gpu_frame_interval_ns; + } + uint64_t interval = kGpuDefaultFrameIntervalNs; + if (monitor) { + MONITORINFOEXW info = {}; + info.cbSize = sizeof(info); + DEVMODEW mode = {}; + mode.dmSize = sizeof(mode); + if (GetMonitorInfoW(monitor, &info) && + EnumDisplaySettingsExW(info.szDevice, ENUM_CURRENT_SETTINGS, &mode, 0) && + mode.dmDisplayFrequency >= 30 && mode.dmDisplayFrequency <= 1000) { + interval = gpuFrameIntervalForHz(mode.dmDisplayFrequency); + } + } + view.gpu_frame_monitor = monitor; + view.gpu_frame_interval_ns = interval; + return interval; +} + static double gpuSurfaceScale(HWND hwnd) { return hwnd ? (double)dpiForWindow(hwnd) / 96.0 : 1.0; } @@ -2608,12 +2701,12 @@ static void emitGpuSurfaceEvent(Host *host, const NativeView &view, WindowsEvent host->callback(host->callback_context, &event); } -static void emitGpuSurfaceInput(Host *host, NativeView &view, int input_kind, double x, double y, int button, double delta_x, double delta_y, const char *key, const char *text, uint32_t modifiers) { +static void emitGpuSurfaceInput(Host *host, NativeView &view, int input_kind, double x, double y, int button, double delta_x, double delta_y, const char *key, const char *text, uint32_t modifiers, uint64_t timestamp_ns = 0) { WindowsEvent event = {}; event.kind = kGpuSurfaceInput; event.x = x; event.y = y; - event.timestamp_ns = gpuTimestampNs(); + event.timestamp_ns = timestamp_ns > 0 ? timestamp_ns : gpuTimestampNs(); event.input_kind = input_kind; event.button = button; event.delta_x = delta_x; @@ -2629,6 +2722,68 @@ static void emitGpuSurfaceInput(Host *host, NativeView &view, int input_kind, do /* Text/composition emit variant: no pointer payload, optional byte cursor * into the UTF-8 text (mirrors native_sdk_emit_gpu_surface_text_input in * the GTK host and emitTextInputEventWithKind in the AppKit host). */ +static void gpuSurfaceScheduleFrameEmission(Host *host, NativeView &view); + +static void queueGpuSurfacePointerMotionInput(Host *host, NativeView &view, int input_kind, double x, double y, int button, uint32_t modifiers) { + view.gpu_pointer_motion_pending = true; + view.gpu_pointer_motion_kind = input_kind; + view.gpu_pointer_motion_x = x; + view.gpu_pointer_motion_y = y; + view.gpu_pointer_motion_button = button; + view.gpu_pointer_motion_modifiers = modifiers; + view.gpu_pointer_motion_timestamp_ns = gpuTimestampNs(); + view.gpu_prompt_frame_pending = true; + gpuSurfaceScheduleFrameEmission(host, view); +} + +static void emitQueuedGpuSurfacePointerMotionInput(Host *host, NativeView &view) { + if (!view.gpu_pointer_motion_pending) return; + const int kind = view.gpu_pointer_motion_kind; + const double x = view.gpu_pointer_motion_x; + const double y = view.gpu_pointer_motion_y; + const int button = view.gpu_pointer_motion_button; + const uint32_t modifiers = view.gpu_pointer_motion_modifiers; + const uint64_t timestamp = view.gpu_pointer_motion_timestamp_ns; + view.gpu_pointer_motion_pending = false; + view.gpu_pointer_motion_timestamp_ns = 0; + emitGpuSurfaceInput(host, view, kind, x, y, button, 0, 0, "", "", modifiers, timestamp); +} + +static void queueGpuSurfaceScrollInput(Host *host, NativeView &view, double x, double y, double delta_x, double delta_y, uint32_t modifiers) { + if (delta_x == 0 && delta_y == 0) return; + view.gpu_scroll_pending = true; + view.gpu_scroll_x = x; + view.gpu_scroll_y = y; + view.gpu_scroll_delta_x += delta_x; + view.gpu_scroll_delta_y += delta_y; + view.gpu_scroll_modifiers = modifiers; + view.gpu_scroll_timestamp_ns = gpuTimestampNs(); + view.gpu_prompt_frame_pending = true; + gpuSurfaceScheduleFrameEmission(host, view); +} + +static void emitQueuedGpuSurfaceScrollInput(Host *host, NativeView &view) { + if (!view.gpu_scroll_pending) return; + const double x = view.gpu_scroll_x; + const double y = view.gpu_scroll_y; + const double delta_x = view.gpu_scroll_delta_x; + const double delta_y = view.gpu_scroll_delta_y; + const uint32_t modifiers = view.gpu_scroll_modifiers; + const uint64_t timestamp = view.gpu_scroll_timestamp_ns; + view.gpu_scroll_pending = false; + view.gpu_scroll_delta_x = 0; + view.gpu_scroll_delta_y = 0; + view.gpu_scroll_timestamp_ns = 0; + if (delta_x != 0 || delta_y != 0) { + emitGpuSurfaceInput(host, view, kGpuInputScroll, x, y, 0, delta_x, delta_y, "", "", modifiers, timestamp); + } +} + +static void emitQueuedGpuSurfaceInputs(Host *host, NativeView &view) { + emitQueuedGpuSurfacePointerMotionInput(host, view); + emitQueuedGpuSurfaceScrollInput(host, view); +} + static void emitGpuSurfaceTextInput(Host *host, NativeView &view, int input_kind, const std::string &text, bool has_composition_cursor, size_t composition_cursor) { WindowsEvent event = {}; event.kind = kGpuSurfaceInput; @@ -2781,17 +2936,18 @@ static bool gpuSurfaceLogicalSize(const NativeView &view, HWND hwnd, double scal * latency) never happens. */ static void gpuSurfaceAdvancePacingClock(NativeView &view) { const uint64_t now = gpuTimestampNs(); + const uint64_t frame_interval_ns = gpuSurfaceFrameIntervalNs(view); if (view.gpu_last_emit_ns == 0) { view.gpu_last_emit_ns = now; return; } - const uint64_t scheduled_ns = view.gpu_last_emit_ns + kGpuFrameIntervalNs; + const uint64_t scheduled_ns = view.gpu_last_emit_ns + frame_interval_ns; if (now < scheduled_ns) { /* Fired before the deadline (timer granularity); re-basing at * now keeps the next delay a full interval. */ view.gpu_last_emit_ns = now; } else { - view.gpu_last_emit_ns = scheduled_ns + ((now - scheduled_ns) / kGpuFrameIntervalNs) * kGpuFrameIntervalNs; + view.gpu_last_emit_ns = scheduled_ns + ((now - scheduled_ns) / frame_interval_ns) * frame_interval_ns; } } @@ -2827,10 +2983,12 @@ static void gpuSurfaceRefreshFrameWakeTimer(Host *host) { if (!host) return; const uint64_t now = gpuTimestampNs(); uint64_t earliest_ns = UINT64_MAX; - for (const auto &entry : host->native_views) { - const NativeView &view = entry.second; + /* Non-const: the monitor-derived interval memoizes its HMONITOR and + * period on the view, so the pacing lookup mutates that cache. */ + for (auto &entry : host->native_views) { + NativeView &view = entry.second; if (view.kind != kViewGpuSurface || !view.hwnd || !view.gpu_emission_scheduled) continue; - const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : kGpuFrameIntervalNs; + const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : gpuSurfaceFrameIntervalNs(view); const uint64_t due_ns = view.gpu_last_emit_ns == 0 ? now : view.gpu_last_emit_ns + pace_ns; earliest_ns = std::min(earliest_ns, due_ns); } @@ -2870,6 +3028,10 @@ static void gpuSurfaceDestroyFrameWakeTimer(Host *host) { * color, buffer geometry) is the payload, so one event serves frame * requests and present completions alike. */ static void gpuSurfaceEmitFrame(Host *host, NativeView &view, HWND hwnd) { + /* Flush latest-wins pointer motion and accumulated wheel deltas before + * the frame callback so the app's rebuilt display list is available to + * this same visible frame. */ + emitQueuedGpuSurfaceInputs(host, view); /* The input's responding frame is THIS one; the follow-up schedule * (an armed animation re-requesting) returns to the minimized * heartbeat unless another input lands. */ @@ -2897,7 +3059,7 @@ static void gpuSurfaceEmitFrame(Host *host, NativeView &view, HWND hwnd) { event.scale = scale; event.frame_index = view.gpu_frame_index; event.timestamp_ns = gpuTimestampNs(); - event.frame_interval_ns = kGpuFrameIntervalNs; + event.frame_interval_ns = gpuSurfaceFrameIntervalNs(view); event.nonblank = view.gpu_nonblank; event.sample_color = view.gpu_sample_color; event.gpu_backend = view.gpu_backend; @@ -2920,8 +3082,39 @@ static void gpuSurfaceEmitFrame(Host *host, NativeView &view, HWND hwnd) { * is queued fold into it. Always fires through the message loop — a * request lands mid engine dispatch and a synchronous emission would * re-enter the engine — and the pacing clock's grid stamping keeps the - * message hop out of the period. The waitable timer supplies the precise - * wake; SetTimer is only the compatibility fallback. */ + * message hop out of the period. + * + * The deadline is a timer-queue one-shot rather than SetTimer. Two reasons: + * SetTimer cannot express a 240 Hz grid (its resolution floor is coarser + * than the 4.17 ms such a display asks for), and WM_TIMER is SYNTHESIZED + * ONLY WHEN THE QUEUE IS EMPTY — so a drag, a trackpad, or any input storm + * starves it exactly when frames matter most, and DefWindowProc's modal + * move/size loop never yields to the run loop that would otherwise notice. + * The callback posts kGpuEmitMessage, an ordinary queued message every pump + * delivers, and does nothing else: all app and runtime work stays on the UI + * thread. */ +struct GpuEmitTimerContext { + HWND hwnd; + uint64_t generation; +}; + +static VOID CALLBACK gpuSurfaceEmitTimerCallback(PVOID raw_context, BOOLEAN) { + const GpuEmitTimerContext *context = static_cast(raw_context); + if (context && context->hwnd) { + PostMessageW(context->hwnd, kGpuEmitMessage, static_cast(context->generation), 0); + } +} + +static void cancelGpuSurfaceFrameEmission(NativeView &view) { + HANDLE timer = view.gpu_emit_timer; + GpuEmitTimerContext *context = static_cast(view.gpu_emit_context); + view.gpu_emit_timer = nullptr; + view.gpu_emit_context = nullptr; + view.gpu_emission_scheduled = false; + if (timer) DeleteTimerQueueTimer(nullptr, timer, INVALID_HANDLE_VALUE); + delete context; +} + static void gpuSurfaceScheduleFrameEmission(Host *host, NativeView &view) { if (!view.hwnd || view.gpu_emission_scheduled) return; const uint64_t now = gpuTimestampNs(); @@ -2932,14 +3125,31 @@ static void gpuSurfaceScheduleFrameEmission(Host *host, NativeView &view) { * timer at the grid delay (restore through the top-level WM_SIZE * handler, re-show through the show verb), so the long delay never * gates the return to full cadence. */ - const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : kGpuFrameIntervalNs; + const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : gpuSurfaceFrameIntervalNs(view); uint64_t delay_ns = 0; if (view.gpu_last_emit_ns > 0 && now < view.gpu_last_emit_ns + pace_ns) { delay_ns = view.gpu_last_emit_ns + pace_ns - now; } - const UINT delay_ms = (UINT)((delay_ns + 500000ull) / 1000000ull); - view.gpu_emission_scheduled = true; - (void)SetTimer(view.hwnd, kGpuEmitTimerId, delay_ms, nullptr); + /* Nearest-millisecond rounding preserves a 240 Hz grid as a 4 ms wait; + * ceiling it to 5 ms would impose an artificial 200 Hz cap. */ + const DWORD delay_ms = static_cast((delay_ns + 500000ull) / 1000000ull); + /* The generation fences a re-arm against the timer already in flight: + * a callback that fires after cancellation posts a stale generation the + * UI thread drops, so a superseded deadline can never emit a frame. */ + view.gpu_emit_generation += 1; + GpuEmitTimerContext *context = new (std::nothrow) GpuEmitTimerContext{ + view.hwnd, view.gpu_emit_generation, + }; + if (!context) return; + HANDLE timer = nullptr; + if (CreateTimerQueueTimer(&timer, nullptr, gpuSurfaceEmitTimerCallback, context, + delay_ms, 0, WT_EXECUTEONLYONCE)) { + view.gpu_emit_timer = timer; + view.gpu_emit_context = context; + view.gpu_emission_scheduled = true; + } else { + delete context; + } gpuSurfaceRefreshFrameWakeTimer(host); } @@ -2960,10 +3170,10 @@ static void gpuSurfaceDrainDueFrameEmissions(Host *host) { if (!host || !host->running) return; const uint64_t now = gpuTimestampNs(); std::vector due_keys; - for (const auto &entry : host->native_views) { - const NativeView &view = entry.second; + for (auto &entry : host->native_views) { + NativeView &view = entry.second; if (view.kind != kViewGpuSurface || !view.hwnd || !view.gpu_emission_scheduled) continue; - const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : kGpuFrameIntervalNs; + const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : gpuSurfaceFrameIntervalNs(view); if (view.gpu_last_emit_ns == 0 || now >= view.gpu_last_emit_ns + pace_ns) { due_keys.push_back(entry.first); } @@ -2978,11 +3188,14 @@ static void gpuSurfaceDrainDueFrameEmissions(Host *host) { NativeView &view = found->second; if (view.kind != kViewGpuSurface || !view.hwnd || !view.gpu_emission_scheduled) continue; const uint64_t current_ns = gpuTimestampNs(); - const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : kGpuFrameIntervalNs; + const uint64_t pace_ns = (!view.gpu_prompt_frame_pending && gpuSurfaceOccludedPacingActive(host, view)) ? kGpuOccludedHeartbeatNs : gpuSurfaceFrameIntervalNs(view); if (view.gpu_last_emit_ns > 0 && current_ns < view.gpu_last_emit_ns + pace_ns) continue; const HWND hwnd = view.hwnd; - KillTimer(hwnd, kGpuEmitTimerId); - view.gpu_emission_scheduled = false; + /* Retire the in-flight deadline (and its context) rather than + * killing a WM_TIMER: the emission is happening now, and the + * generation bump keeps a callback already queued from emitting + * a second frame behind this one. */ + cancelGpuSurfaceFrameEmission(view); gpuSurfaceEmitFrame(host, view, hwnd); if (!host->running) return; } @@ -3307,29 +3520,37 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L if (!view) return DefWindowProcW(hwnd, message, wparam, lparam); const double scale = gpuSurfaceScale(hwnd); switch (message) { + case kGpuEmitMessage: + if (!view->gpu_emission_scheduled || + static_cast(wparam) != view->gpu_emit_generation) return 0; + cancelGpuSurfaceFrameEmission(*view); + gpuSurfaceEmitFrame(host, *view, hwnd); + return 0; case WM_TIMER: if (wparam == kGpuFrameTimerId) { /* Placeholder pump: arm the scheduler until the first * present lands, then retire (SetTimer repeats until - * KillTimer). */ - if (view->gpu_presented) { + * KillTimer). HIDDEN views retire too — a view the app + * declares but keeps hidden (an overlay awaiting its + * first open) never presents, so the pump would tick + * 60x/s for the whole session, and its posted frame + * traffic competes with hardware input in the message + * queue. The show path re-arms the pump for + * never-presented views, so a first reveal still + * establishes frames. The view's OWN visibility gates + * this, never IsWindowVisible: that walks the ancestor + * chain, and during the deferred-show startup window the + * top-level is still hidden — retiring every view's pump + * there left the adopted monitor wells without the first + * frame events their presents ride on. */ + if (view->gpu_presented || !view->visible) { KillTimer(hwnd, kGpuFrameTimerId); return 0; } gpuSurfaceScheduleFrameEmission(host, *view); return 0; } - if (wparam == kGpuEmitTimerId) { - /* The one scheduled emission fires: one-shot semantics - * (KillTimer before the emit — SetTimer timers repeat), - * and the scheduled flag clears BEFORE emitting so the - * emission's engine dispatch can re-arm the scheduler. */ - KillTimer(hwnd, kGpuEmitTimerId); - view->gpu_emission_scheduled = false; - gpuSurfaceEmitFrame(host, *view, hwnd); - if (host->running) gpuSurfaceRefreshFrameWakeTimer(host); - return 0; - } + /* High-resolution emissions arrive through kGpuEmitMessage. */ break; case WM_PAINT: { RECT paint_rects[kGpuPaintRegionRectCap] = {}; @@ -3442,7 +3663,7 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L view->gpu_pointer_x = x; view->gpu_pointer_y = y; const int kind = view->gpu_pointer_down ? kGpuInputPointerDrag : kGpuInputPointerMove; - emitGpuSurfaceInput(host, *view, kind, x, y, 0, 0, 0, "", "", gpuModifierFlags()); + queueGpuSurfacePointerMotionInput(host, *view, kind, x, y, 0, gpuModifierFlags()); return 0; } case WM_MOUSELEAVE: { @@ -3480,6 +3701,8 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L } } if (!suppressed) { + /* Flush the final hover sample before leave cancellation. */ + emitQueuedGpuSurfacePointerMotionInput(host, *view); emitGpuSurfaceInput(host, *view, kGpuInputPointerCancel, view->gpu_pointer_x, view->gpu_pointer_y, 0, 0, 0, "", "", gpuModifierFlags()); } } @@ -3488,6 +3711,8 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L case WM_CAPTURECHANGED: if (view->gpu_pointer_down) { view->gpu_pointer_down = 0; + /* Preserve the final drag sample before capture cancellation. */ + emitQueuedGpuSurfacePointerMotionInput(host, *view); emitGpuSurfaceInput(host, *view, kGpuInputPointerCancel, view->gpu_pointer_x, view->gpu_pointer_y, 0, 0, 0, "", "", gpuModifierFlags()); } break; @@ -3506,7 +3731,7 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L const double delta = (double)(short)HIWORD(wparam) / (double)WHEEL_DELTA * 40.0; const double delta_x = message == WM_MOUSEHWHEEL ? delta : 0; const double delta_y = message == WM_MOUSEWHEEL ? -delta : 0; - emitGpuSurfaceInput(host, *view, kGpuInputScroll, x, y, 0, delta_x, delta_y, "", "", gpuModifierFlags()); + queueGpuSurfaceScrollInput(host, *view, x, y, delta_x, delta_y, gpuModifierFlags()); return 0; } case WM_KEYDOWN: @@ -5805,7 +6030,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA NativeView &surface = view_entry.second; if (surface.kind != kViewGpuSurface || !surface.hwnd || !surface.gpu_emission_scheduled) continue; if (GetAncestor(surface.hwnd, GA_ROOT) != hwnd) continue; - surface.gpu_emission_scheduled = false; + cancelGpuSurfaceFrameEmission(surface); gpuSurfaceScheduleFrameEmission(host, surface); } } @@ -5825,10 +6050,42 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA } } return 0; + case WM_ENTERSIZEMOVE: + if (host) { + for (auto &entry : host->windows) { + if (entry.second.hwnd != hwnd) continue; + entry.second.in_modal_move_loop = true; + entry.second.deferred_frame_event = false; + } + } + break; + case WM_EXITSIZEMOVE: + if (host) { + for (auto &entry : host->windows) { + if (entry.second.hwnd != hwnd) continue; + const bool deferred = entry.second.deferred_frame_event; + entry.second.in_modal_move_loop = false; + entry.second.deferred_frame_event = false; + /* The one emission the whole drag owed: the runtime + * relayouts and persists the settled frame once. */ + if (deferred) emit(host, entry.second, kWindowFrame); + } + } + break; case WM_MOVE: if (host) { for (auto &entry : host->windows) { - if (entry.second.hwnd == hwnd) emit(host, entry.second, kWindowFrame); + if (entry.second.hwnd != hwnd) continue; + /* A drag delivers WM_MOVE at mouse rate, and the + * runtime answers each one with a full shell relayout + * plus a window-state file rewrite. A move changes no + * client size, so nothing needs either until the drag + * settles. */ + if (entry.second.in_modal_move_loop) { + entry.second.deferred_frame_event = true; + continue; + } + emit(host, entry.second, kWindowFrame); } } return 0; @@ -6041,6 +6298,13 @@ static bool createNativeWindow(Host *host, Window &window) { style = WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX; if (window.resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX; } + /* The client area belongs to child HWNDs (canvas views, webviews, and + * whatever the app adopts into them), so the parent must never paint + * over them: without this, DefWindowProc's erase — the class brush is + * COLOR_WINDOW, i.e. white in BOTH OS schemes — flashes across every + * child on any parent repaint. Adopted media surfaces hold that flash + * longest, since they only repaint when a new frame is presented. */ + style |= WS_CLIPCHILDREN; /* The requested frame is a CONTENT size in LOGICAL points (the * other hosts size the content area); scale it to physical pixels * at the DPI the window opens at (the system DPI — WM_DPICHANGED @@ -6220,6 +6484,13 @@ void native_sdk_windows_run(Host *host, EventCallback callback, void *context) { emit(host, entry.second, kResize); emit(host, entry.second, kWindowFrame); } + /* 1 ms system timer resolution for the loop's lifetime: every pacing + * primitive this host uses (the placeholder SetTimer pump, the + * timer-queue frame deadlines, the waitable frame wake) quantizes to + * the system timer, and the default ~15.6 ms granularity caps a 240 Hz + * frame grid — and the coalesced input flush riding it — near 64 Hz. + * Per-process since Windows 10 2004, released on loop exit. */ + const bool timer_resolution_raised = timeBeginPeriod(1) == TIMERR_NOERROR; MSG message = {}; while (host->running) { HANDLE handles[1] = {}; @@ -6239,6 +6510,7 @@ void native_sdk_windows_run(Host *host, EventCallback callback, void *context) { DispatchMessageW(&message); if (host->running) gpuSurfaceDrainDueFrameEmissions(host); } + if (timer_resolution_raised) timeEndPeriod(1); WindowsEvent shutdown = {}; shutdown.kind = kShutdown; shutdown.window_id = 1; @@ -6820,7 +7092,7 @@ int native_sdk_windows_show_window(Host *host, uint64_t window_id) { NativeView &surface = view_entry.second; if (surface.kind != kViewGpuSurface || !surface.hwnd || !surface.gpu_emission_scheduled) continue; if (surface.window_id != window_id) continue; - surface.gpu_emission_scheduled = false; + cancelGpuSurfaceFrameEmission(surface); gpuSurfaceScheduleFrameEmission(host, surface); } emit(host, found->second, kWindowFrame); @@ -6940,7 +7212,10 @@ int native_sdk_windows_create_view(Host *host, uint64_t window_id, const char *l break; case kViewGpuSurface: class_name = gpuSurfaceClassName(host); - style |= WS_TABSTOP; + /* Same contract as the top-level: a container hosting an + * adopted app surface must not paint its own canvas content + * over it. No-op for the containers that have no children. */ + style |= WS_TABSTOP | WS_CLIPCHILDREN; wide_text.clear(); break; default: @@ -7014,7 +7289,7 @@ int native_sdk_windows_note_gpu_surface_input(Host *host, uint64_t window_id, co NativeView &view = found->second; view.gpu_prompt_frame_pending = true; if (view.gpu_emission_scheduled) { - view.gpu_emission_scheduled = false; + cancelGpuSurfaceFrameEmission(view); gpuSurfaceScheduleFrameEmission(host, view); } return 1; From c518b0dc8f37316d5f1d5aff84c19c1122f97edc Mon Sep 17 00:00:00 2001 From: Jeff Date: Wed, 12 Aug 2026 10:11:42 -0700 Subject: [PATCH 2/6] test(windows): track the timer-queue emit path in the source pins Two source-pinning tests still asserted the WM_TIMER scheduling this change replaces, so they fail on it: one looked for `KillTimer(hwnd, kGpuEmitTimerId);` in the drain helper, the other for `if (wparam == kGpuEmitTimerId)` as the emit entry point. Neither string survives arming emissions with CreateTimerQueueTimer and delivering them as kGpuEmitMessage. Re-point both at the shape that actually ships. The drain helper retires a deadline with one call, cancelGpuSurfaceFrameEmission, which also bumps the generation and clears the scheduled flag, so the old kill-then-clear ordering pair collapses into retire-before-emit. The emit handler gains the assertion that matters for a threaded timer: it fences on the generation before touching anything, because a callback that raced a cancellation carries a stale one and must drop. Co-Authored-By: Claude Opus 5 (1M context) --- src/platform/windows/root.zig | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/platform/windows/root.zig b/src/platform/windows/root.zig index 7f8f7614b..fc8cebdfe 100644 --- a/src/platform/windows/root.zig +++ b/src/platform/windows/root.zig @@ -2543,14 +2543,16 @@ test "windows busy message loop drains due gpu frames outside input callbacks" { const loop_at = std.mem.indexOf(u8, helper, "for (const std::string &key : due_keys)") orelse return error.TestExpectedEqual; const loop = helper[loop_at..]; const before_emit_guard_at = std.mem.indexOf(u8, loop, "if (!host->running) return;") orelse return error.TestExpectedEqual; - const kill_at = std.mem.indexOf(u8, helper, "KillTimer(hwnd, kGpuEmitTimerId);") orelse return error.TestExpectedEqual; - const clear_at = std.mem.indexOf(u8, helper, "view.gpu_emission_scheduled = false;") orelse return error.TestExpectedEqual; + // Retiring the deadline is one call now that the one-shot is a timer-queue + // timer rather than a WM_TIMER: it cancels the timer, frees its context, + // bumps the generation so an already-queued callback cannot emit a second + // frame behind this one, and clears gpu_emission_scheduled. + const retire_at = std.mem.indexOf(u8, helper, "cancelGpuSurfaceFrameEmission(view);") orelse return error.TestExpectedEqual; const emit_at = std.mem.indexOf(u8, helper, "gpuSurfaceEmitFrame(host, view, hwnd);") orelse return error.TestExpectedEqual; const after_emit = helper[emit_at + "gpuSurfaceEmitFrame(host, view, hwnd);".len ..]; try std.testing.expect(std.mem.indexOf(u8, after_emit, "if (!host->running) return;") != null); try std.testing.expect(loop_at + before_emit_guard_at < emit_at); - try std.testing.expect(kill_at < clear_at); - try std.testing.expect(clear_at < emit_at); + try std.testing.expect(retire_at < emit_at); } test "windows gpu frame deadlines use a high resolution waitable timer" { @@ -2571,11 +2573,18 @@ test "windows gpu frame deadlines use a high resolution waitable timer" { const wake_at = std.mem.indexOf(u8, schedule, "gpuSurfaceRefreshFrameWakeTimer(host);") orelse return error.TestExpectedEqual; try std.testing.expect(scheduled_at < wake_at); - const timer_at = std.mem.indexOf(u8, host_source, "if (wparam == kGpuEmitTimerId)") orelse return error.TestExpectedEqual; + // The deadline fires on a timer-queue thread, so it cannot touch app state + // directly: the callback posts kGpuEmitMessage and the UI thread emits. + // Pin that the handler fences on the generation before doing anything (a + // callback that raced a cancellation carries a stale one and must drop), + // then retires the deadline before entering application code. + const timer_at = std.mem.indexOf(u8, host_source, "case kGpuEmitMessage:") orelse return error.TestExpectedEqual; const timer_tail = host_source[timer_at..]; + const generation_at = std.mem.indexOf(u8, timer_tail, "static_cast(wparam) != view->gpu_emit_generation") orelse return error.TestExpectedEqual; + const retire_at = std.mem.indexOf(u8, timer_tail, "cancelGpuSurfaceFrameEmission(*view);") orelse return error.TestExpectedEqual; const emit_at = std.mem.indexOf(u8, timer_tail, "gpuSurfaceEmitFrame(host, *view, hwnd);") orelse return error.TestExpectedEqual; - const refresh_at = std.mem.indexOf(u8, timer_tail, "gpuSurfaceRefreshFrameWakeTimer(host);") orelse return error.TestExpectedEqual; - try std.testing.expect(emit_at < refresh_at); + try std.testing.expect(generation_at < retire_at); + try std.testing.expect(retire_at < emit_at); try std.testing.expect(std.mem.indexOf(u8, host_source, "gpuSurfaceDestroyFrameWakeTimer(host);") != null); } From e5b5058f3a1a9d8176a531b682bb87e01852f2b5 Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 19:15:31 -0700 Subject: [PATCH 3/6] fix(windows): stop resize drags from cancelling due gpu frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level WM_SIZE handler re-arms every child surface's pending emission. That exists for restore-from-minimize, where a heartbeat-paced deadline can be parked up to a second out and superseding it returns full cadence without dropping a beat. But WM_SIZE also arrives on every step of a live resize drag, where the pending emission is already grid-paced and already nearly due. Re-arming it there discards a frame that was about to fire and restarts its wait, so a drag whose steps outpace the frame interval keeps resetting the deadline just before it lands. Measured on a 165 Hz desktop, dragging a canvas window for ~10 s: 2,639 deadlines were armed and only 445 fired — 17%. The window painted 2,250 times against 215 presents, so roughly nine of every ten frames on screen were the previous frame stretched to the new size rather than content laid out at that size. It looks plausible, because scaling the last good frame is a convincing stand-in, which is why this hides. Record the pacing interval a deadline was scheduled against and supersede only a parked heartbeat one. The reveal path this was written for still works; a drag now lets its due frames fire. The show/policy-hidden reveal at the other re-arm site is left alone: it runs once on a real occlusion transition, not per message. Co-Authored-By: Claude Opus 5 (1M context) --- src/platform/windows/webview2_host.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp index 7fa8f095e..dd6341925 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -520,6 +520,11 @@ struct NativeView { * the presenter can reject a patch and request a resync. */ bool gpu_force_full_repaint_pending = false; uint64_t gpu_last_emit_ns = 0; + /* The pacing interval the pending emission was scheduled against: + * kGpuOccludedHeartbeatNs while parked, the frame interval otherwise. + * Reveal paths supersede only a parked deadline (see the WM_SIZE + * handler); re-arming a grid-paced one just pushes the frame out. */ + uint64_t gpu_emit_pace_ns = 0; uint64_t gpu_frame_index = 0; double gpu_emitted_width = 0; double gpu_emitted_height = 0; @@ -3133,6 +3138,10 @@ static void gpuSurfaceScheduleFrameEmission(Host *host, NativeView &view) { /* Nearest-millisecond rounding preserves a 240 Hz grid as a 4 ms wait; * ceiling it to 5 ms would impose an artificial 200 Hz cap. */ const DWORD delay_ms = static_cast((delay_ns + 500000ull) / 1000000ull); + /* Remember which cadence this deadline was placed on, so a reveal can + * tell a parked heartbeat emission (worth superseding) from one already + * due on the frame grid (worth leaving alone). */ + view.gpu_emit_pace_ns = pace_ns; /* The generation fences a re-arm against the timer already in flight: * a callback that fires after cancellation posts a stale generation the * UI thread drops, so a superseded deadline can never emit a frame. */ @@ -6024,12 +6033,24 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA * one-shot timer. SetTimer with the same id REPLACES the * pending timer, so re-arming at the frame-grid delay * (the last emit is at least a heartbeat old, so that - * delay computes to zero) is a clean supersede. */ + * delay computes to zero) is a clean supersede. + * + * Only a PARKED deadline is worth superseding. WM_SIZE also + * arrives on every step of a live resize drag, and re-arming + * a grid-paced emission there discards a frame that was + * already due and starts its wait over — so a drag whose + * steps outpace the frame interval keeps resetting the + * deadline just before it fires, and most emissions never + * happen at all. */ if (wparam != SIZE_MINIMIZED) { for (auto &view_entry : host->native_views) { NativeView &surface = view_entry.second; if (surface.kind != kViewGpuSurface || !surface.hwnd || !surface.gpu_emission_scheduled) continue; if (GetAncestor(surface.hwnd, GA_ROOT) != hwnd) continue; + /* Only a PARKED deadline is worth superseding. One + * already due on the frame grid would just be pushed + * further out by the re-arm. */ + if (surface.gpu_emit_pace_ns <= gpuSurfaceFrameIntervalNs(surface)) continue; cancelGpuSurfaceFrameEmission(surface); gpuSurfaceScheduleFrameEmission(host, surface); } From 53530d43a6074c92d3e8c6066732b126057f5141 Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 22:39:17 -0700 Subject: [PATCH 4/6] fix(windows): repaint a gpu surface when its bounds move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A geometry change was not a reason to draw. Emissions are scheduled by input, by animation, and by explicit frame requests; the top-level WM_SIZE handler re-arms child surfaces, but only ones that ALREADY had an emission pending. A panel that happened to be idle when the window or a dock divider moved therefore never received a frame event carrying its new size: it kept presenting the packet it had rendered for the old bounds, and repaired itself only when the pointer wandered in and woke it for unrelated reasons. That is the split a user sees as "some panels resize live, others wait for the mouse" — which panels depends on nothing but whether each happened to be animating at the moment of the drag. Schedule a frame from the surface's own WM_SIZE, gated on syncGpuSurfaceGeometry reporting a real change so an unchanged message cannot hold a frame loop open. The repaint has to be forced: the runtime plans an idle frame for an unchanged scene, and a resize does not change the scene, only the viewport it is laid out against. AppKit already forces one across its view-frame and backing-scale transitions; this is the Win32 half of the same contract. Cost is bounded by the frame grid rather than the message rate — a drag delivers WM_SIZE per mouse step and all of them fold into the single in-flight emission. Co-Authored-By: Claude Opus 5 (1M context) --- src/platform/windows/root.zig | 26 ++++++++++++++++++++++++ src/platform/windows/webview2_host.cpp | 28 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/platform/windows/root.zig b/src/platform/windows/root.zig index fc8cebdfe..61dcecf52 100644 --- a/src/platform/windows/root.zig +++ b/src/platform/windows/root.zig @@ -2366,6 +2366,32 @@ test "windows packet renderer preserves text baselines and disjoint dirty region ) != null); } +test "a resized windows gpu surface asks for its own repaint" { + const host_source = @embedFile("webview2_host.cpp"); + + // The host's only other resize-driven wake re-arms surfaces that + // ALREADY have an emission pending, so an idle panel would never hear + // that its bounds moved. Both halves matter: the schedule delivers a + // frame event carrying the new size, and the forced full repaint stops + // the runtime from planning an idle frame for a scene that did not + // change — only the viewport did. + const forced_at = std.mem.indexOf( + u8, + host_source, + "view->gpu_force_full_repaint_pending = true;\n gpuSurfaceScheduleFrameEmission(host, *view);", + ); + try std.testing.expect(forced_at != null); + + // ...and it is gated on the geometry actually changing. Waking on + // every WM_SIZE regardless would hold a full-rate frame loop open for + // as long as the message keeps arriving. + try std.testing.expect(std.mem.indexOf( + u8, + host_source, + "syncGpuSurfaceGeometry(host, *view, width, height, scale)) {", + ) != null); +} + test "windows packet renderer keeps square rectangle stroke joins" { const renderer_source = @embedFile("gpu_surface_renderer.cpp"); try std.testing.expect(std.mem.indexOf( diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp index dd6341925..97d91b843 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -3622,8 +3622,32 @@ static LRESULT CALLBACK gpuSurfaceProc(HWND hwnd, UINT message, WPARAM wparam, L case WM_SIZE: { double width = 0; double height = 0; - if (gpuSurfaceLogicalSize(*view, hwnd, scale, &width, &height)) { - (void)syncGpuSurfaceGeometry(host, *view, width, height, scale); + if (gpuSurfaceLogicalSize(*view, hwnd, scale, &width, &height) && + syncGpuSurfaceGeometry(host, *view, width, height, scale)) { + /* A geometry change is a reason to draw, and nothing else + * in this host treats it as one. Emissions are scheduled by + * input, by animation, and by explicit frame requests; the + * top-level WM_SIZE handler further down only RE-ARMS + * surfaces that already had one pending. So a panel that + * happened to be idle when the window (or a dock divider) + * moved never heard about its new bounds: it kept + * presenting the packet it rendered for the old ones and + * only repaired itself once the pointer wandered in and + * woke it for unrelated reasons. That is the "some panels + * resize live, others wait for the mouse" split. + * + * The repaint has to be FORCED. The runtime plans an idle + * frame for an unchanged scene, and a resize does not + * change the scene -- only the viewport it is laid out + * against. AppKit already forces one across its view-frame + * and backing-scale transitions; this is the Win32 half of + * the same contract. + * + * Cost is bounded by the frame grid rather than the message + * rate: a drag delivers WM_SIZE per mouse step and every + * one of them folds into the single in-flight emission. */ + view->gpu_force_full_repaint_pending = true; + gpuSurfaceScheduleFrameEmission(host, *view); } return 0; } From 6cbb6798bc22fac8d70c3967b5994d08641eacc3 Mon Sep 17 00:00:00 2001 From: Jeff Date: Mon, 17 Aug 2026 13:25:34 -0700 Subject: [PATCH 5/6] perf(windows): rebuild child z-order only when a layer actually changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `native_sdk_windows_update_view` keyed its Z-order rebuild off `has_layer`, which means "the caller supplied a layer", not "the layer changed". The shell relayout supplies both a frame and a layer for every view on every WM_SIZE, so every one of those calls ran reorderWindowChildren — which collects every child of the window, sorts them, and calls SetWindowPos on each. Measured on a twenty-surface app: ~300 Z-order calls per resize step, every one of them rebuilding the order that already existed, because layers do not change while a window is being dragged. It also explains a result that made no sense on its own. A downstream app that owns its panel geometry nulls the frame out of the relayout patch, so eleven of fifteen views did no frame work at all — and still cost ~1.8 ms each. The frame was the half being skipped; the layer was the half doing the damage. Compare the supplied layer against the stored one and reorder only on a real change. Creation still reorders through its own path, so nothing that depends on a fresh view landing in the right order changes. Windows-only. Per resize step, on that same app: WM_SIZE handler p50 51.41 ms -> 0.37 ms applyShellViews p50 51.39 ms -> 0.40 ms SetWindowPos p50 55.15 ms -> 3.0 - 4.0 ms The median collapses; the tail does not follow it yet. Throughput over a 240-step sweep still scatters between roughly 150 and 320 Hz run to run, because a second defect is now the one setting the spread — see the next commit, which is what makes delivery deterministic. One caveat worth stating: reordering on every update was, accidentally, continuously repairing Z-order. Anything that relied on that repair rather than on getting its layer right will now show it. Co-Authored-By: Claude Opus 5 (1M context) --- src/platform/windows/webview2_host.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp index 97d91b843..c8c79f609 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -7590,6 +7590,12 @@ int native_sdk_windows_update_view(Host *host, uint64_t window_id, const char *l applyNativeViewFrame(host, view); applyNativeChildFrames(host, window_id, view.label); } + /* A supplied layer is not a CHANGED layer. The shell relayout patches + * every view with both a frame and its layer on every WM_SIZE, so + * keying the Z-order rebuild off `has_layer` reordered every child of + * the window once per view per resize step — measured at ~300 + * SetWindowPos calls a step, and the dominant cost of a live resize. */ + const bool layer_changed = has_layer && view.layer != layer; if (has_layer) view.layer = layer; if (has_visible) view.visible = visible != 0; if (has_enabled) view.enabled = enabled != 0; @@ -7604,7 +7610,7 @@ int native_sdk_windows_update_view(Host *host, uint64_t window_id, const char *l bool update_text = has_text || (has_role && !view.explicit_text); std::string display_text = has_text ? view.text : nativeViewDisplayText(view); if (has_visible || has_enabled || has_role || has_accessibility_label || update_text) applyNativeViewState(view, update_text, display_text); - if (has_layer) reorderWindowChildren(host, window_id); + if (layer_changed) reorderWindowChildren(host, window_id); if (view.kind == kViewGpuSurface && (has_frame || has_layer || has_visible)) { auto window = host->windows.find(window_id); if (window != host->windows.end() && window->second.transparent) { From 2abc44ad7a1633734d1a7047a4f6c2d75eeb88b0 Mon Sep 17 00:00:00 2001 From: Jeff Date: Mon, 17 Aug 2026 13:25:49 -0700 Subject: [PATCH 6/6] fix(windows): show a rendered gpu surface without waiting for WM_PAINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rendering a packet the host invalidated the surface and returned, leaving the blit to WM_PAINT. But Windows synthesizes WM_PAINT only when the message queue has nothing else to deliver, and a resize floods that queue — so the paint is starved exactly when frames matter most. Measured on a twenty-surface app mid-drag: 4570 renders produced 338 paints. Every surface was affected, from 4.5:1 to 37:1. The rendered frames were not redundant — each (surface, sequence) rendered exactly once — so this was not wasted re-rendering. It was the opposite: correct frames, laid out for the size the window had just become, overwritten by the next render before anything put them on the glass. What the user saw instead was the previous frame stretched, which is the long-standing "some panels resize live, others wait for the mouse" symptom. Which panels lag was never the interesting part; almost none of them were arriving. UpdateWindow dispatches the pending WM_PAINT straight to the window procedure. The frame is already in the target by this point, so this only spends the blit that makes it count, and it no-ops when the update region is empty — a render that changed nothing still costs nothing. unpainted renders 92.6% -> 0% paints 338 -> 4742 (over 4560 renders; the surplus is OS-initiated repaints, which were always there) The ~0.13 ms per blit across ~16 surfaces a step is real work that was not being done before, so a cost was expected. It does not show up, because what it buys back is larger: leaving the blit to the queue meant it landed whenever the queue happened to drain, and that scatter WAS the tail. Over a 240-step sweep, without this change and with it: without 173.1 / 153.8 / 318.6 Hz p90 4.20 - 14.56 ms with 280.3 / 274.3 / 282.3 / p90 4.73 - 4.95 ms 275.9 Hz Same median, an order of magnitude less spread. Deterministic delivery is the point; the throughput is a side effect of no longer stalling. Co-Authored-By: Claude Opus 5 (1M context) --- src/platform/windows/webview2_host.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/platform/windows/webview2_host.cpp b/src/platform/windows/webview2_host.cpp index c8c79f609..02e4421a5 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -7431,6 +7431,17 @@ int native_sdk_windows_present_gpu_surface_packet_binary(Host *host, uint64_t wi InvalidateRect(view.hwnd, &info.dirty_rects[index], FALSE); } } + /* Service that invalidation NOW rather than leaving it to the + * message queue. WM_PAINT is synthesized only when the queue has + * nothing else to deliver, so a resize — which floods the queue — + * starves it: measured at 15 surfaces re-rendering per step and + * barely one of them reaching the glass, every other panel showing + * pixels laid out for a size the window no longer has. Rendering a + * frame nobody sees is worse than not rendering it, and the frame + * is already in the target here; UpdateWindow just spends the blit + * that makes it count. No-op when the update region is empty, so a + * render that changed nothing still costs nothing. */ + UpdateWindow(view.hwnd); } const bool first_present = !view.gpu_presented;