vercel-labs / vercel-labs/native
Linux gpu_surface emits scroll input per event, starving the idle-band frame emission
Nobody has claimed this yet.
- Dominant language
- Zig
- Stars
- 7.7k
- Forks
- 314
- Avg merge
- 5h
- Merged PRs (30d)
- 13
Description
native_sdk_gpu_scroll in src/platform/linux/gtk_host.c emits on every GTK scroll signal, and an emission is a synchronous dispatch into the engine: view rebuild, layout, display-list diff, all of it inside the signal handler. A high-resolution wheel or a touchpad delivers 100 to 200 events a second, so that work outlasts the gap to the next event. GDK's input source at G_PRIORITY_DEFAULT is therefore ready again the moment the handler returns, and the main loop never descends to the idle band where native_sdk_gpu_surface_schedule_frame_emission arms its emission at G_PRIORITY_DEFAULT_IDLE.
The window stops presenting for as long as the gesture lasts.
static gboolean native_sdk_gpu_scroll(GtkEventControllerScroll *controller, double dx, double dy, gpointer data) {
...
native_sdk_emit_gpu_surface_input(view, NATIVE_SDK_GTK_GPU_INPUT_SCROLL, view->gpu_pointer_x, view->gpu_pointer_y, 0, delta_x, delta_y, "", "", modifiers);
return TRUE;
}
Measured
Ubuntu 26.04, GTK 4.22.4, Wayland, aarch64, four cores. Scrolling a feed of text and images in a Zig app built on the toolkit, ReleaseFast. I instrumented nothing: these are the runtime's own gpu_surface_frame and gpu_surface_input event lines with their nanosecond timestamps.
One 53.8 second gesture:
| frozen window | 26.02s, so 48.3% of the gesture |
| gaps between presented frames over 300ms | 16 |
| worst single gap | 4057 ms |
| frames presented | 12 a second |
| input arriving | 62 a second average, 138 a second sustained inside the freezes, 195 a second peak |
| median input inter-arrival | 6.7 ms |
Inside the worst freeze: 320 scroll events, 320 pointer events, 31 timers, and zero frames.
It is scheduling, not compute, and the evidence is in the same run
CPU was 49% of one core, peaking at 57%, on a four core machine. Three and a half cores idle and half of the fourth.
Timers kept firing at their normal 13 a second through every freeze.
So the main loop was alive and dispatching the whole time, with abundant CPU available, and still did not draw for four seconds. It was not too busy to draw. It was never asked.
Raising the frame source is the wrong fix, and your own note says why
The design note above native_sdk_gpu_surface_schedule_frame_emission documents the mirror of this bug: at G_PRIORITY_DEFAULT a saturated frame loop starves GTK's layout and paint sources, presents land in the retained buffer, queue_draw keeps asking, and the visible window freezes on stale glass. The idle band is load-bearing and should stay.
So the input has to be thinned instead.
macOS already does this
queueScrollInputEvent and emitQueuedScrollInputEvent in src/platform/macos/appkit_host.m accumulate into pendingScrollDeltaX/Y, snapshot the point and modifiers, arm one dispatch_after on the display grid, and fold every further event into the pending one with if (self.scrollInputPending) return;. The GTK host has no equivalent for scroll, or for pointer motion.
That is why this is invisible from a Mac, and why it has been there since the first Linux build rather than being a regression.
The shape that works
It is native_sdk_gpu_surface_schedule_frame_emission applied to scroll input: at most one flush in flight, folded producers, fired on the frame-interval grid anchored at the last emission. The handler becomes an add. Deltas sum, so it changes when the engine hears about a gesture and never how far it went.
Same machine, same gesture, only that file changed:
| before | after | |
|---|---|---|
| frozen time | 26.02s of 53.8s | 0.00s of 56.9s |
| gaps over 300ms | 16 | 0 |
| worst gap | 4057 ms | 136 ms |
| frames presented | 12 a second | 23 a second |
| frame interval p50 | 44 ms | 45 ms |
The last row is the one I would look at. Individual frames did not get faster. They got to happen.
Two details that cost me a round to find, in case you want them:
The pacing clock cannot be a bare timestamp. If it stamps now before the emission, a dispatch that overruns the frame interval leaves now past last + interval, so the next scroll event arms at delay 0 and the flush is ready on every main-loop iteration. That is the same starvation again with the flush standing where GDK stood. native_sdk_gpu_surface_advance_pacing_clock already solves this for frames by re-anchoring to the most recent grid point, and applying it to the scroll clock keeps the next delay inside (0, interval] however long a dispatch ran. It also has to run before the emission, not after, because the emission can tear the view down.
The pointer wants snapshotting with the delta. Pointer motion still emits per event and rewrites gpu_pointer_x/y, so reading them at flush time hands a nested scroll region the whole frame's accumulated delta if the pointer crossed into it. queueScrollInputEvent snapshots the point for what I assume is this reason.
Two things it does not fix
native_sdk_gpu_pointer_move and the drag handler still emit synchronously per event, so a drag over the same content reproduces the identical freeze by the identical mechanism. I have only fixed scroll.
And coalescing changes fling velocity, because ScrollAxisState.applyWheelWithRubberband sets next.velocity = scaled_delta * physics.wheel_velocity_scale from the delta of one event, with no time term anywhere. Folding 138 events a second into 60 makes the release velocity roughly 2.3x, and 3.2x at the peak rate. Distance under the finger is unchanged; only the glide after release differs. On macOS this is moot because the native scroll drivers own the physics and applyWheel never runs, so the default wheel_velocity_scale = 60 has arguably never been calibrated against the Linux path at all. That may deserve its own issue; say the word and I will open one.
Worth knowing about the test surface
Nothing executable reaches native_sdk_gpu_scroll. native automate wheel builds a .gpu_surface_input and calls dispatchPlatformEvent directly, so the automation battery passes whether this handler is right or wrong. I found this by driving a real gesture and reading the runtime's own event log, not by running the suite.
One caveat I would rather state than have you find: the machine is a VM. The mechanism is GLib main-loop priority ordering, which is not virtualisation-dependent, and the event rates above are what a high-resolution wheel produces on bare metal anyway. I saw the same behaviour with a trackpad and with a physical mouse.
I have the port running and it is four small pieces in the one file. Happy to paste the diff here if that is useful.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/platform/linux/gtk_host.c at native_sdk_gpu_scroll and read native_sdk_gpu_surface_schedule_frame_emission plus native_sdk_gpu_surface_advance_pacing_clock. Compare queueScrollInputEvent and emitQueuedScrollInputEvent in src/platform/macos/appkit_host.m, then use a real high-rate scroll gesture and the runtime event log for validation, since native automate wheel does not exercise this handler. Done means scroll input is coalesced without starving frame emission while accumulated deltas and pointer snapshots remain correct.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, linux, zig
- Domain
- desktop, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100