wandres.dev
LA ARQUITECTURA DE LA PESTAÑA · el patrón completo

Depurar esta arquitectura: el estado repartido en tres contextos

Cuesta tanto porque ningún punto de observación ve el sistema entero, así que la instrumentación tiene que fabricar la vista global que las herramientas del navegador no te dan.

⏱ 18 min

Llega el momento en que algo va mal y descubres que la habilidad que has usado durante años para arreglar aplicaciones ya no funciona. No es una impresión: es una consecuencia estructural del patrón que acabas de construir. Depurar consiste en observar el estado y retroceder hasta la causa, y esa operación presupone algo que aquí ha dejado de ser cierto —que exista un punto desde el que se vea todo el estado—. En esta arquitectura el estado está repartido entre el hilo de la interfaz, el Worker que posee la base, el disco que sobrevive a ambos y, si el usuario ha abierto varias pestañas, otros tantos hilos de interfaz con sus propias proyecciones. Ninguna consola del navegador ve ese conjunto. La conclusión de esta última lección del bloque es incómoda pero liberadora: el problema no es de depuración sino de observabilidad, y la vista global que te falta tienes que fabricarla tú, por adelantado y a propósito.

🎯 Al terminar esta lección sabrás
  • Entender por qué las herramientas del navegador no cubren esta arquitectura y qué falta exactamente.
  • Instrumentar con identificadores de traza, identidad de pestaña y un reloj comparable entre contextos.
  • Reconocer las ocho trampas que atrapan a todo el mundo la primera vez.
  • Convertir un fallo irrepetible en un fallo reproducible mediante registro y reproducción de mensajes.

Por qué las herramientas no te ayudan

Las herramientas del navegador están construidas sobre una suposición razonable para casi todo el software web y falsa para el tuyo: que hay un contexto principal y que lo demás son accesorios. De ahí salen las cinco carencias concretas que vas a encontrarte.

La consola está segmentada: cada Worker tiene la suya y hay que seleccionarla explícitamente, así que el registro que buscas puede estar delante de ti y no estar mirándolo. No existe una línea de tiempo unificada que entrelace lo que ocurrió en el hilo principal con lo que ocurrió en el Worker, de modo que la relación causal entre un clic y una consulta hay que reconstruirla a mano. Un punto de interrupción detiene un solo contexto, y detener el hilo principal mientras el Worker sigue trabajando produce un sistema que ya no es el que fallaba. El almacén privado del origen no es inspeccionable de forma cómoda ni uniforme: no hay ningún diálogo del sistema que lo abra y en algunos navegadores solo llegas a él escribiendo código que lo recorra. Y las pestañas son invisibles entre sí: no hay ninguna vista que te muestre las cuatro instancias abiertas de tu aplicación, cuál es la líder y en qué versión va cada una.

Sumadas, esas carencias explican por qué los fallos de esta arquitectura tienen un sabor característico. No se manifiestan como excepciones con su pila de llamadas: se manifiestan como ausencias. Algo que debería haber llegado no llega, algo que debería haberse actualizado no se actualiza, una promesa que debería haberse resuelto sigue esperando. Y una ausencia no deja rastro por sí misma, hay que haber preparado el terreno para que se note.

Hay además una carencia conceptual más profunda que las cinco anteriores y que conviene nombrar. Una pila de llamadas es un registro del pasado causal de la ejecución actual: dice quién llamó a quién hasta llegar aquí. En cuanto una operación cruza una frontera de mensajes, esa cadena se corta, y la pila que ves dentro del Worker empieza en su manejador de mensajes, sin ninguna referencia a la interacción del usuario que la originó tres saltos antes. La información no está oculta ni comprimida: no existe, porque nadie la escribió. Reconstruirla es literalmente el trabajo de la sección siguiente, y es la razón de que la instrumentación aquí no sea un lujo de equipos maduros sino un requisito para poder trabajar.

Instrumentar: identidad, tiempo y un registro común

La instrumentación mínima que hace habitable esta arquitectura tiene tres piezas, y ninguna es opcional.

Un identificador de traza que atraviese todos los saltos. Cada operación iniciada por el usuario recibe un identificador que viaja en todos los mensajes que provoca, atraviesa la frontera, aparece en los registros del Worker, vuelve en la respuesta y se adjunta al aviso de cambio resultante. Con él, reconstruir la historia de una interacción se reduce a filtrar por una cadena. Sin él, es arqueología.

Una identidad estable de pestaña. Un identificador corto generado al arrancar y conservado mientras la pestaña viva permite distinguir en un registro compartido quién dijo qué. Es lo que convierte la frase de que la pestaña no se actualiza en la pregunta útil de cuál de las cuatro.

Un reloj comparable entre contextos. Aquí hay un detalle técnico que estropea silenciosamente muchas mediciones: el reloj monótono de alta resolución cuenta desde el instante en que se creó cada contexto, y un Worker se crea después que su página. Comparar directamente esas marcas entre el hilo principal y el Worker produce diferencias inventadas, a veces negativas. Para obtener instantes comparables hay que sumar a cada marca el origen temporal de su propio contexto.

// Un instante comparable entre contextos distintos
const ahora = () => performance.timeOrigin + performance.now();

function trazar(evento, traza, extra) {
  const linea = { t: ahora(), ctx: CONTEXTO, pestana: ID_PESTANA, traza, evento, ...extra };
  anillo.push(linea);                 // buffer circular local, siempre
  if (DEPURACION) canalTrazas.postMessage(linea);   // vista unificada, opcional
}

Dos decisiones de ese fragmento merecen justificación. El búfer circular guarda siempre los últimos cientos de eventos en memoria y puede volcarse a petición: es la respuesta al hecho de que el fallo casi siempre ocurre antes de que a alguien se le ocurra abrir las herramientas, y un registro que solo existe cuando estás mirando no sirve para nada. Y el canal de trazas separado permite que una pestaña recoja los eventos de todos los contextos y los ordene por instante, fabricando la línea de tiempo unificada que las herramientas no te dan.

sequenceDiagram
participant U as Interfaz pestana A
participant L as Worker lider
participant B as Interfaz pestana B
U->>L: peticion traza a91 crear nota
L->>L: transaccion confirmada version 42
L-->>U: respuesta traza a91
L->>B: aviso version 42 traza a91
L->>U: aviso version 42 traza a91
Note over U,B: la misma traza aparece en cuatro registros distintos
💡
Cuatro contadores valen más que cien registros

Antes de instrumentar nada sofisticado, expón cuatro números en algún rincón visible durante el desarrollo: peticiones en vuelo, profundidad de la cola del Worker, número de secuencia de la última versión aplicada por esta pestaña y cruces de frontera desde el último pintado. Casi todas las patologías de esta arquitectura se ven en esos cuatro valores antes de manifestarse como un fallo: las peticiones en vuelo que solo suben delatan promesas que nadie resuelve, la cola que crece delata un productor más rápido que el consumidor, una versión estancada delata avisos perdidos y los cruces disparados delatan una consulta dentro de un bucle.

ℹ️
Registra transiciones, no valores

La tentación al instrumentar es volcar el contenido de los mensajes, y produce un registro voluminoso e inútil. Lo que necesitas para diagnosticar esta arquitectura no son los datos sino los eventos de frontera: petición emitida, petición recibida, transacción confirmada, aviso difundido, aviso aplicado, promesa resuelta. Seis eventos con su traza y su instante reconstruyen la historia completa de cualquier operación y ocupan una fracción. Los argumentos, si acaso, resumidos a su tamaño y a las claves afectadas, nunca al contenido, que además es dato del usuario y no debe salir de su dispositivo por un canal de diagnóstico.

Las trampas más comunes

  • La promesa que nunca se rechaza. Es la número uno con diferencia. El Worker muere por presión de memoria y las peticiones en vuelo se quedan pendientes para siempre. No hay excepción, no hay error, no hay nada: la aplicación se queda quieta. Cúbrelo con los manejadores de fallo del Worker que rechazan en bloque, y si no lo has hecho, sospéchalo el primero cada vez que algo se cuelgue sin mensaje.
  • La consola equivocada. Tu registro está en el Worker y estás leyendo la del documento. Suena tonto y consume tardes enteras; el prefijo de contexto en cada línea lo elimina de raíz.
  • El punto de interrupción que cambia el sistema. Detener el hilo principal no detiene el Worker: los mensajes se siguen encolando y al reanudar llegan todos de golpe, en un orden que jamás se habría dado. Para problemas de sincronización, el registro es superior al depurador porque no perturba lo que observa.
  • El desarrollo con recarga en caliente. El recargador puede reemplazar tu módulo sin terminar el Worker anterior, dejando dos Workers vivos que compiten por el mismo bloqueo. Produce fallos que no existen en producción y, peor, oculta otros que sí. Termina el Worker explícitamente al descartar el módulo.
  • La pestaña con código viejo. Tras un despliegue conviven pestañas de dos versiones hablando el mismo canal. Sin versión de protocolo acordada, los síntomas son arbitrarios y no se reproducen jamás en local.
  • Recargar la líder crea dos líderes por un instante. Al recargar, la pestaña libera el bloqueo, otra lo toma, y la recargada vuelve pidiéndolo. Durante ese solapamiento puede haber dos intentos de abrir el fichero, y el que pierde debe degradar a seguidora en lugar de fallar.
  • No haber datos y no haberlos todavía. Si tu proyección representa ambas situaciones con el mismo valor vacío, la interfaz mostrará el mensaje de lista vacía durante el arranque y confundirá al usuario y luego a ti. Son tres estados distintos: sin cargar, cargando y cargado vacío.
  • El entorno que no reproduce. Una ventana privada o un perfil distinto tienen otro almacén: ahí tu fallo dependiente de datos nunca aparecerá. Y las propias herramientas abiertas alteran el comportamiento, empezando por desactivar la caché de retroceso, que es justo donde vive uno de tus casos límite.
⚠️
No confundas un fallo del canal con un fallo de la base

La primera pregunta ante cualquier síntoma debe ser en qué frontera se ha perdido la información, y solo después qué hizo mal el motor. Un dato que no aparece puede ser una escritura que no se ejecutó, una escritura ejecutada cuyo aviso no se emitió, un aviso emitido que ningún receptor escuchaba, un aviso recibido que se descartó por versión, o una proyección que se actualizó sin que el componente se volviera a pintar. Son cinco causas con cinco arreglos completamente distintos y un único síntoma. Recorrerlas en orden, comprobando en cada frontera si el mensaje pasó, encuentra la causa en minutos; empezar por el SQL encuentra la causa en horas y a veces por casualidad.

Reproducir lo que solo pasa una vez

La técnica que más rendimiento da en esta arquitectura no es un depurador mejor: es aprovechar una propiedad que el patrón te ha regalado sin pedirla. Si el Worker es el único escritor, si atiende los mensajes en orden y sin ceder el control, y si todo su estado proviene del fichero más la secuencia de mensajes recibidos, entonces el Worker es una máquina determinista. Y una máquina determinista se puede reproducir.

Basta con registrar la secuencia de mensajes de entrada, con su orden y sus argumentos, para poder repetirla después contra un Worker limpio y una copia del fichero. El fallo intermitente que se daba una vez cada doscientas sesiones pasa a darse todas las veces que quieras, en tu equipo, con puntos de interrupción y sin prisa. Es la diferencia entre investigar y adivinar.

// Grabar la entrada del worker durante una sesion de diagnostico
const cinta = [];
self.addEventListener('message', ({ data }) => {
  if (GRABANDO) cinta.push({ n: cinta.length, data: structuredClone(data) });
});

// Reproducir contra un worker limpio y una copia del fichero
async function reproducir(cinta) {
  for (const paso of cinta) {
    worker.postMessage(paso.data);
    await unaVuelta();            // respetar el orden, un mensaje por turno
  }
}

La técnica tiene un límite honesto que hay que declarar: reproduce el comportamiento del escritor, no el de la interfaz ni el del reparto temporal entre pestañas. Un fallo que depende de que dos pestañas hicieran algo a la vez no se reproduce grabando una sola cinta, sino grabando el orden en que el escritor las atendió, que es precisamente el dato que la cinta contiene. Esa es la razón de grabar en el lado del Worker y no en el de cada cliente: el Worker es el único punto del sistema donde todas las historias concurrentes se han fundido ya en una sola secuencia, y esa secuencia es la verdad de lo que ocurrió.

Hay tres condiciones que rompen el determinismo y que conviene vigilar porque son fáciles de introducir sin darse cuenta: las marcas de tiempo tomadas dentro del Worker, los valores aleatorios generados ahí dentro y cualquier lectura de estado externo que no llegue por el canal. Las tres se resuelven de la misma forma, que además mejora el diseño por otros motivos: inyectar el tiempo y la aleatoriedad como argumentos de la operación en lugar de obtenerlos dentro. Un Worker cuya única entrada son los mensajes es un Worker reproducible, comprobable y razonable.

🎞️

Graba la cinta en producción

Un búfer circular con los últimos mensajes, volcado junto al informe de fallo, convierte una incidencia indescifrable en un caso reproducible sin pedirle nada al usuario.

🧪

Prueba con dos pestañas siempre

Toda comprobación de esta arquitectura que se ejecute con una sola pestaña está midiendo el caso fácil. El relevo del líder solo aparece con dos.

🎲

Inyecta el fallo a propósito

Termina el Worker, retrasa las respuestas, descarta un aviso, agota la cuota. Lo que no se ensaya no funciona, y estos cuatro son los que van a ocurrir.

📏

Vigila los cuatro números

Peticiones en vuelo, cola, versión aplicada y cruces por pintado. Publicados en el desarrollo diario, delatan la mayoría de las regresiones antes de que nadie las note.

Aquí se cierra el bloque: has construido un sistema distribuido y su límite es lo que viene después

Merece la pena mirar hacia atrás desde aquí, porque este es el final de un recorrido largo y su conclusión no es la que parecía al empezar. En el nivel 6 la pregunta era dónde guardar unos datos en el navegador, y sonaba a elección de biblioteca. Trece niveles después, lo que tienes delante es un sistema con un recurso exclusivo, un proceso propietario elegido por votación implícita, varios clientes sin autoridad que mantienen réplicas parciales, un protocolo de peticiones correlacionadas, un canal de notificaciones con números de secuencia y detección de huecos, una máquina de estados de arranque, una ruta de recuperación por transferencia completa y un problema de observabilidad que exige trazas distribuidas. Cada una de esas piezas tiene un nombre propio en la literatura de sistemas distribuidos y ninguna se puede quitar, porque no las puso nadie por gusto: las impuso, una por una, la cadena de restricciones que empezó con una línea de una especificación sobre dónde puede vivir un manejador síncrono. Esa es la primera moraleja del bloque, y no es pequeña: el almacenamiento local serio no es una capa de persistencia, es un sistema distribuido en miniatura, y quien lo trata como lo primero acaba descubriendo lo segundo en producción. La segunda moraleja es la que abre lo que viene. Todo lo que has construido descansa sobre un privilegio del que no eras consciente hasta ahora: existe un escritor único, y por eso existe un orden total de las escrituras, y por eso un simple contador incremental basta para versionar el estado, detectar lo que falta y decidir quién va por delante. Ese privilegio se sostiene porque todas las partes viven en la misma máquina, comparten reloj, comparten disco y pueden acordar un líder mediante un bloqueo que el navegador arbitra y libera con fiabilidad. Quítale una sola de esas condiciones —dos dispositivos del mismo usuario, uno de ellos apagado durante una semana, sin ningún árbitro que pueda decidir cuál escribió antes— y todo el edificio se desmonta en su punto exacto: no hay siguiente número porque no hay un único siguiente, el orden total se degrada en un orden parcial y la pregunta de qué versión gana deja de tener respuesta. Ahí termina lo que puede resolver la coordinación y empieza lo que solo puede resolver la estructura de los datos. Los niveles siguientes no son un tema nuevo: son la continuación exacta de esta frase, y responden a la única pregunta que este bloque ha dejado abierta —cómo se versiona el estado cuando nadie tiene autoridad para asignar el número—. La respuesta se llama tiempo lógico, causalidad y convergencia algebraica, y ya sabes por qué hacen falta.

⚔️ Fabrica tu vista global
  1. Implementa el identificador de traza y propágalo por los cuatro saltos: petición, ejecución, respuesta y aviso. Filtra por una traza real y lee su historia completa.
  2. Corrige tus mediciones sumando el origen temporal de cada contexto y comprueba si tus cifras anteriores estaban desplazadas.
  3. Monta el búfer circular en ambos contextos con volcado a petición. Provoca un fallo, ciérralo todo y recupera la historia después.
  4. Publica los cuatro contadores en el desarrollo diario y deja pasar una semana. Anota qué regresión detectaste con ellos antes que con nada más.
  5. Graba la cinta de mensajes de una sesión y reprodúcela contra un Worker limpio. Verifica que el resultado es idéntico y elimina las fuentes de indeterminismo que aparezcan.
  6. Recorre las ocho trampas de esta lección y comprueba cuáles reproduce hoy tu aplicación. Escribe una prueba para cada una que hayas sufrido.