← bitácora

v0.2.0 — pestañas, y luego grupos

Una pestaña es un motor entero

En rumb no existe una representación ligera de una pestaña. Una pestaña es dueña de un contexto de render offscreen derivado del padre headless compartido, de un WebView de Servo construido dentro de él y del delegate que lo vigila. Diez pestañas son diez WebViews, diez hilos de script, diez heaps de JS, diez framebuffers.

tick() llama a spin_event_loop una vez y eso gobierna todas las pestañas — las de fondo siguen cargando —, pero solo la pestaña activa pinta y exporta un dma-buf al slot compartido. El compositor no ve nunca más de un framebuffer. set_active le quita el freno a la pestaña que gana el foco y la muestra, y frena y oculta todas las demás que sigan vivas. Eso es lo que impide que una pestaña de fondo pinte para siempre: si no, un cursor parpadeante en una página de la que te has ido se repintaría a pleno ritmo, y fugaría memoria mientras lo hace.

El contexto de render por pestaña además zanjó un problema más antiguo. Ahora redimensionar intercambia el FBO offscreen en lugar de redimensionar la superficie de surfman — que es lo que hacía panic (servo/servo#38369) y la razón por la que el asunto del redimensionado de ventana llevaba en el backlog desde mayo. Ahora el bucle de render puede igualar el tamaño de render de Servo al área de contenido en cada frame, así que las media queries de CSS se disparan contra el viewport de verdad.

Devolver la memoria

Una pestaña que lleve suficiente tiempo inactiva se descarta: soltar su WebView envía CloseWebView a la constellation y se lleva por delante el hilo de script, el heap de JS, el DOM y la maquetación. El contexto de render se conserva, así que despertarla es reconstruir contra el mismo FBO en lugar de un trasiego nuevo de texturas de GL. La URL, el título, el favicon y la pertenencia a un grupo sobreviven; al despertar se recarga desde la URL guardada.

Soltarlo no basta en Linux. El desmontaje libera la memoria dentro del proceso, pero glibc la mantiene en su pool y el RSS no baja nunca. Así que cada cinco segundos el bucle de render llama a malloc_trim(0) y, en la misma pasada, registra el RSS de /proc/self/statm junto al número de pestañas y la URL activa. El comentario que hay encima de ese watchdog lo llama «el incidente de los 24 GB».

Donde se puso el cuidado fue en las salvaguardas. Nunca la pestaña activa, nunca una fijada, nunca una que ya esté dormida; luego, inactiva por encima del umbral, sin cambios de título ni de favicon dentro de una ventana de señal — al menos cinco segundos incluso en modo instantáneo, para que a una pestaña que sigue cargando con el título parpadeando no se la mate bajo los pies — y sin reproducir contenido multimedia. Superado eso, dentro de la página corre un sondeo de JavaScript de solo lectura: true si hay un elemento de vídeo o de audio reproduciéndose, si un campo editable tiene un valor distinto del suyo por defecto, o si window.onbeforeunload es una función. Nada de parchear fetch ni WebSocket sobre la marcha, que arriesgaría romper el propio sitio que se está inspeccionando; cualquier error o respuesta que no sea booleana cuenta como ocupado, así que no se descarta nada que no se haya podido inspeccionar.

El sondeo existe porque la API de embebido no da nada: Servo no expone ni un fetch o XHR en vuelo, ni un WebSocket o un stream SSE abiertos, ni formularios sucios, ni un beforeunload registrado. El sondeo recupera los dos últimos desde dentro de la página y los tres primeros no los ve en absoluto — una pestaña que mantiene un WebSocket abierto, callada y ociosa, sigue siendo candidata a suspenderse. El valor por defecto de quince minutos hace que eso sea improbable, no imposible, y el comentario de documentación lo dice.

active drawn every frame background throttled, hidden discarded title and URL only switch away select idle long enough WebView and framebuffer dropped waking reloads the page — the state inside it is gone playing audio, or holding input nobody submitted, blocks this edge

Los grupos son maquetación

La pertenencia a un grupo es un campo de la pestaña: un id de grupo opcional. El nombre, el color y el estado de colapsado viven en el shell, indexados por ese id. Todo lo demás de los grupos es renderizado.

La barra recorre las pestañas en orden. Una pestaña sin grupo se dibuja como una ficha suelta; el primer miembro de un grupo hace que el grupo entero se dibuje en esa posición — la píldora, y después la ficha de cada miembro — y los miembros posteriores se saltan. Un grupo parece contiguo porque mover uno los mueve todos: move_group va sacando a los miembros del índice más alto al más bajo, para que los índices menores sigan siendo válidos, les restaura el orden y los reinserta como una sola tirada entre los que no son miembros. Averiguar después dónde ha acabado la pestaña activa se gana su propio párrafo en el código fuente.

El aspecto pasó por varias formas en una sola mañana: un punto de color por ficha, después un borde inferior de color en cada ficha agrupada para que los miembros contiguos formaran una línea continua, y después una única línea dibujada para todo el bloque. Un subrayado horizontal no tiene sentido en una barra vertical, así que allí se convirtió en un raíl a lo largo de la izquierda de la columna.

Una de las pasadas se equivocó en las dos direcciones. Un grupo colapsado recibió un distintivo que decía cuántas pestañas escondía; cuarenta minutos después el distintivo se borró de raíz. Un grupo colapsado es ahora solo su nombre, y esconde a todos sus miembros.

Seis pasadas a un cursor de destino

Arrastrar y soltar costó más commits que el modelo de grupos al que sirve. La primera forma rellenaba con un resaltado el destino sobre el que estaba el cursor, lo que te dice sobre qué pestaña estás — que no es la misma pregunta que dónde va a aterrizar la pestaña arrastrada. Pasó a ser una línea de acento de dos píxeles en el borde delantero de la ficha bajo el cursor, a la izquierda en una barra horizontal y arriba en una vertical, con el significado: la suelta inserta aquí.

Hacer el cursor preciso destapó un bug que el resaltado relleno había escondido. move_tab insertaba directamente en el índice de destino, pero quitar antes la pestaña arrastrada desplaza cada índice posterior una posición hacia abajo, así que un movimiento hacia la derecha aterrizaba una posición tarde y reordenar solo funcionaba hacia la izquierda. El arreglo son tres líneas y el comentario que lo explica es más largo que el código.

El final de la barra costó tres intentos. Una zona de suelta detrás del botón + funcionaba, pero empujaba el + lejos de la última pestaña y dejaba que el cursor de inserción apareciera más allá de él; encogerla y moverla delante del + arregló el cursor y dejó el hueco. La respuesta fue borrar la zona y convertir el propio + en el destino de «insertar al final».

THE STRIP tab pill an expanded group + before before the group inside it at the end a cursor, not a highlighted zone — the reader is told exactly where it lands

El último hueco era el principio de la barra: si un grupo está en la posición cero no hay ninguna ficha delante de la que soltar, porque soltar sobre la primera pestaña del grupo hace que la pestaña arrastrada se una al grupo. Así que la píldora también se convirtió en destino de suelta — una pestaña soltada sobre ella aterriza sin grupo, justo antes del grupo.

Qué no arregla la v0.2.0

Todo sigue corriendo en un solo proceso. La entrada del backlog sobre aislamiento de sitios nombra su propio disparador — que las pestañas entren en el shell — y ese disparador se activó con esta versión. No se hizo. Una página que tumbe a Servo se lleva por delante todas las pestañas, y todos los orígenes comparten un único espacio de direcciones.

El panel de DevTools es una pestaña de un inspector de verdad: solo Elements, sin consola, sin red, sin depurador. Pinta como mucho 150 filas de árbol por frame y resume el resto, porque el shell entero se vuelve a renderizar en cada frame mientras Servo hace tick y un árbol sin límite bloquearía la interfaz a 60fps. El autodesplegado en anchura que se escribió para él está marcado como código muerto hasta que el árbol se virtualice.

La interfaz del navegador está hardcodeada en español. No configurada en español — hardcodeada, como literales de cadena dentro de las funciones de render. No hay capa de localización ni tabla de cadenas contra la que traducir, y las costuras ya se ven: una pestaña sin título pone «New Tab» cuando la etiqueta viene del motor y «nueva pestaña» cuando viene del renderizador de la barra, en la misma ventana.

La anchura máxima de la barra vertical se fijó en 460 px y se apretó a 320 esa misma mañana, que es la clase de número que se elige dos veces porque nadie ha decidido para qué sirve. Nada de eso es una lista de cosas para las que nos quedamos sin tiempo. Es lo que salió.

active WebView framebuffer background WebView framebuffer discarded dropped — title and URL kept GPUI dma-buf one frame drawn, whatever the tab count a sleeping tab gives its GPU memory back; reopening it reloads the page
Cada pestaña es un WebView vivo con su propio framebuffer; solo el activo llega al compositor, y una pestaña descartada devuelve los dos.