← bitácora

Un bloqueador, y lo que dejaba quieto a YouTube

Once días después de cuatro entradas

Las cuatro últimas entradas, del 2026-10-10, contaban cómo el motor cede decisiones al embebedor, cómo YouTube llegó a reproducir y lo que el navegador guarda. Dejaron dos cosas abiertas. El shell tenía los enganches para un bloqueador de contenido y la web no enseñaba ningún bloqueador, y YouTube reproducía con paradas medidas y sin causa.

Las dos se han movido. rumb lleva ya su propio bloqueador, cuirassat, y las paradas resultaron ser cuatro fallos distintos, uno de ellos obra del propio bloqueador.

Cuirassat

Cuirassat está hecho sobre el motor adblock de Brave y trabaja a tres profundidades, cada una sobre un enganche que describieron las entradas anteriores.

Por red, con EasyList y EasyPrivacy: el filtro de peticiones rechaza antes de que salga una petición que las listas nombran. Por hoja de estilos: lo que las listas piden esconder se esconde, con una hoja de usuario que se cambia por página. Y por script, para lo que no alcanza ninguna de las dos.

Un escudo en la barra enseña cuánto se ha bloqueado en la página que tienes delante, con un interruptor para dejar de bloquear en ese sitio. Hay tres niveles. Leve se queda en los anuncios. Moderado añade rastreadores y las listas de uBlock. Drástico suma avisos de cookies, otras molestias y servidores de riesgo, sabiendo que alguna página se romperá.

Hay anuncios que no se pueden cortar por red, porque llegan desde la misma dirección que el contenido, dentro de la respuesta que la página pide para sí misma. Para esos hace falta código que corra en la página antes que el suyo. Cuirassat lleva sus propios scriptlets, con los nombres y argumentos que usan las listas de uBlock: la lista decide cuál se ejecuta, pero el código que corre es el de rumb. Los que reescriben respuestas solo se aceptan de listas marcadas como de confianza.

En YouTube, con las tres, los anuncios desaparecen.

La parada que causó el bloqueador

Los scriptlets llegan a la página como scripts de usuario, y algunos funcionan envolviendo una función de la plataforma. Ahí estaba la primera parada.

load_script ejecutaba los scripts de usuario cada vez que un elemento head se enganchaba a un árbol. Un documento sin contexto de navegación —el que crean DOMParser o createHTMLDocument— tiene aun así la ventana de la página que lo creó, así que su head volvía a ejecutar los scripts de la página. Y lo mismo un segundo head en la propia página.

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.

Un script que envuelve una función la envolvía una vez más en cada pasada. En una página de vídeo de YouTube los scripts se ejecutaban 57 veces, y JSON.stringify pasaba por 342 envoltorios que copian cada uno su argumento. Una llamada con 325 kB tardaba 1200 ms en lugar de 24 ms. La página hace una llamada así cada pocos segundos, y eso paraba el hilo de script, y con él el vídeo, durante 0,8 s.

Ahora los scripts se ejecutan una vez por ventana, y solo para el head de un documento con contexto de navegación.

Otras tres paradas, todas del motor

Cada fotograma de un vídeo marcaba su elemento como dañado, así que el hilo de script rehacía la maquetación y la lista de pintado de toda la página por cada uno. Un fotograma del tamaño del anterior entra ahora en la imagen que la lista de pintado ya muestra, y se pide al pintor que vuelva a dibujar desde el hilo que lo trajo. Reproduciendo 1080p a 30 fotogramas por segundo, el hueco más largo entre dos pintados ha pasado de 147–183 ms cada diez segundos a 50 ms.

timeupdate no se disparaba nunca durante la reproducción de un elemento multimedia cuyos textTracks nadie había tocado: time_marches_on volvía antes del paso que lo encola. El reproductor de YouTube creía que seguía en el segundo 0 y dejaba de pedir datos al tener 20 s en el búfer.

Y un temporizador cancelado por otro que vencía en la misma tanda se ejecutaba igual, porque clearTimeout solo miraba en la lista de espera y fire_timer ya había sacado de ella todos los vencidos. Por eso la ruedita del reproductor se quedaba en pantalla durante la reproducción.

Otra era lentitud más que parada. Cada parte de un Blob es una raíz propia, y el conjunto de raíces busca la que soltar desde su entrada más nueva hacia atrás. YouTube monta un Blob de 54 000 partes de texto en cada navegación, y soltarlas de la más vieja a la más nueva tardaba 729 ms. Ahora se sueltan de la más nueva a la más vieja.

Reproductores que nunca se soltaban

Los manejadores del adaptador de señales y del pipeline sujetaban el reproductor interno con una referencia fuerte, y el reproductor interno es dueño de ambos, así que no se liberaba ningún reproductor. Cada cambio de fuente de un elemento multimedia dejaba atrás un hilo de GStreamer, con un pipeline, su decodificador y, con source buffers, todos los fotogramas codificados. Una página que carga un archivo ocho veces y engancha un MediaSource ocho veces acababa con 16 hilos de reproductor, 8 sesiones de NVDEC y 1645 MB residentes. Ahora acaba con 1, 1 y 632 MB.

Liberarlos destapó el siguiente fallo. Un mensaje del reproductor guarda una referencia al reproductor, así que cuando el hilo que lo enviaba soltaba la última, el reproductor se destruía en el hilo al que espera. Se veía como un segfault en libgobject cuando YouTube cambiaba de vídeo, dos veces en 114 cambios. Ahora un hilo recolector suelta los reproductores, lo que además saca esa espera del hilo de script.

Y un vídeo silenciado silenciaba el siguiente. El servidor de audio recuerda el volumen y el silencio por nombre de aplicación y se los da a cada flujo nuevo, así que una página que silenciaba un vídeo dejaba mudo el siguiente. El volumen y el silencio se aplican ahora dentro del pipeline, y un flujo se abre con los valores de su reproductor.

MSE ha ganado lo que piden el audio de YouTube y su menú de calidad: SourceBuffer.mode = "sequence", source buffers audio/mpeg y audio/aac, getVideoPlaybackQuality() contado donde los fotogramas llegan al renderizador, y navigator.mediaCapabilities.decodingInfo().

Fuera del multimedia, una llamada a la consola en una página de directo de Twitch mandaba a las devtools una descripción de todo lo que alcanzaba el objeto registrado, hubiera cliente o no, y la página crecía 700 MB por segundo hasta que el sistema mataba el navegador. La descripción se detiene ahora a tres objetos de profundidad. Y un campo de texto vacío y con el foco vuelve a enseñar el cursor.

Lo que queda

Sigue sin haber EME, así que nada detrás de DRM se reproduce, y los códecs son los que den los plugins de GStreamer de la máquina. Lo siguiente en el backlog son las descargas con cuarentena y un modo de bloqueo total.

rumb todavía no sustituye a tu navegador. Cada semana le falta menos.

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.
Nada fuera del ciclo apuntaba al reproductor, así que nada lo soltaba nunca.