wandres.dev
OPFS II · la restricción que lo cambia todo

Exclusividad: un bloqueo, un fichero, muchas pestañas

El manejador de acceso síncrono toma un bloqueo exclusivo sobre el fichero mientras vive: qué ocurre cuando el usuario abre una segunda pestaña, por qué el fallo es inmediato y determinista, y por qué el nivel 18 existe.

⏱ 16 min

Hasta aquí la restricción parecía cara pero manejable: un worker, una capa de mensajes, algo de disciplina. Ahora llega la parte que rompe suposiciones, y llega por donde nadie mira, que es el usuario haciendo algo perfectamente normal: pulsar el atajo de duplicar pestaña. El manejador de acceso síncrono no se limita a existir dentro de un Worker; mientras existe, se queda el fichero para él solo. Y como el navegador no es un sistema operativo que arbitre entre procesos, el árbitro tienes que escribirlo tú.

🎯 Al terminar esta lección sabrás
  • Entender el alcance exacto del bloqueo: por entrada de fichero y para todo el origen.
  • Reproducir el fallo de la segunda pestaña y reconocer el error que lo delata.
  • Conocer el bloqueo fantasma: cierres sucios, pestañas congeladas y manejadores que no se liberan.
  • Situar las salidas reales —líder único, proxy entre pestañas, elección de VFS— y su coste.

Un bloqueo, un fichero, un origen

Cuando createSyncAccessHandle tiene éxito, adquiere un bloqueo exclusivo sobre esa entrada del sistema de ficheros y lo mantiene hasta que llames a close. Mientras tanto, cualquier otro intento de abrir un manejador síncrono sobre el mismo fichero falla: la promesa se rechaza con un DOMException de tipo NoModificationAllowedError.

Tres precisiones que cambian el alcance del problema:

  • El bloqueo es por entrada, no por directorio ni por origen entero. Otros ficheros siguen disponibles.
  • Su ámbito es todo el origen. No importa qué worker, qué pestaña ni qué ventana lo pidió: OPFS es un único almacén compartido por origen, y el bloqueo se ve desde todas partes.
  • No hay cola de espera. La llamada no aguarda a que el otro termine: falla, y falla enseguida. Si quieres esperar, el bucle de reintentos lo escribes tú.
// Pestana A, worker de datos
const h = await fichero.createSyncAccessHandle(); // OK, bloqueo tomado

// Pestana B, su propio worker, mismo origen, mismo fichero
try {
  const h2 = await fichero.createSyncAccessHandle();
} catch (e) {
  console.error(e.name); // NoModificationAllowedError
}

Y no hablamos de un fichero, sino de los que el motor decida abrir. Un SQLite tradicional acompaña la base con su fichero de journal o de registro de escritura anticipada, y la capa VFS abre un manejador para cada uno. La contención se multiplica por ahí, y con ella los estados intermedios feos: una pestaña que consiguió el bloqueo de la base pero no el del journal está en una situación peor que una que no consiguió nada.

flowchart TB
A[Pestana A worker] -->|createSyncAccessHandle| F[Entrada datos sqlite3]
B[Pestana B worker] -->|createSyncAccessHandle| F
F --> OK[A obtiene el bloqueo exclusivo]
F --> ERR[B recibe NoModificationAllowedError]
ERR --> Q[Que hace B ahora]
style OK fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111b
style Q fill:#f9e2af,color:#11111b

Lo que ve el usuario al abrir la segunda pestaña

La consecuencia práctica es que la exclusividad no se manifiesta como un problema de concurrencia clásico —dos escrituras que se pisan, una fila perdida— sino como un problema de arranque. No hay corrupción, no hay carrera silenciosa, no hay dato incorrecto: hay una pestaña que sencillamente no puede empezar. Es, dentro de lo malo, la mejor forma de fallar que podía tocarte, y conviene apreciarla antes de quejarse.

Sin código defensivo, la segunda pestaña no falla al escribir: falla al arrancar. El worker intenta abrir la base, no puede, y la aplicación se queda en su pantalla de carga o revienta con un error que el usuario no puede interpretar. La primera pestaña, entretanto, funciona perfectamente, lo que convierte el informe de fallo en un misterio: “a veces la app no carga”.

Y no es un caso raro. Es el comportamiento habitual de cualquiera:

🔁

Duplicar la pestaña

Un atajo de teclado que la gente usa a diario para comparar dos vistas de la misma aplicación. Instantáneamente tienes dos workers peleando por el mismo fichero.

🔗

Abrir un enlace en pestaña nueva

Cualquier enlace interno abierto con el botón central o con la tecla de comando crea una segunda instancia completa de tu aplicación en el mismo origen.

♻️

Restaurar la sesión

Al reabrir el navegador se restauran de golpe las pestañas de la sesión anterior. Si el usuario tenía tres de tu aplicación, arrancan tres workers casi a la vez y compiten en carrera.

🪟

Ventanas y perfiles

Otra ventana, una ventana de aplicación instalada o una vista emergente comparten el mismo origen. Solo el modo incógnito y otro perfil tienen un almacén distinto.

⚠️
Reintentar en bucle no es una solución, es un consumo de batería

El primer parche que se le ocurre a todo el mundo es reintentar con espera creciente hasta que el otro suelte. Falla por dos motivos. El primero es que la otra pestaña no va a soltar nunca: mantiene el manejador abierto durante toda su sesión, que es justo lo que debe hacer. El segundo es que si de verdad soltara, tendrías dos motores escribiendo alternativamente sobre el mismo fichero con cachés de páginas independientes y desactualizadas, que es una receta directa de corrupción. La exclusividad no es el obstáculo: es lo que te protege de eso.

El bloqueo fantasma

Peor que el conflicto visible es el conflicto que no deberías tener. El manejador se libera cuando llamas a close o cuando se destruye el ámbito global del worker. Ninguna de las dos cosas ocurre siempre cuando crees.

  • Cierre sucio. Si la pestaña se cierra de golpe, si el proceso de renderizado cae o si el sistema mata la aplicación por presión de memoria —cosa habitual en un móvil—, no hay ninguna garantía de que tu close llegue a ejecutarse. El navegador acaba liberando el manejador al destruir el worker, pero el instante exacto no está bajo tu control ni es inmediato.
  • Pestaña congelada. Una página guardada en la caché de retroceso y avance no está cerrada: está suspendida, viva y con su worker intacto. Para el usuario esa pestaña “ya no está”; para OPFS sigue sosteniendo el bloqueo. Abrir la aplicación de nuevo desde otra pestaña puede fallar por culpa de una que el usuario cree haber abandonado.
  • Descarga no fiable. beforeunload no está garantizado y unload es peor. pagehide con visibilitychange es lo más cercano a un aviso fiable, y aun así no cubre una muerte abrupta del proceso.
// Liberar lo antes posible, sabiendo que no siempre llegara
addEventListener('pagehide', () => {
  worker.postMessage({ tipo: 'cerrar' }); // el worker hace close
});

// Y en el worker, ser tolerante al arrancar
async function abrirConEspera(fichero, intentos = 5) {
  for (let i = 0; i < intentos; i++) {
    try {
      return await fichero.createSyncAccessHandle();
    } catch (e) {
      if (e.name !== 'NoModificationAllowedError') throw e;
      await new Promise((r) => setTimeout(r, 100 * 2 ** i));
    }
  }
  throw new Error('OTRA_PESTANA_TIENE_LA_BASE');
}

Repara en el detalle del throw cuando el error no es el esperado: reintentar a ciegas ante cualquier fallo es cómo se entierra un problema de cuota o un fichero corrupto bajo cinco segundos de espera y un mensaje equivocado. La espera creciente solo tiene sentido para NoModificationAllowedError, que es el único error que el tiempo puede arreglar.

ℹ️
El nombre del error es tu mejor diagnóstico

Vale la pena aprenderse los tres que vas a ver en esta ruta y no confundirlos nunca: InvalidStateError significa que estás en el contexto equivocado, casi siempre el hilo principal, y ninguna espera lo arregla; NoModificationAllowedError significa que otro manejador tiene el fichero, y puede o no arreglarse solo; QuotaExceededError significa que te has quedado sin espacio, y es materia del nivel siguiente. Registrar el name en tus informes de fallo, y no solo el mensaje, convierte una incidencia indescifrable en una causa concreta.

Ese bucle no resuelve nada por sí solo, pero sirve para lo único que puede servir: distinguir un fantasma que se disipa en medio segundo de un conflicto real con otra pestaña activa, y entonces contarle al usuario algo verdadero en lugar de dejarlo mirando un spinner.

Las salidas, y por qué ninguna es gratis

Todas las soluciones reales comparten la misma forma: un solo escritor, elegido de forma explícita, y el resto de pestañas hablando con él. Que ese escritor sea único no es una limitación que aceptes a regañadientes: es lo que permite que una sola caché de páginas sea la verdad, y por tanto la condición de que la base no se corrompa.

  • Elección de líder con la API de bloqueos. navigator.locks.request con un nombre y modo exclusivo te da algo que OPFS no te da: liberación automática y fiable cuando la pestaña muere, gestionada por el navegador. El líder abre la base; los demás esperan su turno en la cola del bloqueo.
  • Proxy entre pestañas. Las pestañas seguidoras no tocan el fichero: envían sus consultas al líder por un canal de difusión y reciben las respuestas y las notificaciones de cambio por el mismo camino. Ahora tu RPC tiene dos saltos en lugar de uno.
  • Worker compartido. Donde está disponible resuelve el problema de raíz, con una única instancia para todas las pestañas del origen, pero su soporte no es uniforme y no puedes construir sobre él sin un plan alternativo.
  • Elegir el VFS con criterio. El VFS por defecto de la compilación oficial de SQLite coordina varias conexiones a cambio de un camino más lento; el VFS de reserva de manejadores es notablemente más rápido porque los toma por adelantado y los conserva, y a cambio no admite instancias simultáneas. Esa elección es, literalmente, elegir velocidad o multipestaña.
// El esqueleto de la eleccion de lider
navigator.locks.request('db-lider', { mode: 'exclusive' }, async () => {
  await arrancarWorkerDeDatos();   // solo el lider abre OPFS
  atenderPeticionesDeOtrasPestanas();
  await new Promise(() => {});     // retener el bloqueo mientras viva
});

La promesa que nunca resuelve no es un truco sucio: el bloqueo se sostiene mientras dure el callback, así que retenerlo indefinidamente es precisamente cómo se declara “yo soy el líder hasta que muera”. Y cuando esa pestaña muere, el navegador libera el bloqueo por su cuenta y otra de la cola entra sin que nadie tenga que detectar nada. Esa liberación garantizada es justo la propiedad que el manejador de OPFS no te ofrece, y la razón por la que se combinan las dos APIs en lugar de usar solo una.

Te dieron un disco, pero no te dieron un sistema operativo

Aquí está la lección profunda del nivel, y no trata de OPFS sino de qué has aceptado al pedir un motor de base de datos de verdad dentro de un navegador. SQLite, y con él prácticamente cualquier base embebida, se diseñó bajo una suposición que nunca hizo falta enunciar porque el sistema operativo la sostenía: hay procesos, hay un núcleo que arbitra entre ellos, y ese núcleo ofrece bloqueos de fichero con una propiedad que damos por descontada —cuando un proceso muere, el núcleo libera sus bloqueos, siempre, sin excepciones y sin cooperación del programa. Sobre esa red de seguridad se construyó cuarenta años de software. El navegador te ha dado ahora la mitad del trato: un sistema de ficheros real, acceso posicional, durabilidad, exclusividad. Y no te ha dado la otra mitad. Las pestañas se parecen a procesos, compiten como procesos y mueren como procesos, pero no hay un núcleo que las arbitre por ti; tu aplicación tiene que elegir a su propio líder, detectar a sus propios muertos, reconducir a sus propios seguidores y decidir qué hacer cuando el líder desaparece a mitad de una transacción. Eso ya no es programación de interfaces: es coordinación distribuida, con elección de líder, detección de fallos y particiones lógicas dentro de un mismo navegador. Y por eso este track dedica un nivel entero, el 18, a Web Locks y al liderazgo entre pestañas: no es un apéndice ni una optimización tardía, es el sistema operativo que te falta, y tienes que escribirlo tú. Quien descubre esto al final del proyecto reescribe la capa de datos; quien lo sabe desde la lección 4 la diseña bien a la primera.

⚔️ Provoca el conflicto a propósito
  1. Abre tu aplicación en dos pestañas y captura el error de la segunda. Anota el name exacto y en qué punto del arranque aparece.
  2. Cierra la primera pestaña de golpe desde el gestor de tareas del navegador y mide cuánto tarda la segunda en poder abrir el fichero.
  3. Navega hacia atrás y hacia delante para meter una pestaña en la caché de retroceso y comprueba si sigue reteniendo el bloqueo. Documenta el resultado en tu navegador.
  4. Implementa la apertura con espera creciente y haz que, tras agotar los intentos, la interfaz muestre un mensaje honesto en lugar de un error genérico.
  5. Monta la elección de líder con navigator.locks y comprueba que, al cerrar la pestaña líder, otra toma el relevo y abre la base sin intervención del usuario.