docs(bug-fix): post-mortem e changelog das chaves AVIF presas no otimizador

This commit is contained in:
Francisco Solla 2026-10-02 18:28:12 -03:00
parent d850782f76
commit f441323244
2 changed files with 78 additions and 0 deletions

View file

@ -0,0 +1 @@
**Fix — hero da home sem imagens: chaves AVIF presas no otimizador do Next (2026-10-02):** o relato "as imagens pararam de aparecer" reproduziu em produção após o deploy do SHA `ac39c817`: no browser, `RUI`/`WAGNER`/`Jeronimo` (e `Lula` em certos viewports) do hero não carregavam — requests a `/_next/image` pendentes para sempre (`complete=false`, `naturalWidth=0`) e Cloudflare 524 após ~100–125s no URL exato; a matriz em prod (Accept de browser, q=75) envenenou exatamente `RUI - 2-2.avif w=256`, `WAGNER - 2-9 final.avif w=256`, `Jeronimo.avif w=256` e `Lula.avif w=384` (o conjunto que um load 1920×1080 DPR1 pede em paralelo), enquanto todas as outras larguras (16–3840) e imagens respondiam 200 em ~0,3–6s, e o mesmo `RUI w=256` em avif/q=76 respondia 200 em ~0,26s. Causa-raiz: o `Batcher` do `ResponseCache` (`node_modules/next/dist/lib/batcher.js`) deduplica por cache key e guarda a promise em `this.pending` sem timeout — uma geração que pendurou (os masters AVIF do hero decodificados via libheif no sharp 0.34.2, caminho do advisory crítico GHSA-2xp9-vwfh-vxw4, "Unauthenticated RCE in Image Optimization API when AVIF files are used", `next >=10 <15.5.24`, cuja mitigação oficial no patch `409772ec`/15.5.24 é adicionar AVIF a `BYPASS_TYPES`; o hang transiente não foi reproduzido localmente — sharp glibc e musl/Alpine, sequencial e 4 concorrentes, 15 rodadas — nem em staging idêntico) deixou a chave presa para sempre até restart, e o fix anterior (`16f95975`) havia regerado os masters como AVIF display-size sem que nenhum guard olhasse formato/mídia. O fix converte os 8 AVIF locais referenciados via `next/image` para WebP — 5 masters do hero regerados display-size a partir dos próprios AVIF aprovados (`pnpm images:resize --format webp --quality 85`: `RUI`/`WAGNER`/`Jeronimo` 768px, `Lula` 1024px, `JOA00162` 1536px; 538KB vs 351KB) e 3 cards da home apontados para os `.webp` existentes —, deleta os 9 `.avif` de `public/` (8 referenciados + `Marca-Lula.avif` órfão) e faz `scripts/build-radio-artes.mjs` ler `JOA00162.webp`; o caminho vira libwebp (loader seguro no patch oficial) e as URLs do otimizador mudam de chave, matando as 4 chaves envenenadas no deploy sem purge nem restart. Guardas: ban de literal `.avif` em `src/` (duas grafias, incluindo `image:`) e zero `.avif` em `public/` em `tests/unit/imageDelivery.unit.spec.ts`, e hero exigindo `.webp` em `tests/unit/campaignHero.unit.spec.tsx` — vermelhos sem o fix (2–3 falhas), verdes com (8 passed); otimizador local 200 `image/webp` a 256w em 0,7–1,6ms e derivados menores (ex.: `RUI` 256: 9,8KB vs 12,2KB); e2e `frontend.e2e.spec.ts` 42 passed; `pnpm gate:fast` 468 arquivos/5178 testes. Post-mortem em `docs/postmortems/2026-10-02-hero-webp-avif-otimizador.md` (prevenções caras registradas como Issues: upgrade Next ≥15.5.24, Cache Rule/observabilidade Cloudflare para `/_next/image` com runbook de purge, e healthcheck/probe sintético de chaves presas; não fazer: patch local no Batcher, `unoptimized` global, upgrade neste PR). Sem migration, sem schema, sem URL pública alterada.

View file

@ -0,0 +1,77 @@
# Post-mortem: hero da home sem imagens — chaves AVIF presas no otimizador do Next
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Data do post-mortem | 2026-10-02 |
| Severidade | alta (imagens principais da home ausentes para parte dos visitantes; sem indisponibilidade geral nem perda de dados) |
| Ambiente | prod |
| Issue(s) | sem Issue |
| PR do fix | pendente — PR deste fluxo |
| Detectado por | humano (relato na sessão de bug-fix de 2026-10-02 ~17:15 -03: "as imagens pararam de aparecer") |
## Timeline
| Momento | Data/hora | Evento |
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | 2026-10-02 ~16:35 -03 (~19:35Z) | Deploy de produção do SHA `ac39c817` recria o container (job de produção do `deploy.yml` rodou 19:26:40Z–19:35:53Z; container recriado ~19:35Z ≈ 16:35 -03). Antes disso o sintoma não existia; staging (mesmo SHA, deploy 15:17Z) nunca reproduziu. |
| Detecção | 2026-10-02 ~17:15 -03 (≈20:15Z) | Relato humano na sessão de bug-fix ("as imagens pararam de aparecer"). No browser, requests a `/_next/image` ficavam pendentes para sempre (`complete=false`, `naturalWidth=0`); o Cloudflare devolvia 524 após ~100–125s no mesmo URL exato. Sem Issue. |
| Correção implementada | 2026-10-02 | Worktree `fix/16`: 5 masters do hero regerados em WebP display-size, 3 refs de cards da home apontadas para `.webp` existentes, 9 `.avif` de `public/` removidos, `scripts/build-radio-artes.mjs` lendo `JOA00162.webp` e 3 guards de formato. `pnpm gate:fast` verde (468 arquivos, 5178 testes); e2e `frontend.e2e.spec.ts` 42 passed. |
| Correção mergeada | pendente | PR deste fluxo aberto com o fix, os guards e este post-mortem; merge pendente do required check + auto-merge. |
| Deploy | pendente | Merge em `main` dispara o deploy; produção só após approve humano no environment `production`. |
| Verificado em prod | pendente | Aguardando deploy e confirmação humana. |
## O bug
Em produção, no browser, as imagens `RUI`/`WAGNER`/`Jeronimo` (e `Lula` em certos viewports) do hero da home pública não carregavam: os requests para `/_next/image` ficavam pendentes para sempre (`complete=false`, `naturalWidth=0`) e o mesmo URL exato nunca respondia — o Cloudflare devolvia 524 após ~100–125s. A matriz de evidências em produção (Accept de browser, q=75) mostrou que estavam envenenadas exatamente `RUI - 2-2.avif w=256`, `WAGNER - 2-9 final.avif w=256`, `Jeronimo.avif w=256` e `Lula.avif w=384`; **todas** as outras larguras dos mesmos arquivos (16–3840) e todas as outras imagens (`JOA00162` em todas as larguras, `fundo.avif`, as 2 fotos `.avif` da home, o HEIC do Instagram) respondiam 200 em ~0,3–6s. As 4 chaves correspondem exatamente ao conjunto que um load da home a 1920×1080 DPR1 pede em paralelo (aliados 211px→256; Lula 284px→384). A chave com mimeType avif (Accept: `image/avif`) do mesmo `RUI w=256` respondia 200 em 0,26s, e `q=76` respondia 200 em 0,28s — a geração estava saudável; só as 4 chaves exatas estavam presas na memória do processo. O sintoma afetava a página principal para parte dos visitantes, sem indisponibilidade geral nem perda de dados. **Sintoma — não a causa.**
## Causa-raiz
### 5 whys
1. **Por que a imagem não aparecia?** Porque o request para a chave exata do otimizador nunca respondia (524).
2. **Por que nunca respondia?** Porque o `Batcher` do `ResponseCache` do Next (`node_modules/next/dist/lib/batcher.js`) deduplica por cache key e guarda a promise em `this.pending`; uma geração que pendura deixa a promise sem timeout para sempre — toda request futura daquela chave entra na mesma promise morta até o processo reiniciar. Evidência de código: `node_modules/next/dist/server/response-cache/index.js` (Batcher com `schedulerFn`), sem timeout.
3. **Por que a geração pendurou?** Os 5 masters do hero eram AVIF e o Next 15.4.11 os decodifica via libheif (sharp 0.34.2). O advisory crítico GHSA-2xp9-vwfh-vxw4 ("Unauthenticated RCE in Image Optimization API when AVIF files are used"; afeta `next >=10 <15.5.24`) tem como mitigação oficial, no patch vercel/next.js commit `409772ec` (release 15.5.24), adicionar AVIF a `BYPASS_TYPES` (não otimizar AVIF) e bloquear os loaders libvips não-seguros. O hang transiente **não foi reproduzido** localmente (testado sharp glibc e musl/Alpine, sequencial e 4 concorrentes, 15 rodadas; staging idêntico também não reproduz) — é race de ambiente/carga no startup; ficou registrado como não reproduzido.
4. **Por que AVIF chegou ao otimizador?** Porque o fix anterior (`16f95975`, 2026-10-01) regerou os masters como AVIF no tamanho de exibição para resolver a lentidão; o caminho AVIF→libheif permaneceu, e nenhum guard olhava formato/mídia.
5. **Por que passou?** Porque não havia observabilidade de `/_next/image` nem guard de formato; o advisory era conhecido e o upgrade do Next é débito registrado como caro (`docs/plans/b195-f1-wizard-form-duplicado-streaming-impl.md`).
## Correção
- **Masters convertidos para WebP:** os 8 AVIF locais referenciados via `next/image` viraram WebP — os 5 masters do hero foram regerados display-size a partir dos próprios AVIF aprovados (mesmo enquadramento; `pnpm images:resize --format webp --quality 85`: `RUI`/`WAGNER`/`Jeronimo` 768px, `Lula` 1024px, `JOA00162` 1536px; 538KB vs 351KB de AVIF — o master não vai ao cliente) e os 3 cards da home (`fundo`, `53569851134…`, `52396285023…`) passaram a referenciar os `.webp` existentes.
- **AVIF removido de `public/`:** os 9 `.avif` foram deletados (8 referenciados + `Marca-Lula.avif` órfão).
- **`scripts/build-radio-artes.mjs`** passou a ler `JOA00162.webp`.
- **Arquivos:** `src/components/CampaignHero.tsx`, `src/app/(frontend)/(home)/page.tsx`, `scripts/build-radio-artes.mjs`, `public/*.webp` (5 regerados), `public/*.avif` (9 removidos).
Resolve a causa: o caminho de decodificação vira libwebp (o patch oficial trata webp como loader seguro) e as URLs do otimizador mudam de chave, matando as 4 chaves envenenadas no próximo deploy — sem purge nem restart manual.
## Verificação
- Teste de regressão: `tests/unit/imageDelivery.unit.spec.ts` (2 casos novos: ban de literal `.avif` em `src/` incluindo `image:` e zero `.avif` em `public/`) e `tests/unit/campaignHero.unit.spec.tsx` (caso "serve masters WebP"). Prova vermelho/verde independente: sem o fix, 2–3 falhas (recebe `/RUI - 2-2.avif`; `.avif` presente; dimensão com arquivo ausente); com o fix, 8 passed.
- Suíte: `pnpm gate:fast` verde (468 arquivos, 5178 testes); e2e `tests/e2e/frontend.e2e.spec.ts` 42 passed (modo dev local; o comando com dependências estourou 900s e o recipe do config `--no-deps --project=frontend` passou em 1,1 min).
- Otimizador local: home com 0 ocorrências de avif; os 8 masters WebP servidos via `/_next/image` a 256w em 200 `image/webp` (0,7–1,6ms) e derivados ligeiramente menores que os do AVIF (ex.: `RUI` 256: 9,8KB vs 12,2KB).
- Prod (read-only, antes do deploy): as 4 chaves AVIF seguem presas (timeout/524) e a home ainda serve AVIF (260 ocorrências).
- CI: pendente (PR ainda não aberto). Prod: pendente (deploy e confirmação humana).
## Prevenção
| Estratégia | Custo | Estado |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------- |
| Ban de literal `.avif` em `src/` (as duas grafias, incluindo `image:`), zero `.avif` em `public/` e hero exigindo `.webp` (`imageDelivery.unit.spec.ts` / `campaignHero.unit.spec.tsx`) | barata | implementada agora (este PR) |
| Upgrade Next ≥15.5.24 (AVIF em `BYPASS_TYPES` + loaders libvips seguros) | cara | documentada — Issue, não implementada neste fluxo |
| Cache Rule/observabilidade Cloudflare para `/_next/image` + runbook de purge | cara | documentada — Issue, não implementada neste fluxo |
| Healthcheck/probe sintético de chaves presas com alerta | cara | documentada — Issue, não implementada neste fluxo |
**Estratégia implementada:** os 3 guards de formato acima (ban de AVIF em `src/` com as duas grafias, zero AVIF em `public/`, hero exigindo `.webp`), somados aos guards de dimensão/TTL do fix anterior.
**Estratégia documentada (cara):** (c) upgrade Next ≥15.5.24 (AVIF em `BYPASS_TYPES` + loaders seguros) — Issue; (d) Cache Rule/observabilidade Cloudflare para `/_next/image` + runbook de purge — Issue; (e) healthcheck/probe sintético de chaves presas com alerta — Issue. **Não fazer:** patch local no Batcher, `unoptimized` global, upgrade do Next neste PR.
## Lições
- **Default de dependência vulnerável:** os masters AVIF num Next <15.5.24 significavam decodificar via libheif exatamente o caminho do advisory crítico GHSA-2xp9-vwfh-vxw4 (RCE não autenticada na Image Optimization API); a mitigação oficial é bypass (`BYPASS_TYPES`), não um remendo local.
- **Mitigação upstream é bypass, não patch local:** o patch `409772ec` (15.5.24) trata AVIF como tipo a não otimizar; patch no Batcher e `unoptimized` global foram descartados — o caminho certo é o upgrade registrado como débito/Issue.
- **O Batcher transforma hang transiente em outage permanente por chave:** a promise sem timeout em `this.pending` (`node_modules/next/dist/lib/batcher.js`) faz toda request futura da mesma cache key herdar a promise morta até restart do processo — o URL exato nunca respondia enquanto as outras larguras fluíam.
- **Guard de formato teria pegado:** um ban simples de `.avif` no `src/` e um check de `public/` teriam barrado a regressão no PR anterior — é exatamente a prevenção barata implementada agora, junto dos guards de dimensão/TTL.
- **Staging idêntico não reproduz race — observabilidade é a rede:** o mesmo SHA não reproduziu em staging, o hang não reproduziu local (glibc/musl, sequencial/4 concorrentes, 15 rodadas) e a detecção foi humana ~40 min depois do deploy; sem probe/alerta de `/_next/image`, o 524 do Cloudflare (~100–125s) é o único sinal e chega tarde.