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
docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.mdVista generada desde
docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | PROPOSED |
| Fecha | 2026-09-28 |
| Fichero | docs/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.
Leído de docs/decisions/dec-0108-r08-produccion-fuera-del-loop-quic.md, el fichero canónico.
AskUserQuestion a waxin. Mientras no se
lockee sólo se avanza en lo reversible, que es la medición y el escenario.br-design, rama w2/br-design).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).docs/track/byte-runtime/m4/evidence/r08-hol-measurement-2026-09-28.md (+ los
JSON crudos de r08-hol-2026-09-28/).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 caliente | 256 MiB frío | 64 MiB caliente | |
|---|---|---|---|
Loop bloqueado por una petición (ttfb de la grande) | 270 ms | 874 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ón | 4,2 s | 5,2 s | 1,2 s |
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:
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.
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.
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).
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.
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.
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:
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.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: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.
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.
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.
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.
sendCapacity/notifyWritable), que
es también la dependencia de R-09/R-12 (m4#05/#07). A.1 no depende y puede ir antes.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.send_order) ni la unificación con el
GlobalByteScheduler de r26.