Files
lgcosta 0a8ba69340 docs: record the stress findings and the 16-gate suite
caveats §13 documents the capacity ceiling the stress test found: the
workqueue backlog grows with the offered rate and OOM-panics a 1 GB guest
above ~1 Gbit/s. Gate count, suite status and the doc indexes move to
16/16 green.
2026-11-12 20:11:12 -03:00

239 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ressalvas — divergências do comportamento do OpenBSD
English: [docs/en/caveats.md](../en/caveats.md).
Estas são as formas pelas quais este port difere de rodar o mesmo ruleset no
OpenBSD 8.0, ou ainda não tem forma de produção. Tudo aqui tem base no
código (`module/pfsrc/pfk_hooks.c`, `module/pfsrc/pfk_mod.c`,
`docs/porting-notes.md`) e em observações da bancada; nada é especulação.
## Caminho de dados
### 1. O PF roda em contexto de processo via workqueue (modelo netisr), não no caminho rápido do pacote
O OpenBSD avalia o `pf_test()` na CPU que recebeu o pacote, em `IPL_NET`,
com uma lock de leitura NET que não dorme. O Linux nos dá contexto de
softirq e locks da compat *sleepáveis* (rw-semaphores), então o hook nunca
chama o `pf_test()` inline: ele furta a skb (`NF_STOLEN`) para uma
**workqueue ordenada** (`pfk_netisr`, `WQ_MEM_RECLAIM`), roda o engine em
contexto de processo e então reinjeta (`netif_rx()` na entrada,
`dst->output()` na saída/trânsito).
Consequências, todas observáveis:
- **Vereditos são assíncronos.** Qualquer regra encadeada depois do nosso
hook na mesma chain netfilter (outras regras iptables/nft em prioridades
maiores) não vê o pacote até o passe reinjetado chegar. Contadores e
intercalação de log mudam na mesma medida.
- **Latência + vazão**: cada pacote paga um ida-e-volta pela workqueue,
serializado num único worker ordenado. Os gates funcionais são a régua
atual; o `bench-basic.sh` planejado (ainda não está na árvore) é só informativo.
- **Reordenação**: a fila ordenada preserva a ordem de chegada dos itens
*enfileirados*, mas o stack em volta (RPS, multi-NAPI) ainda pode nos
entregar pacotes cuja ordem original de conclusão difere da ordem de
reinjeção.
### 2. A proteção contra reentrada é um tag por direção no `skb->mark`
O sobrevivente reinjetado carrega `skb->mark |= PFK_DONE_IN (0x50460010)` ou
`PFK_DONE_OUT (0x50460020)`; nossos hooks aceitam qualquer pacote já marcado
para aquela direção em vez de re-enfileirar (senão PF_IN → netif_rx → PF_IN…
entraria em loop). O `skb->mark` é um **campo compartilhado e gravável de
32 bits**: qualquer subsistema que o *sobrescreva* — `meta mark set` do nft,
`CONNMARK --restore`, filtros de tc, alguns stacks de VPN — pode (a) limpar o
nosso tag e fazer o pacote ser **re-enfileirado pelo PF de novo**, ou (b)
acidentalmente ligar esses bits e deixar o pacote **pular** um passe do PF.
Regra prática num host com PF-on-Linux: não use mangling de fwmark no mesmo
tráfego; se precisar, mascare os bits do tag do PF no seu espaço de mark.
### 3. Sem lock NET no caminho de dados (interino do M2)
O `pf_test()` roda hoje **sem** a lock de leitura NET da compat (comentário
em `pfk_hooks.c`): com o modelo de workqueue, um escritor de controle
parqueado (mudança de ruleset, ioctl de purge) esfomeava o lado dos pacotes
a ponto de ser contraproducente. A mutação do ruleset e a caminhada de
pacotes rodam, portanto, concorrentemente, protegidas pelo que o próprio
`pf.c` do upstream usa (PF_LOCK/padrões tipo RCU) — um desvio interino
deliberado, sendo retrabalhado junto com o modelo de locks no M5+. Até lá:
espere correção em regime estável, não sob reload concorrente agressivo; o
soak de reload sob tráfego é um gate aberto.
### 4. Fail-open sob pressão de memória
O item da fila é um `kmalloc(GFP_ATOMIC)` em softirq. Se falhar, o hook
retorna `NF_ACCEPT` — o pacote **passa por fora do PF**. É uma escolha
deliberada de disponibilidade (espelha "cai numa fila cheia = bypass do
qdisc"), mas é um trade-off de segurança que vale conhecer: os vereditos do
PF são best-effort exatamente quando o host está sem memória atômica.
### 5. A ordem dos hooks vs conntrack varia por direção
- Entrada (`PREROUTING`, `NF_IP_PRI_FIRST`): o PF roda **antes** do defrag
e do conntrack — como projetado (spec §4.2).
- Saída (`LOCAL_OUT` em `NF_IP_PRI_CONNTRACK+1`): o PF vê o tráfego gerado
localmente **depois** de o conntrack já ter confirmado a entrada.
- Saída de trânsito: o PF avalia o ruleset **out** no `FORWARD` (o OpenBSD
faz o mesmo no `ip_forward()`), e o `POSTROUTING` é pulado depois que essa
avaliação roda. `nat-to`/`rdr-to` foram validados contra essa ordem (gates
05/06).
- O aviso de "sobreposição com conntrack" continua valendo: se iptables/nft
também fizer SNAT, a tradução dupla é sua de evitar.
## Plano de controle / ciclo de vida
### 6. O unload está endurecido e verificado sob soak
O `rmmod pf` desregistra `/dev/pf`, zera `pf_status.running` sob a lock NET,
deleta **e sincroniza** os dois timers de purge (eles se rearmam sozinhos —
um travamento real de rmmod foi observado e corrigido parando as fontes
antes de drenar a workqueue), desregistra os hooks e drena o `pfk_netisr`.
A correção está na árvore, mas o ciclo completo sob tráfego + estados vivos
(gate de soak T18) **ainda não** rodou verde. Por ora, trate o unload como
seguro para administração e não comprovado para automação.
## Notas de comportamento verificadas hoje
### 7. `pfctl -s states` lista estados reais, incluindo traduções
A anomalia anterior era um use-after-free: `refcnt_rele()` tinha polaridade
invertida, então chaves de estado vivas eram liberadas ainda ligadas no RBT
de estados — o que escondia estados e quebrava o walk da árvore (`pfctl -s
states` travando a máquina). Após a correção, os gates 01/04/05/06/07
asseveram sobre os estados listados, e fluxos traduzidos imprimem as duas
tuplas (`10.0.2.1:59335 (10.0.1.10:4380) -> 10.0.2.20:8`).
### 8. Ainda são necessários consumidores
`divert-to` exige um processo atrelado à porta do divert (`tools/pdivert`);
sem listener o pacote é descartado, como o `divs_noport` do BSD, e o kernel
imprime `pfk: divert port N: no socket bound`. `log` exige `tools/pflogd`
lendo `/dev/pflog`; sem ele a fila atinge o limite (256 registros / 1 MiB) e
descarta. Nenhum dos dois é mais stub, mas nenhum faz algo sem o consumidor
rodar (ver [pf-guide.md](pf-guide.md)).
### 9. Filas: caminho verificado; a semântica de modelagem é do HFSC, não um bug
`15-queue.sh` prova que o caminho de filas funciona de ponta a ponta: a
configuração carrega, a disciplina hfsc é anexada à interface, `pfctl -s
queue -v` lista as duas classes com estatísticas, um fluxo de 2 MB pela fila
completa, e o contador de pacotes da classe avança - o escalonador está mesmo
no caminho de dados.
O que o lab não mostra é um *teto de taxa*, e isso é semântica do HFSC, não
defeito do port: o `pf.conf` do OpenBSD 8.0 expõe apenas `bandwidth`, que mapeia
para a curva **linkshare** do HFSC - garantia quando classes competem, não limite
de um fluxo isolado. Não existe palavra-chave `minimum`/`maximum` nesta versão do
pfctl (verificado na sua tabela de palavras-chave), logo não há como definir
upperlimit pelo pf.conf. Um teste de contenção com duas classes saturadas seria o
próximo passo se você quiser asserir sobre share; no enlace virtual de centenas de
Mbit do lab, um único fluxo termina antes de acumular dívida de tempo virtual.
Bugs encontrados e corrigidos no caminho (detalhes em `docs/porting-notes.md`): a
convenção de retorno de `ifqop_enq`, `ifq_q` vs `ifq_softc` (deref de NULL que
matava a máquina no meio da transferência), o cookie de dequeue perdido, o
handle da classe raiz do hfsc colidindo com qids ímpares do pf, o
`DIOCGETQSTATS` falhando porque kernel e pfctl discordavam sobre
`struct hfsc_class_stats` (ambos agora usam o `net/hfsc.h` vendorizado), o
payload do pflog começando no cabeçalho de transporte, e o `splassert` do caminho
de purge que transformava uma corrida benigna em `BUG()`.
## Desvios da camada de compat (de `docs/porting-notes.md`, registro do M1)
Estes são desvios deliberados e marcados no código; importam ao raciocinar
sobre a semântica das regras:
- **Match pelo dono do socket** (`user`/`pid` em regras): só uid — o
`struct sock` do Linux não guarda egid/cpid, então o gid degrada para
`(gid_t)-1` e o pid para `NO_PID`.
- **Checksums**: o offload do driver não é confiável na entrada (decisão do
M1) — checksums TCP são recalculados em software; os pseudo-checksums de
saída são feitos no caminho próprio do port.
- **Opções IP / record-route / timestamps**: não processadas no caminho de
saída do PF; **redirecionamentos ICMP não são gerados** pelo código de
rota do PF (o FIB do Linux emite).
- **Labels de rota**: não-objetivo — a camada de ioctl rejeita regras com
label; `rtable_iterate()` sempre retorna NULL (sem iteração de uRPF
multipath).
- **Grupos de interface**: o Linux não tem; matches `group …` se comportam
como um grupo inexistente no OpenBSD.
- **Skbs GSO/multi-segmento**: tratadas por linearização na entrada do shim
mbuf (custo de performance documentado no spec §10; os gates vão
quantificar).
- **Instância única**: um mundo PF em `init_net`, como o OpenBSD tem um
kernel. Containers/namespaces não são visíveis ao PF e não podem ser
filtrados por ele neste design.
## Pipeline dos screencasts (2026-11-12)
### 10. A geração de vídeo do MiniMax não está habilitada nesta conta
Todos os modelos de vídeo do MiniMax (`T2V-01`, `T2V-01-Director`,
`MiniMax-Hailuo-02`) respondem com o erro de API **2068 — "The current Token
Plan tier does not support this model"**. A narração (TTS) e a arte do
cartão de título funcionam normalmente; só os modelos de vídeo estão
bloqueados. Por isso as transições entre fases são renderizadas localmente
com ffmpeg (um crossfade sobre um cartão de capítulo) em vez de clipes
gerados por IA. Mudar isso exige só subir o tier da conta — o ponto de
extensão é `scripts/screencast/join.py`, que constrói os cartões.
### 11. A narração é uma trilha separada, não uma dublagem do vídeo
Cada fase é gravada **uma vez** e serve aos dois idiomas: a sessão de
terminal é neutra em relação ao idioma, e as duas versões narradas diferem
apenas na trilha de áudio e nas legendas queimadas. O gravador segura cada
batida na tela pelo tempo que o segmento de narração mais longo (EN ou PT)
precisa, então a voz e a ação no terminal ficam em sincronia nos dois.
### 12. O que os vídeos não mostram
Os vídeos são uma demonstração, não uma rodada de teste: a suíte de gates do
lab (`scripts/vm-run-tests.sh`) é a autoridade para PASS/FAIL, e os logs
dela ficam em `out/test-logs/`. Os screencasts mostram os mesmos comandos,
mas um quadro de vídeo não é um veredito.
## Achados de capacidade (2026-11-12, a partir de `scripts/stress/run-stress.sh`)
### 13. O backlog da workqueue cresce com a taxa oferecida: acima de ~1 Gbit/s a VM dá OOM-panic
Esta é a consequência mais dura da §1 e ela é **reproduzível**, não teórica.
O PF rouba cada skb para uma única workqueue ordenada e roda o engine em
contexto de processo, então o número de pacotes em voo só é limitado pela
memória que o backlog consome.
Medido no lab (guest Alpine, **1 GB** de RAM, 4 fluxos TCP por um ruleset
stateful):
| Taxa oferecida (por fluxo × 4) | Resultado |
|---|---|
| 50 Mbit/s (200 Mbit/s no total) | estável |
| 100 Mbit/s (400 Mbit/s no total) | estável |
| 250 Mbit/s (**1 Gbit/s** no total) | **kernel panic: "System is deadlocked on memory"** |
O panic é um OOM sem processo matável; o slab naquele momento mostra
`kmalloc-8k` ≈ 50 MB, `skbuff_head_cache` ≈ 18 MB e `files_cache` ≈ 40 MB —
ou seja, o backlog comeu o 1 GB. A VM reinicia (watchdog de 120 s) e todas as
conexões que passavam pelo firewall caem junto.
Consequências práticas:
- **Limite a taxa que você aponta para o PF** e dimensione a máquina para o
backlog: a memória necessária cresce com a taxa oferecida, não com o
número de fluxos concorrentes.
- Por isso o teste de stress (`tests/vm/16-stress.sh`) roda as fases de
tráfego a uma taxa **limitada** (padrão: 100 Mbit/s por fluxo = 400 Mbit/s
no total) — o regime que este port suporta — e reporta o teto
separadamente, em vez de dar OOM no firewall sob teste.
- Os gates funcionais (01-15) não são afetados: eles movem megabytes, não gigabits
por segundo.
O que resolveria isso é um fast path de verdade (avaliar `pf_test()` inline em
softirq, como o OpenBSD faz) ou uma fila limitada com política de drop — as
duas são mudanças de design fora do escopo deste port.
## O que *não* é ressalva
ARP e outros EtherTypes não-IPv4/IPv6 nunca entram nos hooks netfilter de
IPv4/IPv6 — passam pela máquina intactos, exatamente como o PF no OpenBSD
ignora não-IP. O `/dev/pf` é 0600 somente-root por projeto (equivalente ao
padrão do `pfctl` no OpenBSD da era `securelevel`).