Archive ·
A video player on GNOME drops a frame every twelve seconds, and it is not the player: the desktop’s garbage collector traced to a missed vblank, 3 November 2026
Karhu Player’s log said a dropped frame every ten to fifteen seconds under GNOME. We traced the screen’s page flips, the scheduler and the compositor’s own call stacks on one clock: in twelve of thirteen missed refreshes the desktop’s compositor was running JavaScript across its own deadline, mostly a 14 to 24 ms garbage collection from a timer in gjs. A faster machine paid the same. What the player does about it, and does not, is the design question this answers.
Written once, on the date above. Corrections are added at the end as dated addenda, and nothing above them is changed silently.
The thing we were trying to explain
Karhu Player plays a 60 frame-a-second clip on a 60 Hz screen under Ubuntu 24.04’s GNOME desktop, and about every twelve seconds its log says it dropped a frame. A slow-motion film of the screen shows what a viewer would see: one picture held for a second refresh, then one skipped so the clip catches up. The rate did not change with the player’s overlay on or off, at 1080p or UHD, with the clip at the screen’s exact rate or faster. So it was not our drawing. On 2 November 2026 a reading of which process had the CPU at which fraction of the wall-clock second put every drop at one phase of the second and pointed at the desktop’s own once-a-second work, and we wrote that down as an inference with a hope attached: about a millisecond of desktop work seemed to cost a refresh on our small test machine, so a faster one need not miss. On 3 November we traced it instead of inferring it.
How we traced it
Three records on one clock, taken while the player ran for 95 seconds full screen. The graphics driver’s own event for every completed page flip, which carries the hardware time the picture changed: a gap of two refresh periods between flips is a vblank the desktop missed, whatever anyone’s log says. Every scheduler switch and wake-up on the machine, so that for each miss we could read what the desktop’s compositor thread was doing in the 30 ms before it and what else was on its CPU. And the compositor’s call stacks sampled two thousand times a second, with the debug symbols for its JavaScript engine fetched afterwards so the samples had names. The player’s own Wayland trace and drop lines ran beside them, as the thing to be explained, not the instrument.
In the 88 seconds read, 5,266 flips arrived 16.675 ms apart, and 13 gaps were two periods wide (one was three). Twelve of the thirteen fell within 60 ms of one of the player’s logged drops. The player logged 16; the other four were its own catch-ups at a normal 16.7 ms paint, with no missed vblank behind them.
What the desktop was doing
The compositor’s cadence, read from the scheduler record: its main thread wakes 3.6 to 5.2 ms before each vblank, runs for about 0.4 ms to decide the frame, hands it to the thread that commits it to the display, and the flip lands on the vblank. At a miss, in 12 of the 13, that main thread was inside one continuous run that spanned the moment it should have dispatched the frame: a median of 16 ms of work, 13 to 28, where it normally does under 4 in the same window. The frame went out when the run ended, one vblank late. It was not waiting for a CPU: its longest wait was 1.2 ms. Nothing else was crowding its core, and our player had less time on that core at the misses than at any other moment.
The stacks say what the run was. Under seven of the thirteen misses the compositor was marking live objects for its JavaScript garbage collector, in collections of 14 to 24 ms, every one beginning within a hundredth of a second of the same fraction of the wall-clock second, and every collection of the run produced a miss. Five misses were JavaScript script and just-in-time compilation runs of 9 to 20 ms. One was a thread woken 5 ms late with nothing running, and we have not read why. Across the whole run, the compositor’s JavaScript engine ran almost only in one tenth of each second, which is where the drops had been found the day before.
A second trace with deeper stack unwinding named the caller. Seventy-seven of eighty-two collection samples unwind to trigger_gc_if_needed in gjs, GNOME Shell’s JavaScript binding, calling a full collection of the engine, from a timer gjs arms itself. The phase of that timer within the second moved when the machine was rebooted, as GLib’s second-granularity timers do, and the misses moved with it.
The hope, tested
The small machine is an Intel N100 desktop. The same trace on a Core Ultra 7 laptop with the same GNOME, gjs and JavaScript engine builds: 11 missed vblanks in 88 seconds, every one at a logged drop, seven of them the same collections, 15 to 25 ms long, under the same caller. The collections are no shorter on the faster processor by enough to matter. One collection on the laptop took 4 ms and cost nothing, which is the rule’s other side: a collection longer than the compositor’s four or five millisecond lead costs a vblank, and the lead does not grow with the processor. The hope we wrote down on 2 November was wrong. The work was twenty milliseconds, not one, and the faster machine pays the same.
We also ran the trace under two other video players on the same desktop. The same collections landed under both. This is the desktop’s cost, paid by whatever is on the screen; what each player does about it, and how the players compare, is a separate question we will publish separately after its own review.
What this means for the player’s design
Karhu Player waits for each refresh of the screen before it shows the next picture, and it keeps a short queue of decoded pictures ahead of the one on screen. When the desktop shows a picture for a second refresh, the player is now one refresh behind the clock. Its rule for that is simple and we have kept it: where the lag will drain on its own, because the clip is slightly slower than the screen, hold the picture and let it; where it will not, drop one picture to catch up, and write that drop in the log with the paint time that caused it. The log line for each of these drops says the previous picture took 25 to 34 ms to paint, two refreshes, which is what the trace confirmed from the other side.
We do not hide the miss. The alternative presentation mode, in which the player draws continuously and the display takes whatever is newest, makes the pairs vanish from a counter while doubling the drawing for no more pictures on the glass, and we measured that before we chose. A deeper queue would, by arithmetic, absorb one missed vblank at the price of a frame of latency on every picture. Neither buys a viewer anything the desktop has not already taken. What a viewer sees on this desktop is one picture held and one skipped every ten to fifteen seconds, from the desktop’s garbage collector, and the honest thing a player can do is show the clip at its own clock, drop exactly one picture when it must, and say so.
What remains open
- Why gjs collects when it does. The timer and the caller are named; the rule that arms the timer is in gjs’s source and we have not read it.
- One miss in thirteen was a late wake with nothing running on the core. Unread.
- Whether a shell with less to collect misses less. We measured two machines running the same shell; we have not varied the shell.
What we got wrong on the way
- The 2 November reading said about a millisecond of desktop work. It was twenty. A CPU sampler averages over the second; the trace reads the run.
- The trace reader’s first version added up the compositor’s time from the slices that ended inside its window, and so left out the one long slice that ran across the vblank in five of the thirteen misses. The timeline of slices printed beside the sums showed them. It counts by overlap now.
- Its first version also judged what a stack sample was doing from every frame in the stack. With deep unwinding, every frame of the shell’s ends in the JavaScript function that runs the shell, so an idle machine read as 86 % script. The leaf frames decide now.
- The first symbolised stack text held 3,672 of 40,540 samples. The tool that wrote it had stopped where an optional step failed and said so only on its error stream. We noticed because eleven samples a second did not match a thread the scheduler had at eleven percent of a core.
The recorder and the reader are kept with the player’s source, each with a self-test on a made-up trace and a sweep that breaks each of its guards in turn. The records this entry reads are dated 3 November 2026 and describe Karhu Player as built on 2 November under GNOME 46 with gjs 1.80.2 and SpiderMonkey 115.