docs(realtime): record the design decisions behind the realtime layer

Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
This commit is contained in:
2026-08-14 19:04:41 +02:00
parent 099315a538
commit 55a2af4756
14 changed files with 4470 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
# Recherche: WS-Auth, Heartbeat und Reconnect
Type: research
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Welche etablierten Muster gibt es für Authentifizierung, Verbindungserhalt und Reconnect
bei langlebigen WebSocket-Verbindungen — und welche davon passen zu Axum 0.8 und einem
JWT-basierten Auth-Modell?
### Zu klären
- **Token-Übergabe beim Handshake.** Die Browser-`WebSocket`-API erlaubt keine
benutzerdefinierten Header. Übliche Auswege: Token im Query-String (landet in
Server-Logs — bei diesem Bedrohungsmodell heikel), `Sec-WebSocket-Protocol`-Missbrauch,
Cookie, oder ein erstes Auth-Frame nach dem Handshake mit kurzem Timeout. Was ist
gängige Praxis, was sind die dokumentierten Nachteile? **Achtung:** falls die
Verbindung im Tauri-Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*),
entfällt die Header-Beschränkung komplett — die Recherche soll beide Fälle abdecken.
- **Token-Ablauf bei laufender Verbindung.** Das JWT ist beim Handshake gültig und läuft
nach Minuten ab, während die Verbindung Stunden steht. Muster: Verbindungs-TTL mit
erzwungenem Reconnect, In-Band-Re-Auth-Frame, serverseitige Revocation-Liste. Was
machen andere?
- **Heartbeat.** WebSocket hat Ping/Pong auf Protokollebene. Wann reicht das, wann braucht
es Application-Level-Heartbeats? Übliche Intervalle. Verhalten hinter Proxies und
Corporate-Firewalls (relevant: Firmenkunden sind Zielgruppe).
- **Reconnect-Strategie.** Exponentielles Backoff mit Jitter — konkrete Parameter aus
der Praxis. Thundering-Herd-Verhalten, wenn ein Server neu startet und alle Clients
gleichzeitig zurückkommen.
- **Axum 0.8 konkret.** Wie sieht ein WebSocket-Upgrade mit State-Zugriff und Middleware
aus? Greift `middleware::from_fn_with_state` beim Upgrade-Request? Wie werden viele
gleichzeitige Verbindungen sauber verwaltet und beim Shutdown beendet?
- **`tokio::sync::broadcast` und langsame Empfänger.** Der Kanal verwirft Nachrichten für
hinterherhinkende Receiver (`RecvError::Lagged`). Wie behandelt man das korrekt, wenn
„Lagged" eigentlich „Resync jetzt" bedeuten soll? Gibt es dafür ein etabliertes Muster
oder bessere Kanaltypen für diesen Zweck?
### Quellenqualität
Axum-/Tokio-Dokumentation und Quellcode, RFC 6455, MDN. Praxisberichte nur mit Datum.
### Erwartetes Ergebnis
Eine Markdown-Datei im Repo: pro Teilfrage die Optionen mit ihren dokumentierten Vor-
und Nachteilen, jeweils belegt. **Keine Empfehlung** — die Entscheidung fällt im Ticket
*Verbindungsaufbau & Auth über WS*, dieses Ticket liefert nur die Faktenlage.
## Answer
Vollständiges Ergebnis: [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md)
— Faktenlage zu allen sechs Teilfragen, Quellenverzeichnis und ein Anhang „Was nicht belegt
werden konnte" mit 17 Punkten. Keine Empfehlung, wie gefordert.
### Die vier Befunde, die andere Tickets verändern
**1. Der `Authorization`-Header ist der leiseste Kanal — und das ist belegbar, nicht Folklore.**
In den Default-Konfigurationen von nginx, Apache und Caddy landet der Query-String im
Access-Log; Caddy redigiert `Authorization` aktiv zu `REDACTED`, `request.uri` dagegen nicht.
Kein Transportweg schützt vor dem Betreiber selbst — der Server *muss* das Token sehen. Der
Unterschied zwischen den Optionen ist ausschließlich, was den Handshake in Logfiles, Backups
und APM-Systemen **überlebt**. Nebenbei entkräftet: die verbreiteten Referrer- und
Browser-History-Argumente gegen den Query-String ließen sich für `new WebSocket(url)` **nicht**
belegen und gehören nicht ins Entscheidungsticket.
**2. `Sec-WebSocket-Protocol` ist für dieses Bedrohungsmodell schlechter als gedacht** — Caddy
loggt per Default *alle* Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu
kommt: ein JWT ist zwar ein legaler Subprotocol-Token, aber `=` ist als Separator verboten
(Base64-Padding), und der Browser erzwingt ein Echo des Subprotokolls durch den Server.
**3. Die Browser-API verschluckt alle Fehlerdetails — der Rust-Layer nicht.** WHATWG schreibt
vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler, Subprotocol-Mismatch und Abbruch nach
dem Handshake für das Skript **ununterscheidbar** sein müssen: immer `CloseEvent.code === 1006`.
Im WebView ist „Token abgelaufen" also nicht von „Server down" unterscheidbar. `tungstenite::Error::Http`
trägt dagegen die vollständige HTTP-Response inklusive Statuscode und Body. Diese Asymmetrie
zieht sich durch fast jede Teilfrage → starkes Argument in *Wo lebt die WS-Verbindung im Client*.
**4. `axum::serve(...).with_graceful_shutdown(...)` beendet WebSocket-Verbindungen nicht und
wartet nicht auf sie.** Belegt vierfach: der `close_rx`-Guard hängt nur an der hyper-Task, das
`on_upgrade`-`tokio::spawn` nicht; `UpgradeableConnection::graceful_shutdown` ist nach dem Hijack
laut hyper-util-Doku „does nothing"; axum-Issue #3003 (offen seit 21.10.2024) und PR #3831
(offen seit 11.07.2026) beschreiben genau das. **Das Projekt muss WS-Verbindungen selbst
tracken und beenden** — `CancellationToken` + `TaskTracker` sind die belegten Bausteine.
### Weitere Faktenlage, kompakt
- **`middleware::from_fn_with_state` greift beim Upgrade-Request** — das Upgrade ist ein normaler
GET, 401/403 sind regulär sendbar.
- **Token-Ablauf:** SignalR dokumentiert ausdrücklich, dass Auth **nicht** revalidiert wird.
Optionen sind TTL/Reconnect (AWS API GW: 2 h / 10 min idle, hart), In-Band-Refresh (Centrifugo,
Ably — Token landet dann in keinem Log) oder eine Revocation-Liste (im Betreiber-Angreifer-Modell
per Definition wirkungslos).
- **Heartbeat:** Protokoll-Ping ist aus dem WebView **nicht auslösbar** (WHATWG), im Rust-Layer
antwortet tungstenite automatisch. Proxy-Korridor der dokumentierten Idle-Timeouts: Azure
AppGW 20 s, GCP 30 s, nginx 60 s. Die verbreiteten Cloudflare-„100 s" und die HAProxy-Defaults
sind Mythen und ließen sich nicht belegen. Etablierte Intervalle: Socket.IO 25 s, Phoenix 30 s,
Centrifugo 25 s.
- **Reconnect:** Praxiswerte reichen von 10 ms (Phoenix, ohne Jitter) bis 15 s (Ably). Gegen
Thundering Herd sind vier Ansätze belegt: Client-Jitter, server-gesteuerter Hinweis
(`Retry-After`, Discord Op 7), Draining und Accept-Rate-Limiting — mit Postmortems von Discord
(29.04.2026) und Slack (15.03.2021).
- **`RecvError::Lagged(n)`:** `n` = übersprungene Nachrichten, der Receiver bleibt brauchbar, der
Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ **„Lagged = Resync" ist nirgends
als offizielles Muster dokumentiert** — gängige Community-Praxis, aber ohne Primärquelle.
Belegbar sind nur die Bausteine (`resubscribe()`, `Receiver::len()` als Frühwarnung,
`mpsc::try_send` pro Client).
- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe** — „retains only the last sent value"
ist exakt die Semantik von „der Cursor der Conversation X ist jetzt N". Kein Lag-Fehler möglich.
→ *Fan-out-Architektur*.
- **`PgListener` belegt eine dedizierte Postgres-Connection außerhalb des Pools** (aktuell
`max_connections = 10`), reconnected aber selbst. `NOTIFY` ist transaktional. Neue Gefahr:
eine volle Notify-Queue lässt Transaktionen **beim Commit fehlschlagen**.
- **Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.