dec-0105

Vista generada de dec-0105: quic-zig upstream (fork = endel + capa 0.17) y MoQT draft-17 completo

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0105-quic-zig-upstream-y-moqt-draft-17.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0105-quic-zig-upstream-y-moqt-draft-17.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
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0105-quic-zig-upstream-y-moqt-draft-17.md

Enmienda a: dec-0057

Por qué importa (del frontmatter del ADR):

Gobierna el pin del submodule docs/references/quic-zig (qué lleva el fork y cómo se mueve), el pin de libxev que arrastra, y el wire MoQT de native/zig/media-daemon/moqt/ (moqt_types.DRAFT, SUPPORTED_DRAFTS, negotiateDraft). Sin este ADR un executor volvería a cargar parches de Styx en el fork (estrategia r36) o bajaría el wire a un subconjunto de draft-17 con códigos locales.

Nodos del roadmap que lo citan en refs: track/byte-runtime/m4

Páginas de la documentación que lo citan: Puesta en marcha de un nodo (implementado), zkit, spire y conduit (implementado)

Texto del ADR

Leído de docs/decisions/dec-0105-quic-zig-upstream-y-moqt-draft-17.md, el fichero canónico.

dec-0105 — quic-zig upstream (fork = endel + capa 0.17) y MoQT draft-17 completo

  • Fecha: 2026-09-28
  • Estado: la parte wire MoQT draft-17 (D1) está LOCKED por decisión de waxin. El resto (D2–D6) está PROPUESTO y pendiente de lock (AskUserQuestion).
  • Nodo: track/byte-runtime/m4 (tickets 05 R-09 y 07 R-12, reabre y cierra R-03).
  • Amends: dec-0057 / r43. Supersedes: la estrategia de parches de r36. Reafirma: dec-0016.
  • Evidencia: docs/track/byte-runtime/m4/evidence/quiczig-migration-2026-09-28/.

Contexto

Hasta esta rama el submodule apuntaba a cf68ecc: un fork MKS2508/quic-zig que había divergido de endel/quic-zig a base de parches propios (r36 lane 1, capa 0.17 de r43) y que ya no seguía a upstream. Dos P1 del nodo m4 estaban bloqueados por ese pin, no por Styx:

  • R-09 (ticket 05): no había bound en bytes para un FETCH. SendStream.write_buffer retenía el rango entero y no existía primitiva de capacidad (sendCapacity / notifyWritable) contra la que dosificar.
  • R-12 (ticket 07): un STOP_SENDING del peer sobre un uni stream local cerraba la conexión entera (processFrame sólo miraba el mapa bidi). Además, el control stream multiplexado no podía identificar qué request cancelar.

Upstream sí tiene esas primitivas: streamSendCapacity, getSendStreamStats, notifyWritable/onWritable, onStopSending, el reset correcto de uni streams, la compactación de write_buffer al ACK y el codec moq/ con draft-17/18. waxin decidió migrar a upstream y lockeó el wire draft-17 completo.

Decisión

D1 — Wire MoQT = draft-ietf-moq-transport-17 completo (LOCKED, waxin)

  • Un uni control stream por lado, con SETUP, y un bidi stream por request (§3.3). El stream identifica el request y lleva su respuesta.
  • La cancelación es cerrar ese stream: STOP_SENDING o RESET_STREAM (§5.1.1). El publisher resetea sus data streams con los códigos de §10.4.3.
  • FETCH:
    • La End Location es exclusiva (último + 1). El objeto 0 como fin significa el grupo entero.
    • FETCH_OK lleva end y end_of_track según §9.15.
    • La serialización de objetos es la de §10.4.4 (flags, objeto previo, marcadores de fin de rango).
  • Los errores de request usan los códigos del draft (DOES_NOT_EXIST, INVALID_RANGE, NOT_SUPPORTED, EXCESSIVE_LOAD, …). Sin códigos locales de Styx en el wire: el 0x1000 de la ronda anterior se eliminó.
  • Eliminados sin shim: moqt_decode.zig, LegacyFetch y el alias fetch_ok_alias, que ahora es fetch_ok_end en cliente y escenarios.

D2 — Negociación de draft preparada para draft-18 (PROPUESTA)

  • moqt_types.DRAFT = .draft_17 es el draft por defecto, y SUPPORTED_DRAFTS = &.{DRAFT} es la lista que se ofrece y acepta.
  • negotiateDraft elige sobre WT-Available-Protocols. Un peer sin cabecera recibe DRAFT. Una oferta sin ningún draft que hablemos cierra la sesión con VERSION_NEGOTIATION_FAILED, nunca se adivina.
  • Cada llamada al codec recibe el draft negociado de su conexión.
  • Hablar draft-18 = añadir .draft_18 a la lista, después de pasar la batería de conformidad y los smokes con un peer draft-18. No se activa en esta rama.

D3 — Política del fork quic-zig (PROPUESTA; amends dec-0057 y supersede la estrategia de r36)

  • El main de MKS2508/quic-zig es upstream endel/quic-zig más la capa mecánica 0.17 y, encima, los fixes de bugs de upstream que upstream aún no ha mergeado. Nada de Styx. La capa mecánica es: compat 0.17, PSSSignature.concatVerify, guard comptime de toolchain, pin de CI y pin de libxev.
  • Estado del remoto (git ls-remote, 2026-09-28): main y fix/pacer-wakeup apuntan los dos a 5066ee4, que es e6468ca (upstream 6d42346 más la capa) más el fix del pacer de D6. El pin del submodule en esta rama sigue en e6468ca: moverlo es la acción pendiente de D6. 2026-09-29: main = de0f676, que es 5066ee4 más fix/wt-poll-h3-swallow (git -C docs/references/quic-zig rev-parse origin/main). El pin está en de0f676 (2a5cf5b).
  • Los fixes de Styx no se acumulan en el fork (eso era la estrategia de r36: parches sin PR y sin salida). Un bug de upstream se arregla así:
    1. Test rojo y fix, en un commit, en una rama fix/* del fork.
    2. PR a upstream, con OK de waxin.
    3. El commit entra en main encima de la capa 0.17, y el pin de styx se mueve a ese main con OK explícito.
    4. Cuando upstream lo mergea, el siguiente rebase sobre endel/main lo absorbe y el commit deja de existir en el fork.
  • Por eso main sólo lleva, además de la capa 0.17, commits que van camino de upstream y tienen su rama fix/*. Eso es lo que distingue esta política de la de r36.
  • Actualizar el fork = rebase sobre endel/main, re-aplicar la capa mecánica y los fix/* que upstream no haya mergeado.
  • El pin anterior queda en la rama legacy/cf68ecc-pre-upstream, sólo como vara de medir.

D4 — Política de libxev (PROPUESTA)

  • quic-zig trae libxev de MKS2508/libxev f5d3811, que es el port 0.17 de MKS (c1e223b) más los parches del tag quic-zig-2026-09-26-2 de endel (#224 #240 #245 #246 #247).
  • Se genera con las reglas del fork de libxev de endel: cada cambio es una PR upstream, la rama combinada se regenera y nunca se edita, y el pin se mueve con zig fetch.
  • No se parchea libxev desde styx ni desde quic-zig.

D5 — quic-zig sigue siendo la implementación principal (reafirma dec-0016)

La migración mantiene quic-zig all-in detrás de las interfaces de transporte del daemon. No reabre la alternativa Rust.

D6 — Bug de upstream encontrado por el gate de closeSession (PROPUESTA, requiere OK)

  • Síntoma: en el WT byte path, algunas conexiones nuevas se quedan paradas 15-30 s con datos en cola. El cliente espera, closeSession nunca llega y el gate se cuelga.
    • Con conexiones una tras otra (p6-repro-sequential.txt): entre el 1 % y el 3 % en e6468ca, 0/30 en cf68ecc y 0/200 con el fix.
    • En el gate de closeSession (p6-closegate-*.json, 5 conexiones concurrentes): 4 de 600 en e6468ca y 0 de 600 con el fix. Aquí sale menos porque el tráfico de las otras conexiones despierta el loop.
    • closeSession en sí nunca se cuelga: 0 en los dos pines, máximo 11 ms.
  • Root cause (p6-pacer-stall-diag-trace.txt): Connection.send rechaza un paquete por pacing y nextTimeoutNs recalcula el retardo del pacer más tarde, al armar el timer. Si la espera ya terminó entre medias, no se programa ningún despertar. Con nada en vuelo, el siguiente timer es el keep-alive o el idle.
  • Fix: un commit sobre e6468ca, 5066ee4. Está publicado en MKS2508/quic-zig como rama fix/pacer-wakeup y como main (D3); comprobado con git ls-remote. La PR a endel no consta desde esta rama.
    • Qué cambia: send guarda el deadline absoluto del rechazo y nextTimeoutNs lo reporta. onTimeout lo descarta cuando pasa.
    • Test: rojo en e6468ca (p6-quiczig-pacer-test-RED-e6468ca.txt). Con el fix la suite del fork pasa 992/992 (p6-quiczig-suite-GREEN-fix.txt).
  • Pendiente: mover el pin del submodule a 5066ee4 y volver a pasar el gate de closeSession (tools/soak-harness/scenarios/wt-nconn-rss.ts, 20 runs) sobre ese pin. El VERDE que hay (p6-closegate-fix5066ee4.json) se midió con el fix en un árbol local, no con el pin movido. No se mueve sin OK.
  • Actualización 2026-09-28 (tanda 3, integrador): el pin ya está en 5066ee4, el main del fork. El gate se repitió sobre ese pin en el árbol integrado y sigue ROJO: 1/20 runs, 1 de 600 conexiones parada y 0 closeSession colgados (docs/track/byte-runtime/m4/evidence/closegate-pin-5066ee4-2026-09-28/). El cliente parado es de warm-up y se queda en la misma fase que los rojos de e6468ca: el servidor recibe el CONNECT y crea la sesión, pero el cliente nunca recibe WT session ready. El fix del pacer no cierra D6 del todo. Queda una parada residual de baja frecuencia sin root cause. Es el follow-up m4#11-FU-closegate-connect-stall, que sigue la política de D3 (test en rojo y fix en el fork). El bloqueo de abajo sigue en pie: sólo cambia el pin sobre el que se mide.
  • Actualización 2026-09-29 (lane closegate): VERDE sobre el pin de0f676.
    • Root cause de la parada residual: WebTransportConnection.poll() devolvía null después de tragarse un evento H3 sin significado, aunque H3 tuviera más eventos en cola. El 200 del CONNECT se quedaba sin leer (closegate-rootcause-2026-09-28/).
    • Fix de0f676, con test rojo/verde en el fork. Ya está en main de MKS2508/quic-zig siguiendo D3, y el submodule se movió a ese main (2a5cf5b).
    • Gate sobre el pin en el árbol integrado: 20/20 runs, 600/600 conexiones, 0 clientes parados, 0 closeSession colgados, close_ms_max 6 ms (docs/track/byte-runtime/m4/evidence/closegate-pin-de0f676-2026-09-29/).
    • Control del mutante onReadable con una sola iteración: HUNG 2/30 en 5066ee4 y 0/30 en de0f676.
    • Para medirlo hubo que arreglar tres cosas en styx; ninguna estaba en quic-zig:
      • el harness no presentaba el SCT que SEC-Z01 exige (403 en todas las conexiones);
      • el daemon moría por SIGPIPE cuando un cliente IPC colgaba antes de leer la respuesta (77c944f);
      • styx-wt-client no salía cuando el peer cerraba la sesión (c9f93bf).
    • Queda el paso 2 de D3, la PR de de0f676 (y de 5066ee4) a endel/quic-zig, con OK de waxin.
  • BLOQUEANTE para mergear esta rama a master (levantado 2026-09-29, ver la actualización de arriba): el gate de closeSession estaba ROJO sobre el pin e6468ca (p6-closegate-e6468ca.json). La condición era que el pin estuviera en un main del fork que contuviera 5066ee4 y que el gate pasara VERDE sobre ese pin. Se cumple con de0f676.
  • Nota: el server MoQT despierta cada 10 ms (poll_interval_ms), por eso allí el bug queda acotado a 10 ms y no se ve. El WT byte path no tiene poll y se queda parado.
  • Nota: el loop H3 tampoco tiene poll. Un smoke H3 en Debug sobre e6468ca agotó una vez el timeout en una transferencia de 1 MiB, y no se reprodujo en 10 repeticiones (h3-debug-loop.txt). Queda sin atribuir.
  • No se usa poll_interval_ms en el WT byte path como remedio: acotaría el síntoma y dejaría el bug debajo.

Segunda retención (no cubierta por dec-0049 / r37 / r38)

dec-0049 (arena → GPA), junto con r37/r38, cerró la retención G4: memoria del stack QUIC que la arena nunca liberaba. Con N=250 conexiones WT (r03-wt-n250-*-summary.json, ReleaseSafe, mismo daemon y escenario), el pin legacy muestra otra retención:

pinRssAnon tras warm-up → finalretención por conexión cerradabytes servidos
cf68ecc (legacy)78 176 → 180 592 KiB, lineal409,66 KiB5,26 GB
e6468ca (upstream)72 960 → 74 276 KiB, plana5,26 KiB10,78 GB
  • En el legacy, live_bytes (el allocator contado del daemon) sólo sube unos 7,9 MB mientras RssAnon sube unos 100 MiB. La retención está fuera de lo que el daemon cuenta.
  • Con upstream desaparece. No se ha buscado su causa en el legacy, porque el pin se retira: se deja medida, no explicada.
  • Dentro de UNA conexión MoQT, 4000 FETCH (8000 streams abiertos y cerrados) dejan RssAnon entre 6044 y 7176 KiB. Entre el 25 % y el 100 % de los FETCH varía −524 KiB: plana (r03-moqt-one-conn-r4000-summary.json).
  • Dentro de UNA conexión WT del byte path (90 s, rangos de 64 KiB, cada rango en su propio bidi stream; r03-wt-oneconn-*-summary.json):
    • Legacy: RssAnon sube de 11,6 a 488,8 MiB para unos 4 500 rangos, unos 108 KiB por rango. Es write_buffer retenido hasta cerrar la conexión: la observación de disposal de r36, que el pin legacy mantenía.
    • Upstream: RssAnon sube de 6,8 a 25,7 MiB para unos 50 800 rangos, unos 380 B por rango. Se libera al cerrar.
    • Instrumentado (r03-wt-oneconn-growth-attribution.txt): los mapas por stream de quic-zig y del handler no crecen. Lo que crece es ReadScheduler.reads, append-only, una entrada por rango. Es deuda documentada de la sesión (session/scheduler.zig, "scheduler-reads-monotonic-growth"), no de quic-zig.

Consecuencias

  • R-09 y R-12 se cierran con bound en bytes y cancel real (tickets 05 y 07). R-03 se reabre y se cierra con medición.
  • Mover el pin exige rebase sobre upstream más la capa 0.17, no cherry-picks de parches de Styx. Los bugs de upstream se reportan y se llevan en ramas del fork, no en main.
  • El gate de closeSession depende de D6. Salió rojo en e6468ca (4/600) y en 5066ee4 (1/600), y VERDE en de0f676 (0/600, 2026-09-29). El bloqueo de merge de D6 queda levantado con el pin en de0f676.
  • docs/decisions/README.md no se toca en esta rama, porque el índice lo mantiene axon. Hay que añadir dec-0105 al integrar.