From 4d1c5acf5748b9a9b9b40cfb6ee1118593bacecd Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 29 Oct 2026 12:12:35 +0100 Subject: sound: drop the OSD's canvas grain, it broke Qt's garbage collector The desktop shell's recurring 100% CPU spin is a Qt bug set off by the volume OSD. Its grain Canvas called createImageData on every paint, and Qt 6.11 subtracts that pixel buffer from the V4 unmanaged-heap counter without having added it. The first GC after an OSD show wrapped the size_t counter, and from then on every JS allocation ran a full GC until a restart. The OSD shows on volume keys, track changes and resume, which is why the spin followed all three. Found by running each always-on unit of the desktop in its own qs with GC stats: only the sound module wrapped, 3 s after a resume, and a volume nudge reproduced it on demand. Qt's qml tool with a bare Canvas confirms it is createImageData alone: 17,856 of 17,856 GCs wrapped, the same canvas drawing with fillRect 0 of 5. With the grain removed, 8 OSD shows under forced GCs left the counter intact. The grain was alpha 14/255, so the OSD looks nearly the same. vram.sh's comment blamed the fdinfo parse for the underflow; corrected, and the lesson is in AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 ++++++++++++++ desktop/modules/perf/vram.sh | 6 +++--- desktop/modules/sound/Osd.qml | 33 ++++----------------------------- desktop/modules/sound/README.md | 5 +++-- 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e089254..ca862dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -402,6 +402,20 @@ changing that component. The ones that generalise: is not a function`, which shows only in `qs log`; a Repeater bound to it simply builds nothing. Use `reduce((a, x) => a.concat(...), [])`. Seen with the assistant page's cloud Prefer rows (2026-10-23). +- **Canvas `createImageData` breaks Qt's garbage collector for good.** Qt + 6.11 subtracts the pixel buffer from the V4 engine's unmanaged-heap counter + without ever having added it, so the first GC after one is created wraps + the `size_t` counter below zero, and from then on every JS allocation runs + a full GC: a core pinned, keybinds and the drawer lagging, until a + restart. Reproduced in Qt's own `qml` tool with nothing but a `Canvas` + (17,856 of 17,856 GCs wrapped; the same canvas drawing with `fillRect`, 0 + of 5). The volume OSD's grain did it on every show, which is why the + desktop shell spun after volume keys, track changes and resumes alike. + `QV4_GC_TIMELIMIT=0`, set earlier for a misdiagnosed "stuck incremental + GC", does not help. Never use `createImageData`, `getImageData` or + `putImageData`. Found by running each always-on unit in its own `qs` with + `--log-rules qt.qml.gc.allocatorStats.debug=true` and reading the + unmanaged counter after each GC. - **A `Socket` whose connect failed never retries.** Quickshell 0.3.1 keeps the failed `QLocalSocket`, so setting `connected = true` again (or toggling it, or changing `path`) is a no-op: `setConnected` only connects when no diff --git a/desktop/modules/perf/vram.sh b/desktop/modules/perf/vram.sh index 8ba61fb..83db27d 100755 --- a/desktop/modules/perf/vram.sh +++ b/desktop/modules/perf/vram.sh @@ -17,9 +17,9 @@ # instead of scanning, for test_vram.sh. # # Summed here rather than in JS: a process holding thousands of DRM fds -# makes the scan a few hundred KB, and parsing that in QML every 2 s drove -# Qt's unmanaged-heap counter to underflow, after which the V4 engine ran a -# GC on every allocation and the shell sat at 100% CPU (AGENTS.md). +# makes the scan a few hundred KB, which is a lot of string churn to parse +# in QML every 2 s. It was moved here while chasing a GC underflow that +# turned out to be the sound OSD's createImageData (AGENTS.md), not this. # # Fds of a repeated client id with different values: the first one awk's # for-in reaches counts, where Perf.js took the first in scan order. They diff --git a/desktop/modules/sound/Osd.qml b/desktop/modules/sound/Osd.qml index f60f5ce..e66d559 100644 --- a/desktop/modules/sound/Osd.qml +++ b/desktop/modules/sound/Osd.qml @@ -186,9 +186,10 @@ Scope { color: Qt.alpha(Theme.base, 0.82) } - // Frost on the scrim, faked: a faint top-down sheen and fixed - // grain. The art is opaque, so the compositor's frosting - // cannot show here without costing the scrim its legibility. + // Frost on the scrim, faked: a faint top-down sheen. The art is + // opaque, so the compositor's frosting cannot show here + // without costing the scrim its legibility. No grain: see + // createImageData in AGENTS.md. Rectangle { anchors.fill: parent radius: bgMask.radius @@ -197,32 +198,6 @@ Scope { GradientStop { position: 0.6; color: "transparent" } } } - - // ponytail: grain painted once with Canvas; a ShaderEffect - // would animate or scale better but needs a compiled .qsb. - Canvas { - anchors.fill: parent - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: bgMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1.0 - } - onPaint: { - if (width <= 0 || height <= 0) return; - const ctx = getContext("2d"); - const img = ctx.createImageData(width, height); - for (let i = 0; i < img.data.length; i += 4) { - const v = Math.random() * 255; - img.data[i] = v; - img.data[i + 1] = v; - img.data[i + 2] = v; - img.data[i + 3] = 14; - } - ctx.putImageData(img, 0, 0); - } - } } HoverHandler { diff --git a/desktop/modules/sound/README.md b/desktop/modules/sound/README.md index c3ada08..88aba11 100644 --- a/desktop/modules/sound/README.md +++ b/desktop/modules/sound/README.md @@ -60,8 +60,9 @@ When the track has art, it also fills the whole panel as a blurred background under a dark scrim (`Theme.base` at 0.82), and the thumbnail stays sharp in the row. The palette is dark, so the scrim is what keeps text legible on a light cover; blur alone did not. The art is opaque, so it hides the -compositor's frosting while it shows, so a faint sheen and grain fake it on -the scrim. Without art the panel is unchanged. +compositor's frosting while it shows, so a faint sheen fakes it on the +scrim. It once had Canvas grain too, removed because `createImageData` +underflows Qt's GC counter (AGENTS.md). Without art the panel is unchanged. A new cover crossfades in once it has decoded, so a slow art server never fades through black. When the track has no art, or its cover fails to load, -- cgit v1.2.3