rumb
← logbook

A blocker, and what kept YouTube still

Eleven days after four entries

The last four entries, on 2026-10-10, covered the engine handing decisions to the embedder, YouTube playing at all, and what the browser keeps. They left two things open. The shell had hooks for a content blocker and no blocker shown on the site, and YouTube played with stalls that were measured and had no cause.

Both have moved. rumb now carries its own blocker, cuirassat, and the stalls turned out to be four separate bugs — one of them the blocker's own doing.

Cuirassat

Cuirassat is built on Brave's adblock engine and works at three depths, each on a hook the previous entries described.

By network, with EasyList and EasyPrivacy: a request the lists name is refused by the request filter before it leaves. By stylesheet: what the lists ask to hide is hidden, with a user stylesheet swapped per page. And by script, for what neither of those reaches.

A shield in the toolbar shows how much was blocked on the page in front of you, with a switch to stop blocking on that site. There are three levels. Mild stays with ads. Moderate adds trackers and the uBlock lists. Drastic adds cookie notices, other annoyances and risky servers, knowing that some page will break.

Some ads cannot be cut by network, because they arrive from the same address as the content, inside the response the page asks for itself. Those need code that runs in the page before the page's own does. Cuirassat carries its own scriptlets, under the names and arguments the uBlock lists use: the list decides which one runs, but the code that runs is rumb's. The ones that rewrite responses are only accepted from lists marked as trusted.

On YouTube, with all three, the ads are gone.

The stall the blocker caused

Scriptlets reach the page as user scripts, and some of them work by wrapping a built-in function. That is where the first stall was.

load_script ran the user scripts every time a head element was bound to a tree. A document with no browsing context — the one DOMParser or createHTMLDocument makes — still has the window of the page that made it, so its head ran the page's scripts again. So did a second head in the page itself.

ONE WINDOW, MANY HEADS the page's window <head> of the page <head> from DOMParser <head> from createHTMLDocument load_script, per head bound JSON.stringify 342 wrappers 57 runs 325 kB: 1200 ms not 24 ms video still 0.8 s The scripts now run once in a window, for the head of a document with a browsing context only.

A script that wraps a function wrapped it once more on each run. On a YouTube watch page the scripts ran 57 times, and JSON.stringify went through 342 wrappers that each copy their argument. A call with 325 kB took 1200 ms instead of 24 ms. The page makes such a call every few seconds, which stopped the script thread, and the video with it, for 0.8 s.

The scripts now run once in a window, and only for the head of a document with a browsing context.

Three more stalls, all the engine's

Every frame of a video marked its element as damaged, so the script thread redid the layout and the display list of the whole page for each one. A frame the size of the one before now goes into the image the display list already shows, and the painter is asked to draw again from the thread that brought it. Playing 1080p at 30 frames a second, the longest gap between two paints went from 147–183 ms every ten seconds to 50 ms.

timeupdate never fired during playback of any media element that nothing had touched the textTracks of: time_marches_on returned before the step that queues it. The YouTube player believed playback was still at 0 s and stopped asking for data once 20 s were buffered.

And a timer cleared by another timer due in the same batch still ran, because clearTimeout only looked in the waiting list and fire_timer had already taken every due timer out of it. That is why the player's spinner stayed on screen during playback.

One more was slow rather than stalled. Every part of a Blob is a root of its own, and the set of roots looks for the one to give up from its newest entry backwards. YouTube builds a Blob of 54 000 text parts on every navigation, and dropping them oldest first took 729 ms. They now go newest first.

Players that never let go

The handlers of the signal adapter and of the pipeline held the inner player strongly, and the inner player owns both, so no player was ever freed. Every change of a media element's source left a GStreamer thread behind, with a pipeline, its decoder and, with source buffers, every coded frame. A page that loads a file eight times and attaches a MediaSource eight times ended with 16 player threads, 8 NVDEC sessions and 1645 MB resident. It now ends with 1, 1 and 632 MB.

Freeing them exposed the next bug. A player message holds a reference to the player, so when the thread that posted it gave up the last one, the player was disposed on the thread it waits for. That showed up as a segfault in libgobject when YouTube changed video, twice in 114 changes. A reaper thread now gives players up, which also keeps that wait off the script thread.

And one muted video muted the next. The audio server remembers volume and mute by application name and gives them to every new stream, so a page that muted one video left the next one silent. Volume and mute are now applied inside the pipeline, and a stream opens with the values of its player.

MSE gained what YouTube's audio and its quality menu ask for: SourceBuffer.mode = "sequence", audio/mpeg and audio/aac source buffers, getVideoPlaybackQuality() counted where frames reach the renderer, and navigator.mediaCapabilities.decodingInfo().

Outside media, a console call on a Twitch stream page sent the devtools a description of everything the logged object leads to, client or no client, and the page grew by 700 MB a second until the system killed the browser. The description now stops three objects deep. And a focused, empty text field shows its caret again.

What is left

There is still no EME, so anything behind DRM does not play, and codec support is whatever the GStreamer plugins on the machine provide. Next in the backlog are downloads with quarantine and a total-blocking mode.

rumb does not replace your browser yet. Each week it is missing less.

BEFORE: A CYCLE AFTER: A WEAK BACK-REFERENCE inner player handlers owns strong 16 threads · 8 NVDEC · 1645 MB inner player handlers owns weak 1 thread · 1 NVDEC · 632 MB Eight loads of a file and eight MediaSource attachments, measured after each.
Nothing outside the cycle pointed at the player, so nothing ever let it go.