From 672f682e1869aca0ef23bcab550574827530dc4b Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 19:13:39 -0700 Subject: [PATCH 1/3] 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 cdb102173..30ab470c9 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1662,6 +1662,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 0773ffb7b..51d383844 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 @@ -175,6 +177,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; @@ -366,6 +371,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). */ @@ -469,6 +480,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 @@ -573,6 +608,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 { @@ -1919,12 +1959,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); @@ -1991,6 +2052,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); @@ -2002,6 +2065,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); @@ -2067,7 +2131,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 @@ -2088,11 +2152,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); @@ -2108,6 +2171,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; } @@ -2588,12 +2681,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; @@ -2609,6 +2702,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; @@ -2761,17 +2916,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; } } @@ -2807,10 +2963,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); } @@ -2850,6 +3008,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. */ @@ -2877,7 +3039,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; @@ -2900,8 +3062,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(); @@ -2912,14 +3105,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); } @@ -2940,10 +3150,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); } @@ -2958,11 +3168,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; } @@ -3287,29 +3500,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] = {}; @@ -3422,7 +3643,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: { @@ -3460,6 +3681,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()); } } @@ -3468,6 +3691,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; @@ -3486,7 +3711,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: @@ -5781,7 +6006,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); } } @@ -5801,10 +6026,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; @@ -6017,6 +6274,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 @@ -6194,6 +6458,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] = {}; @@ -6213,6 +6484,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; @@ -6771,7 +7043,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); @@ -6891,7 +7163,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: @@ -6965,7 +7240,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 2976afbb5dd6d09b1faa4ad07d2d50a57fb66687 Mon Sep 17 00:00:00 2001 From: Jeff Date: Wed, 12 Aug 2026 10:11:42 -0700 Subject: [PATCH 2/3] 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 9c0237554..40e0a26be 100644 --- a/src/platform/windows/root.zig +++ b/src/platform/windows/root.zig @@ -2507,14 +2507,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" { @@ -2535,11 +2537,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 bf4da8c253b1430a171938c8ff0278fb76702697 Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 19:15:31 -0700 Subject: [PATCH 3/3] 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 51d383844..137fe1908 100644 --- a/src/platform/windows/webview2_host.cpp +++ b/src/platform/windows/webview2_host.cpp @@ -519,6 +519,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; @@ -3113,6 +3118,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. */ @@ -6000,12 +6009,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); }