Skip to content

fix(windows): stop resize drags from cancelling due gpu frames - #324

Closed
jhodges10 wants to merge 3 commits into
vercel-labs:mainfrom
jhodges10:fix/windows-resize-emission-rearm
Closed

fix(windows): stop resize drags from cancelling due gpu frames#324
jhodges10 wants to merge 3 commits into
vercel-labs:mainfrom
jhodges10:fix/windows-resize-emission-rearm

Conversation

@jhodges10

Copy link
Copy Markdown
Contributor

The problem

The top-level WM_SIZE handler re-arms every child surface's pending emission:

if (wparam != SIZE_MINIMIZED) {
    for (auto &view_entry : host->native_views) {
        ...
        surface.gpu_emission_scheduled = false;
        gpuSurfaceScheduleFrameEmission(host, surface);
    }
}

That exists for restore-from-minimize, and the reasoning in its comment is sound: a heartbeat-paced deadline can be parked up to a second out, so 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 throws away a frame that was about to fire and restarts its wait. When drag steps outpace the frame interval — which is the normal case — the deadline is repeatedly reset just before it lands, and most emissions never happen.

Measurements

165 Hz Windows desktop, dragging a canvas window for ~10 s, counters on the emission pipeline:

resize=2192  sched=6996  folded=4357  emits=445  present=215  paints=2250
  • Armed deadlines: 6996 − 4357 folded = 2,639. Only 445 fired — 17%.
  • 2,250 window paints against 215 presents, so roughly nine of every ten frames on screen were the previous frame stretched to the new size, not content laid out at that size.

This hides well because scaling the last good frame is a convincing stand-in; a drag looks smooth while showing mostly interpolation. It also survived a machine reboot and got worse on an idle machine (27% → 17%), which rules out interference.

The fix

Record the pacing interval a deadline was scheduled against, and supersede only a parked heartbeat one:

if (surface.gpu_emit_pace_ns <= kGpuFrameIntervalNs) continue;

The restore-from-minimize path this was written for is unchanged — a parked deadline is still superseded. A grid-paced deadline during a drag is now left to fire.

The other re-arm site (show / policy-hidden reveal) is deliberately untouched: it runs once on a real occlusion transition rather than per message, so it has no equivalent problem.

Testing

zig build is clean. zig build test shows no new failures — canvas_widget_event_tests (target-less composition keys) and three package.test DMG/service cases fail identically on unmodified main on this machine, so they are pre-existing here.

Verified against unmodified main: the same re-arm is present and the same counters reproduce, so this is independent of #323 and can land in either order. The two touch the same block and will need a trivial conflict resolution depending on which merges first.

Caveat: measured on one Windows desktop. tools/windows-truth/perf-input-desktop.ps1 would be the authoritative check and has not been run against this change.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@jhodges10 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

jhodges10 and others added 3 commits August 12, 2026 10:18
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 vercel-labs#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 vercel-labs#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@jhodges10
jhodges10 force-pushed the fix/windows-resize-emission-rearm branch from 9a6a81a to bf4da8c Compare August 12, 2026 17:22
@jhodges10

Copy link
Copy Markdown
Contributor Author

Folding this into #323 rather than keeping it standalone.

The two changes modify the same function, and this fix's correct implementation depends on which base it sits on: against main it guards a SetTimer deadline using kGpuFrameIntervalNs; against #323's timer queue it guards a CreateTimerQueueTimer deadline using gpuSurfaceFrameIntervalNs(surface). Maintaining two mutually-conflicting versions of one fix is a standing trap, and whichever merged second would have had to be rewritten anyway.

The commit is preserved verbatim in #323 (bf4da8c2), resolved against the timer queue. Nothing is lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant