dec-0108

Vista generada de dec-0108: R-08: la producción de bytes sale del hilo del loop QUIC y avanza por capacidad de envío

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoPROPOSED
Fecha2026-09-28
Ficherodocs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md

Por qué importa (del frontmatter del ADR):

Decide si el byte path de styx (WT hoy, FETCH MoQT y H3 después) sigue produciendo rangos de forma síncrona dentro del callback del event loop QUIC (serveRangeScheduled, native/zig/media-daemon/transport/quiczig_transport.zig:793) o pasa a un backend async con producción por ventanas según la capacidad de envío. Sin este ADR, el próximo executor que toque el serve no sabría que el bloqueo del loop ya se midió y supera el umbral T_lat de H-F5.

Nodos del roadmap que lo citan en refs: ninguno.

Páginas de la documentación que lo citan: ninguna todavía.

Texto del ADR

Leído de docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md, el fichero canónico.

dec-0108 — R-08: la producción de bytes sale del hilo del loop QUIC y avanza por capacidad de envío

  • Fecha: 2026-09-28
  • Estado: PROPOSED. No es un lock: requiere AskUserQuestion a waxin. Mientras no se lockee sólo se avanza en lo reversible, que es la medición y el escenario.
  • Propone: builder-dataplane (lane br-design, rama w2/br-design).
  • Cita: r16 (mandato frontier), r44 (Candidate H, condiciones #1/#4, gauntlet H-F5 con T_lat), dec-0103 (zkit), dec-0104 (colas VOD sin pérdida), r04/r20 §3.6 (cancel(requestId)), R-09/R-12 (m4#05/#07, que dependen de la migración a quic-zig upstream).
  • Evidencia: docs/track/byte-runtime/m4/evidence/r08-hol-measurement-2026-09-28.md (+ los JSON crudos de r08-hol-2026-09-28/).

Contexto

serveRangeScheduled atiende el rango pedido entero dentro de un solo onStreamData, en el hilo del loop QUIC. Para cada ventana de admisión de 16 MiB hace enqueue, drainStep (el LocalBackend hace pread síncrono) y sendStreamData, y repite hasta el final del rango antes de devolver el control (quiczig_transport.zig:934-1123). El Backend del scheduler (session/scheduler.zig:233) es un vtable con un único consumidor síncrono.

Medido el 2026-09-28 (33 repeticiones válidas de 33, ReleaseFast, loopback; la tabla completa está en la evidencia):

256 MiB caliente256 MiB frío64 MiB caliente
Loop bloqueado por una petición (ttfb de la grande)270 ms874 ms (máx. 1,43 s)78–85 ms
Delta p99 de lecturas de otra sesión vs baseline+147,5 ms+165,2 ms+75,0 ms
p95 de lecturas pequeñas en la misma sesión4,2 s5,2 s1,2 s

Umbral

T_lat = 50 ms, pre-registrado en la gauntlet H-F5 ("S2 (healthy sub): p99 delivery delta ≤ 50ms vs baseline", "F blocks only itself", doc 13 §H-F5) y citado por r44. En el byte path se concreta en tres umbrales:

  • U1: delta del p99 de lecturas de otra sesión respecto al baseline ≤ 50 ms.
  • U2: tiempo de loop bloqueado por una sola petición ≤ 50 ms.
  • U3: una lectura grande no retrasa las demás lecturas de su propia sesión más allá de U1.

Las tres se incumplen en todas las pasadas. Al ritmo medido de 1,05 ms/MiB en caliente, U2 cae con cualquier rango de más de ~47 MiB incluso con la page cache caliente. Con un backend no local (HTTP/S3/torrent, r26), el bloqueo pasa a ser la latencia de red del origen dentro del loop que atiende a todas las sesiones.

Opciones

A — Producción fuera del loop, empujada por la capacidad de envío (recomendada)

  1. El backend se vuelve async de verdad. Backend.startRead encola la lectura en un pool de workers del scheduler (o en io_uring cuando exista) y la completion vuelve al loop por un wakeup (xev.Async, el mismo mecanismo que midió T0-1 en Candidate H, con p99 del wake de ~10 µs). El loop no ejecuta nunca pread ni una petición de red al origen.

  2. Producción por ventanas según la capacidad de envío, no por el rango entero. Una ServeTask por stream pide la ventana N+1 sólo cuando el stream admite más bytes (sendCapacity/notifyWritable, que trae la migración a quic-zig upstream: es lo mismo que necesita R-09). El write_buffer de un stream queda acotado a O(ventana), no a O(rango).

  3. El envío intercala streams dentro de la conexión, con round-robin o con la urgencia del send_order de WebTransport. Sin esto, U3 no se cumple aunque se cumplan 1 y 2: los bytes ya encolados de la grande seguirían delante.

  4. cancel(requestId) sigue siendo por petición (r04/r20 §3.6): el cancel_flag que hoy se comprueba entre ventanas (quiczig_transport.zig:961) pasa a comprobarse antes de pedir cada ventana al pool, y una ventana en vuelo se descarta al completar.

  5. Contrato de cierre: MediaSession.close nunca libera source/scheduler con una lectura del pool pendiente. Hoy el quiesce de R-01 (serving_count, media_session.zig:102-108, espera en close() :252-268) cubre de WtHandler.resolveWiring (quiczig_transport.zig:621) al defer de serveRange (:591). Eso basta porque el serve es síncrono: cuando serveRange vuelve, no queda ninguna lectura viva. Con A, serveRange vuelve tras el primer startRead y la lectura sigue en un worker con punteros a source, al fd y al slot del producer. Si el contador se soltara en el mismo sitio, close() vería 0 y liberaría todo bajo el worker: el mismo UAF que R-01 cerró, desplazado al pool. Por eso:

    • La ServeTask retiene una unidad de in_flight (o de un refcount equivalente propio de la sesión) desde antes de su primer startRead hasta que se retira su última completion, y no hasta que vuelve el callback.
    • Cada lectura en vuelo se retira exactamente una vez, se entregue (el loop hace sendStreamData y libera el buffer) o se descarte (cancel, seek, RESET del stream, cierre de sesión o error del backend). El descarte también libera el buffer y cuenta como retirada. El que retira es quien gana un CAS sobre el estado de la lectura: el worker, si al acabar el pread la encuentra cancelada, o el loop, al procesar la completion.
    • close() sigue el orden de hoy: detachWiring (ninguna ServeTask nueva puede resolver la sesión), marca canceladas las ServeTask de la sesión y espera a in_flight == 0. Sólo entonces seekCancelPending, source.close() y los deinit. Esa espera corre en el hilo de IPC y nunca en el del loop ni con el mutex del MediaSessionManager tomado (el deadlock del follow-up de R-01, media_session.zig:519-580). Una completion ya encolada al loop se retira en su siguiente iteración, así que la espera está acotada por un paso del loop más la lectura en curso del worker.
    • Test TSAN obligatorio (lane test:zig:tsan): un backend de prueba con latch retiene una lectura en el pool; otro hilo llama a MediaSessionManager.closeSession. close() no puede volver antes de abrir el latch. Al abrirlo, la lectura se descarta, close() vuelve, TSAN da 0 reports y testing.allocator 0 leaks. Tiene dos variantes: el cierre llega con la lectura en el worker, y el cierre llega con la completion ya encolada al loop. Mutante en ROJO: soltar in_flight al volver serveRange, como hoy. Con él TSAN tiene que reportar la carrera sobre source, o testing.allocator el uso tras liberar.

Coste: cambia el contrato del Backend (de síncrono a completion), la ServeTask pasa a ser estado explícito en vez de pila del callback, y hacen falta tests de orden y cancelación con TSAN (el loop y el pool pasan a ser dos hilos). Reutiliza ReadScheduler, AdmissionController y la maquinaria de BackpressureWaker (media-core/sync/backpressure_waker.zig). No reimplementa nada de zkit (dec-0103). Si el pool necesita un watchdog de worker colgado, el que hay es zkit.HungWorkerWatchdog y se consume, no se copia. Ese consumo es además la condición #8 de r44.

B — Mature path: se lockea el serve síncrono y se añade un trigger de revisión

Se mantiene el diseño actual y se limita el daño con una ventana por callback: servir una sola ventana por onStreamData y reprogramar la siguiente con un timer de 0 ms. Se revisaría si una medición futura de este escenario supera U1/U2.

Coste bajo, pero no cumple el umbral. Con ventanas de 16 MiB el bloqueo por paso queda en unos 17 ms en caliente, pero en frío o con un backend remoto una sola ventana ya supera los 50 ms. U3 sigue sin cumplirse (sin capacidad de envío, cada ventana sigue encolándose entera). Y cementa la suposición de latencia local, que es justo lo que R-08 señala.

C — Un hilo de loop por sesión o por conexión

Aísla sesiones entre sí (U1), pero no U3, y multiplica hilos y estado de quic-zig. Choca con el modelo single-writer de Candidate H (r44: dominios de un solo escritor, B2 subsumido). Descartada.

Recomendación

A. Por r16 la frontier se prioriza salvo blocker concreto, reproducible y material, y aquí el coste material lo tiene el mature path: incumple un umbral pre-registrado en 33 de 33 repeticiones. B queda como fallback temporal sólo si la migración upstream se retrasa, aplicado como paso intermedio reversible (una ventana por callback) sin lock.

Consecuencias si se lockea A

  • Cierre (A.5): es condición de merge de A.1, no una mejora posterior. El primer commit que saque una lectura del hilo del loop entra con el test TSAN de cierre durante lectura en vuelo y su mutante.
  • Orden: A.2 depende de la migración a quic-zig upstream (sendCapacity/notifyWritable), que es también la dependencia de R-09/R-12 (m4#05/#07). A.1 no depende y puede ir antes.
  • Milestone de promoción del delivery plane (r44 #1/#4): A.1 es la mitad "pool" del bake-off H-B1 (lanes vs pool), del lado de la producción. La decisión de A fija la forma, y H-B1 se resuelve midiendo pool frente a lanes con este mismo escenario. La gauntlet (BR3) gatea el merge de esa rama y tiene que incluir U1/U2/U3 como oráculos.
  • FETCH MoQT y H3: tienen el mismo patrón (fetch_runtime lee con source.readAt en el loop de MoQT; H3 en h3z_transport.zig:295 y siguientes). Entran en el mismo contrato de backend y no se diseñan aparte.
  • Hallazgo colateral (evidencia §4): con otra conexión activa, una respuesta ya encolada avanza a unos 12 MiB/s y termina ~1,5 s después de que la otra conexión se calla. Es reparto del envío entre conexiones en quic-zig, no R-08. Hay que re-medirlo sobre la migración upstream antes de decidir nada.
  • Qué NO decide este ADR: el tamaño del pool, io_uring frente a hilos, la política de urgencia entre streams (eso es de la capa de decisión, Bun, vía send_order) ni la unificación con el GlobalByteScheduler de r26.