# Recherche: WS-Auth, Heartbeat und Reconnect Ticket: [`../issues/03-research-ws-auth-reconnect.md`](../issues/03-research-ws-auth-reconnect.md) Parent: [`../map.md`](../map.md) Stand: 14.08.2026 > **Dieses Dokument gibt bewusst keine Empfehlung ab.** Es stellt pro Teilfrage die Optionen > mit ihren belegten Vor- und Nachteilen gegenüber. Die Entscheidung fällt im Ticket > *Verbindungsaufbau & Auth über WS*. ## Vorbemerkung zum Bedrohungsmodell Die Map setzt fest: **der Serverbetreiber ist der Angreifer.** Für die Token-Frage folgt daraus eine Klarstellung, die im weiteren Text nicht wiederholt wird: **kein Token-Transportweg schützt vor dem Serverbetreiber selbst** — der Server muss das Token sehen, um es zu prüfen. Der Unterschied zwischen den Optionen ist ausschließlich die **Persistenz- und Streubreite** des Geheimnisses: was landet in Logfiles, Log-Backups, Reverse-Proxy-Logs, APM/Tracing-Systemen und deren Subprozessoren — also in Artefakten, die den Handshake überleben und mehr Leute sehen als der Prozess, der das Token validiert. Vertraulichkeit *gegenüber* dem Betreiber ist eine Aufgabe der E2EE-Schicht, nicht der Auth-Schicht. RFC 6455 sagt dazu selbst nichts vor: §10.5 — > „This protocol does not prescribe any particular way that servers can authenticate clients > during a WebSocket handshake." (https://datatracker.ietf.org/doc/html/rfc6455#section-10.5) Ein zweites, für das Projekt relevantes Faktum vorab: **die Browser-API verschluckt alle Fehlerdetails.** WHATWG WebSockets Standard, „Feedback from the protocol": > „User agents must not convey any failure information to scripts in a way that would allow a > script to distinguish the following situations […] Allowing a script to distinguish these > cases would allow a script to probe the user's local network in preparation for an attack." (https://websockets.spec.whatwg.org/#feedback-from-the-protocol) Zu den nicht unterscheidbaren Fällen gehören DNS-Fehler, Connection-Refused, TLS-Fehler, **Subprotocol-Mismatch** und Abbruch nach erfolgreichem Handshake. Das Skript sieht in allen Fällen `CloseEvent.code === 1006`, `wasClean === false`; das `error`-Event ist ein generisches `Event` ohne Payload (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event). **Im WebView ist „Token abgelaufen" nicht von „Server down" unterscheidbar. Im Rust-Layer schon** — `tungstenite::Error::Http` trägt die vollständige HTTP-Response inklusive Statuscode und Body (https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html). Diese Asymmetrie zieht sich durch fast jede Teilfrage. --- ## 1. Token-Übergabe beim Handshake ### 1.1 Ausgangslage Der Handshake ist ein normaler HTTP-Request. RFC 6455 §4.1: > „If the status code received from the server is not 101, the client handles the response per > HTTP [RFC2616] procedures. In particular, the client might perform authentication if it > receives a 401 status code" (https://datatracker.ietf.org/doc/html/rfc6455#section-4.1) Ein Axum-Handler kann also regulär mit 401/403 samt JSON-Body ablehnen. Ob der Client das *sieht*, hängt daran, wo die Verbindung lebt (siehe Vorbemerkung). ### 1.2 Option A — Token im Query-String (`wss://host/ws?token=…`) **Normative Ablehnung.** RFC 6750 §2.3: > „Because of the security weaknesses associated with the URI method (see Section 5), including > the high likelihood that the URL containing the access token will be logged, it SHOULD NOT be > used unless it is impossible to transport the access token in the 'Authorization' request > header field or the HTTP request entity-body." (https://datatracker.ietf.org/doc/html/rfc6750#section-2.3) RFC 6750 §5.3: > „Bearer tokens SHOULD NOT be passed in page URLs […] Browsers, web servers, and other > software may not adequately secure URLs in the browser history, web server logs, and other > data structures." (https://datatracker.ietf.org/doc/html/rfc6750#section-5.3) OWASP REST Security Cheat Sheet: „Passwords, security tokens, and API keys should not appear in the URL, as this can be captured in web server logs" (https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html) **Das Log-Verhalten in Default-Konfigurationen — der eigentliche Beleg:** | Proxy/Server | Query-String im Default-Log? | Header im Default-Log? | Quelle | |---|---|---|---| | nginx (`combined`) | **Ja** — `$request` ist die Request-Line inkl. Query | Nein (nur Referer + User-Agent) | https://nginx.org/en/docs/http/ngx_http_log_module.html | | Apache httpd (`common`/`combined`) | **Ja** — `%r` = „The request line from the client" | Nein (außer explizit `%{Header}i`) | https://httpd.apache.org/docs/2.4/logs.html | | Caddy (JSON) | **Ja** — `request.uri` = „Absolute path and query of the URI" | **Ja, alle Header** — außer `Cookie`, `Set-Cookie`, `Authorization`, `Proxy-Authorization` (→ `REDACTED`) | https://caddyserver.com/docs/logging | Caddy wörtlich: > „By default, access logs […] with headers that contain potentially sensitive information > (`Cookie`, `Set-Cookie`, `Authorization` and `Proxy-Authorization`) will be logged as > `REDACTED`." **Befund:** In allen drei Default-Konfigurationen ist der `Authorization`-Header der leisere Kanal. Bei Caddy ist er sogar aktiv redigiert, `request.uri` dagegen nicht. Das ist der einzige belastbare technische Unterschied im Log-Kanal — und für dieses Projekt der entscheidende, weil der Betreiber-Angreifer genau über solche Artefakte an Tokens kommt, die er im Prozess nur flüchtig sieht. **Wer es trotzdem so macht — und wie ehrlich die Doku dazu ist.** Microsoft SignalR (`ms.date: 2026-07-29`): > „In standard web APIs, bearer tokens are sent in an HTTP header. However, SignalR is unable to > set these headers in browsers when some transports are used. When WebSockets and Server-Sent > Events are used, the token is transmitted as a query string parameter." und auf der Security-Seite: > „Many web servers log the URL for each request, including the query string. Logging the URLs > may log the access token. ASP.NET Core logs the URL for each request by default […] you can > write middleware to log the data that you require and filter out the `access_token` query > string value (if present)." (https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz, https://learn.microsoft.com/en-us/aspnet/core/signalr/security) Azure Web PubSub unterstützt beides parallel: `/client/?hub={hub}&access_token={token}` **oder** `Authorization: Bearer {token}` (https://learn.microsoft.com/en-us/azure/azure-web-pubsub/concept-client-protocols). AWS AppSync unterstützt drei Wege gleichzeitig: Query-Param, Subprotocol und echte Header (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html). python-websockets bewertet den Query-Param als „fully reliable but less secure", weil „URIs end up in logs, which leaks credentials" (https://websockets.readthedocs.io/en/stable/topics/authentication.html). **Zwei verbreitete Nachteils-Behauptungen, die sich NICHT belegen ließen** — bitte nicht ins Entscheidungsticket übernehmen: - **Referrer-Leak:** RFC 6750 §5.3 nennt Referrer, spricht aber von „page URLs". Für `new WebSocket(url)` konnte keine Quelle gefunden werden. Der Handshake ist eine Subresource-Request; im Referrer landet die *Seiten*-URL, nicht die WS-URL. - **Browser-History:** `new WebSocket(url)` erzeugt keinen History-Eintrag. Kein Beleg gefunden. Belegbar bleiben: **Access-Logs, Log-Aggregation/APM, und deren Backups.** Das reicht. | | | |---|---| | **Pro** | Funktioniert in jeder WebSocket-Implementierung inklusive Browser-API; keine Sonderbehandlung nötig; als `Query`-Extractor in Axum trivial. | | **Contra** | RFC 6750 sagt explizit „SHOULD NOT"; landet per Default in nginx-, Apache- und Caddy-Logs; verschärft sich linear mit erzwungenen Reconnects (siehe §2). | ### 1.3 Option B — `Sec-WebSocket-Protocol`-Missbrauch **Ist ein JWT ein legaler Subprotocol-Token?** RFC 6455 §4.1: > „The elements that comprise this value MUST be non-empty strings with characters in the range > U+0021 to U+007E not including separator characters as defined in [RFC2616]." RFC 2616 §2.2 definiert als Separatoren: `( ) < > @ , ; : \ " / [ ] ? = { }`, SP, HT (https://datatracker.ietf.org/doc/html/rfc2616#section-2.2). `.`, `-` und `_` sind **keine** Separatoren → ein kompakt serialisiertes JWT (`base64url.base64url.base64url`) ist ein **gültiger** Subprotocol-Token. **Aber `=` ist ein Separator** → Base64 *mit* Padding wäre illegal. Genau deshalb schreibt Kubernetes base64url-ohne-Padding vor. **Die zwingende Echo-Semantik ist der eigentliche Haken.** RFC 6455 §4.2.2: Der Server wählt „one or none of the acceptable protocols". RFC 6455 §4.1: Gibt der Server ein Subprotokoll zurück, das der Client nicht angeboten hat, „the client MUST _Fail the WebSocket Connection_". Browser sind **strenger** als RFC 6455 — WHATWG: > „If protocols is not the empty list and extracting header list values given > `Sec-WebSocket-Protocol` and response's header list results in null, failure, or the empty > byte sequence, then fail the WebSocket connection." (https://websockets.spec.whatwg.org/#establish-a-websocket-connection) Bietet der Browser-Client also Subprotokolle an, **muss** der Server eines zurückgeben — aber er darf natürlich nicht das Token echoen. Daraus folgt zwingend das Kubernetes-Muster: **mindestens zwei Subprotokolle anbieten**, eines ist das Token, eines das echte Protokoll. **Kubernetes API-Server, der kanonische Präzedenzfall.** `bearerProtocolPrefix = "base64url.bearer.authorization.k8s.io."` > „ProtocolAuthenticator allows a websocket connection to provide a bearer token as a > subprotocol in the format > `base64url.bearer.authorization.`" (https://github.com/kubernetes/apiserver/blob/master/pkg/authentication/request/websocket/protocol.go) Der Handler verlangt mindestens ein weiteres Subprotocol, entfernt bei Erfolg das Token-Subprotocol und echot die verbleibenden zurück. Motivation aus dem ursprünglichen PR: „Browser clients do not have the ability to set an Authorization header programmatically on websocket requests" (https://github.com/kubernetes/kubernetes/pull/47740). AWS AppSync macht dasselbe mit `header-` zusätzlich zu `graphql-ws`. **Log-Verhalten — hier kippt der Vorteil:** - nginx / Apache: `Sec-WebSocket-Protocol` steht **nicht** im Default-Format. - **Caddy: der Header wird per Default vollständig geloggt** — `request.headers` ist eine komplette Map, und `Sec-WebSocket-Protocol` steht **nicht** auf der Redaction-Liste. Bei Caddy ist dieser Weg damit **schlechter** als `Authorization: Bearer`. **IANA:** Subprotocol-Namen sind „First Come First Served" registriert (https://www.iana.org/assignments/websocket/websocket.xhtml); ein Token-Präfix wie das von Kubernetes ist **nicht** registriert. Registrierung ist optional, Kollisionsrisiko theoretisch. | | | |---|---| | **Pro** | Funktioniert in der Browser-API ohne Custom-Header; landet nicht in nginx-/Apache-Default-Logs; von Kubernetes und AWS AppSync in Produktion belegt. | | **Contra** | Erzwingt das Zwei-Subprotokoll-Konstrukt, sonst schlägt der Browser-Handshake fehl (WHATWG, strenger als RFC); Base64url-Padding muss weg (`=` ist Separator); **bei Caddy per Default im Log**; Proxy-Header-Längenlimits gelten (nginx `large_client_header_buffers`, Default 4×8k — **nicht verifiziert**, vor einer Entscheidung prüfen). | ### 1.4 Option C — Cookie **Cookies fließen beim Handshake automatisch mit**, normativ: WHATWG „establish a WebSocket connection" setzt den Request auf „mode is 'websocket', credentials mode is **'include'**" (https://websockets.spec.whatwg.org/#establish-a-websocket-connection) — auch cross-origin. Genau daraus entsteht **CSWSH** (Cross-Site WebSocket Hijacking). RFC 6455 §10.2 zur Origin-Prüfung, und wörtlich zur Grenze des Modells: > „when the WebSocket Protocol is used by a dedicated client directly […] the origin model is > not useful, as the client can provide any arbitrary origin string." (https://datatracker.ietf.org/doc/html/rfc6455#section-10.2) RFC 6455 §4.1: „This header field is sent by browser clients; for non-browser clients, this header field may be sent if it makes sense in the context of those clients." OWASP WSTG-CLNT-10: „It is the server's responsibility to verify the `Origin` header in the initial HTTP WebSocket handshake." (https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/10-Testing_WebSockets) Microsoft SignalR Security dagegen: „The protections provided by CORS don't apply to WebSockets." und „The `Origin` header is controlled by the client and, like the `Referer` header, can be faked. These headers should **not** be used as an authentication mechanism." **Gilt SameSite für WS-Handshakes? — Ableitung, nicht wörtlich belegt.** draft-ietf-httpbis-rfc6265bis §5.6.7.1 lässt Lax nur gelten, „if and only if they are top-level navigations which use a 'safe' […] HTTP method" (https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis). Ein WS-Handshake ist keine Top-Level-Navigation → Lax und Strict blocken den cross-site Handshake. **Das Wort „WebSocket" kommt in rfc6265bis nicht vor.** Die Aussage stützt sich zusätzlich auf das OWASP WebSocket Security Cheat Sheet („Use SameSite cookies […] to strengthen CSWSH defenses", https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) — diese Seite trägt allerdings **kein verifizierbares Datum**. **Tauri-spezifische Komplikation — zwei getrennte Cookie-Stores:** - WebView hat eine eigene Cookie-Jar; Tauri v2.4.0 führte `Webview::cookies()` / `cookies_for_url()` ein (https://v2.tauri.app/release/tauri/v2.4.0/). - `tauri-plugin-http` nutzt den reqwest-Store — dokumentiertes offenes Problem, dass beide nicht synchron sind: https://github.com/tauri-apps/tauri/issues/13045 - Origin im Tauri-WebView ist ein Custom-Scheme (`tauri://localhost`, unter Windows `http://tauri.localhost`) — Origin-Allowlisting muss das kennen: https://github.com/tauri-apps/tauri/discussions/4912 - `tokio-tungstenite` hat **keine** Cookie-Jar; Cookies müssten manuell als Header gesetzt werden. | | | |---|---| | **Pro** | `HttpOnly` schützt gegen XSS-Auslesen; wird per Default nicht geloggt (Caddy redigiert explizit); im Browser vollautomatisch. | | **Contra** | CSWSH — CORS greift nicht, Origin-Prüfung ist die einzige Verteidigung und ist bei Nicht-Browser-Clients wertlos (RFC 6455 §10.2 sagt das selbst); SameSite-Wirksamkeit ist abgeleitet, nicht wörtlich belegt; in Tauri zwei inkonsistente Cookie-Stores; im Rust-Client-Layer gar kein natürliches Konstrukt; passt schlecht zum Multi-Server-Modell der Map (N Server = N Cookie-Domains). | ### 1.5 Option D — Erstes Auth-Frame nach dem Handshake **Wer macht das, mit belegten Timeouts und Close-Codes:** | System | Ablauf | Timeout | Fehler-Code | Quelle | |---|---|---|---|---| | **graphql-ws** | `ConnectionInit` mit `payload` → `ConnectionAck` | `connectionInitWaitTimeout`, `@default 3_000` (3 s) | `4408` Init-Timeout; `4401` Unauthorized, `4403` Forbidden (empfohlen) | https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md | | **Centrifugo** | `connect`-Command mit `{"token":""}` | `client.stale_close_delay`, Default `"10s"` | `3502 "stale"`, `3500 "invalid token"` (terminal) | https://centrifugal.dev/docs/server/codes | | **Home Assistant** | Server: `auth_required` → Client: `{"type":"auth","access_token":…}` → `auth_ok`/`auth_invalid` | nicht dokumentiert | — | https://developers.home-assistant.io/docs/api/websocket/ | | **Discord Gateway** | Nach `Hello` (op 10) sendet der Client `Identify` (op 2) | nicht dokumentiert | `4003` Not authenticated (reconnect **true**), `4004` Authentication failed (reconnect **false**) | https://docs.discord.com/developers/events/gateway | | **Pusher Channels** | `pusher:signin` über den offenen Socket | impliziert | `4009` (Bereich 4000–4099 = „SHOULD NOT be re-established unchanged") | https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/ | | **Socket.IO** | `auth`-Option → `socket.handshake.auth.token`, Middleware vor Connect | — | „the connection will be refused and the client will receive a `connect_error` event" | https://socket.io/docs/v4/middlewares/ | **Korrektur zu einer verbreiteten Annahme:** *Phoenix Channels* wird oft als Beispiel für dieses Muster genannt, ist aber keines — Phoenix übergibt den Token als Socket-**Param**, also im Query-String (`new Socket("/socket", {authToken: window.userToken})`, geprüft in `connect/3` via `Phoenix.Token.verify(…, max_age: 1209600)`, https://phoenix.hexdocs.pm/channels.html). Als Beleg für Option D nicht verwendbar. python-websockets bewertet diesen Weg als „fully reliable and the most secure mechanism", merkt aber an, dass Auth damit auf der **Anwendungsschicht** stattfindet, nach abgeschlossenem Handshake (https://websockets.readthedocs.io/en/stable/topics/authentication.html). | | | |---|---| | **Pro** | Token verlässt nie die HTTP-Schicht → **landet in keinem Access-Log**, egal welcher Proxy; funktioniert in Browser-API und Rust-Client identisch; erlaubt strukturierte Fehler statt `1006`; derselbe Kanal trägt später den Re-Auth (§2.2). | | **Contra** | Unauthentifizierte Sockets belegen Ressourcen bis zum Timeout → DoS-Fläche (genau dagegen existieren graphql-ws' 3 s und Centrifugos `3502 "stale"`); kein HTTP-401 mehr möglich, nur Close-Codes; Auth wandert in die Applikationsschicht und muss dort selbst gegen Missbrauch gehärtet werden (Centrifugo hat dafür eigene Codes `3013 too many requests`, `3509 too many errors`). | ### 1.6 Option E — Rust-Client mit voller Header-Kontrolle (`Authorization: Bearer`) Wenn die Verbindung im Tauri-Rust-Layer lebt (siehe Ticket *Wo lebt die WS-Verbindung im Client*), entfällt die Browser-Beschränkung vollständig. `tungstenite::client::IntoClientRequest` ist implementiert für `&str`, `String`, `Uri`, `Url`, **`http::Request<()>`**, `httparse::Request`, `ClientRequestBuilder` (https://docs.rs/tungstenite/latest/tungstenite/client/trait.IntoClientRequest.html). `ClientRequestBuilder` (https://docs.rs/tungstenite/latest/tungstenite/client/struct.ClientRequestBuilder.html): > „Builder for a custom `IntoClientRequest` with options to add custom additional headers and > sub protocols." > `with_header()`: „Adds (`key`, `value`) as an additional header to the handshake request" > `with_sub_protocol()`: „Adds `protocol` to the handshake request subprotocols" Praxis-Pattern: ```rust let mut request = "wss://api.example.com/ws".into_client_request()?; request.headers_mut().insert("Authorization", "Bearer token123".parse()?); let (ws_stream, response) = connect_async(request).await?; ``` **Fehlerdetails sind hier verfügbar** — `tungstenite::Error::Http(Box>>>)` trägt Statuscode und Body (https://docs.rs/tungstenite/latest/tungstenite/error/enum.Error.html). Ein 401 mit strukturiertem JSON-Grund ist auswertbar; im WebView nicht (siehe Vorbemerkung). **Auch aus dem React-Frontend erreichbar, ohne eigenen Rust-Command:** `tauri-plugin-websocket` — „Open a WebSocket connection using a Rust client in JavaScript" (https://v2.tauri.app/plugin/websocket/). Die `ConnectionConfig` enthält laut Referenz das Feld **`headers?`** neben `maxMessageSize`, `maxFrameSize` etc. (https://v2.tauri.app/reference/javascript/websocket/). Preis: es ist nicht die Browser-`WebSocket`-API — kein `WebSocket`-Objekt, keine `wasClean`-Semantik, IPC-Bridge im Datenpfad. *(Der exakte Feldtyp und das Verhalten bei verbotenen Headern wurden nicht verifiziert.)* **Proxy-Einschränkung, relevant für Firmenkunden:** tokio-tungstenite bringt **keinen** eigenen HTTP-/SOCKS-Proxy-Support mit — `connect_async` ist nur mit „Connect to a given URL" dokumentiert (https://docs.rs/tokio-tungstenite/). Ein CONNECT-Tunnel muss selbst aufgebaut und der fertige Stream an `client_async` übergeben werden. | | | |---|---| | **Pro** | Der einzige Weg, der von RFC 6750 §2.3 ausdrücklich als der richtige benannt wird; **wird von nginx und Apache per Default nicht geloggt, von Caddy explizit redigiert**; volle Fehlerdetails (401 mit Body) statt `1006`; keine Zwei-Subprotokoll-Akrobatik. | | **Contra** | Setzt voraus, dass die Verbindung im Rust-Layer lebt (Kopplung an ein anderes Ticket); kein Proxy-Support in tokio-tungstenite → CONNECT-Tunnel selbst bauen, was bei Firmenkunden mit expliziter Proxy-Konfiguration Arbeit ist; über `tauri-plugin-websocket` verliert man die Browser-`WebSocket`-Semantik im Frontend. | ### 1.7 Querschnitt-Option — kurzlebige Einweg-„Ticket"-Tokens Nicht exklusiv zu A–E, sondern **kombinierbar**: ein Ticket per authentifiziertem REST-Call holen und dieses (statt des langlebigen JWT) an den Handshake hängen. - **Azure Web PubSub „Client Access URL"**, Lebensdauer explizit konfigurierbar (`expirationTimeInMinutes: 5`): > „The client negotiates with your application server. The application server contains the > authorization middleware, which handles the client request and signs a JWT for the client to > connect to the service. […] The client tries to connect to the Web PubSub service by using > the URL and the JWT returned from the application server." (https://learn.microsoft.com/en-us/azure/azure-web-pubsub/howto-generate-client-access-url, `ms.date: 2024-10-17`) - **Slack RTM:** „The Websocket URLs provided by `rtm.connect` are single-use and are only valid for **30 seconds**" (https://docs.slack.dev/legacy/legacy-rtm-api/) — Achtung, RTM ist als Legacy markiert. - **Ably** — Token-Request-Flow via `authUrl`/`authCallback` (https://ably.com/docs/auth/token). - python-websockets nennt Single-Use-Tokens ausdrücklich als Mitigation für den Query-Param-Weg. **Es gibt dafür keine RFC-/OWASP-Quelle** — das ist Herstellerpraxis, kein Standard. | | | |---|---| | **Pro** | Reduziert den Schaden eines Log-Leaks von „Sitzungsdauer" auf „Sekunden bis Minuten"; entkoppelt das langlebige JWT vom Handshake-Kanal; macht Option A dadurch deutlich weniger heikel. | | **Contra** | Zusätzlicher REST-Roundtrip vor jedem (Re-)Connect — verschärft den Thundering Herd (§4); zusätzlicher serverseitiger Zustand (Einmal-Verbrauch, Ablauf) — steht in Spannung zur „eine Binary + Postgres"-Regel der Map; kein Standard, jede Implementierung ist eigen. | ### 1.8 Close-Codes für Auth-Fehler IANA-Registry (https://www.iana.org/assignments/websocket/websocket.xhtml), Policy: 1000–2999 „Standards Action, Specification Required or IESG Review"; **3000–3999 „First Come First Served"; 4000–4999 „Reserved for Private Use"**. RFC 6455 §7.4.2 formuliert dasselbe. Registriert und relevant: `1008 Policy Violation`, `1011 Internal Error`. Im 3000er-Bereich existieren `3000 Unauthorized`, `3003 Forbidden`, `3008 Timeout` — **alle ohne Reference-Spalte**, also private First-Come-First-Served-Einträge ohne Spezifikation, keine Standardsemantik. **Etablierte Praxis im privaten Bereich:** | System | Codes | |---|---| | graphql-ws | `4400` Bad Request, `4401` Unauthorized, `4403` Forbidden, `4408` Init-Timeout, `4429` Too many init requests | | Discord | `4003` Not authenticated (reconnect true), **`4004` Authentication failed (reconnect false)**, `4005` Already authenticated | | Pusher | Bereichs-Semantik: **4000–4099** „SHOULD NOT be re-established unchanged"; **4100–4199** „SHOULD be re-established after backing off"; **4200–4299** „SHOULD be re-established immediately" | | Centrifugo | nicht-terminal: `3005` connection expired, `3011` force reconnect. Terminal: `3500` invalid token, `3502` stale, `3507` permission denied | **Musterbefund:** Alle vier Systeme kodieren nicht nur *warum*, sondern auch **ob der Client reconnecten darf**. Das ist die tragende Semantik, nicht die Fehlerbenennung — und sie ist die Voraussetzung dafür, dass §4.4 (Reconnect-Klassen) überhaupt funktioniert. --- ## 2. Token-Ablauf bei stundenlanger Verbindung ### 2.1 Die Grundsatzfrage: gilt Auth nur beim Handshake? Die klarste Primärquelle ist Microsofts SignalR-Doku (https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz, `updated_at: 2026-08-12`): > „SignalR captures the authenticated user when a connection is established and caches it for the > lifetime of the connection. […] **SignalR doesn't automatically revalidate the user during the > life of the connection, regardless of the authentication scheme.** This behavior applies to all > schemes, including cookie authentication and bearer token authentication." > „Changes to a user's identity, roles, or claims that occur after the connection is established > aren't reflected on an existing connection." > „If a token expires during the lifetime of a connection, by default the connection continues to > work." Konkretes Szenario aus derselben Seite: Nutzer verliert die `Editor`-Rolle → „The user can keep calling `Editor`-only hub methods until the connection is closed." Die dort genannten Gegenmittel sind wörtlich: > „Close affected connections so that clients reconnect and reauthenticate. For bearer token > authentication, the `CloseOnAuthenticationExpiration` option closes connections when the > authentication token expires." > „Perform authorization checks in hub methods against current data, such as the user's current > roles or claims from a data store, instead of relying only on the cached principal." OWASP WebSocket Security Cheat Sheet (kein verifizierbares Datum): > „Close WebSocket connections when sessions expire. Re-validate user sessions periodically > (every 30 minutes is common)…" > „Rotate tokens in long-lived connections to prevent hijacked sessions from persisting." > „When users log out, close all their WebSocket connections immediately." > „Don't assume WebSocket connection equals unlimited access. Check authorization for each action." **Negativer Befund:** Auth0/Okta haben **keine** auffindbare WebSocket-spezifische Dokumentation zum Token-Ablauf. Alle Treffer waren Community-Inhalte. Nicht als Beleg verwenden. ### 2.2 Option A — Verbindungs-TTL / erzwungener Reconnect **AWS API Gateway WebSocket API**, harte, **nicht erhöhbare** Quotas (https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-execution-service-websocket-limits-table.html): | Quota | Wert | Erhöhbar | |---|---|---| | Connection duration for WebSocket API | **2 Stunden** | Nein | | Idle Connection Timeout | **10 Minuten** | Nein | | WebSocket frame size | 32 KB | Nein | | Message payload size | 128 KB | Nein | **AWS AppSync Realtime:** „AWS AppSync keeps sending these messages and servicing the registered subscriptions until it shuts down the connection automatically (**after 24 hours**)" (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html). | | | |---|---| | **Pro** | Einfachste denkbare Implementierung — ein Timer pro Verbindung; Auth-Zustand kann nie länger gültig sein als das Token; Revocation greift spätestens nach einer TTL, ohne dass der Server eine Liste führen muss. | | **Contra** | **Multipliziert die Anzahl der Handshakes** — bei 2 h TTL sind das 12 Handshakes pro Tag pro Client statt einem. In Kombination mit Option 1.2 (Query-String) heißt das 12 Log-Zeilen mit Token statt einer. Das ist die direkte Wechselwirkung zwischen §1 und §2: **kurze TTL verschlimmert den Query-String-Nachteil linear.** Zusätzlich: jeder erzwungene Reconnect ist ein Backfill-Zyklus (`?since`) und, wenn alle TTLs synchron ablaufen, ein selbstgemachter Thundering Herd (§4.3). | ### 2.3 Option B — In-Band-Re-Auth über den offenen Socket | System | Mechanismus | Semantik | Quelle | |---|---|---|---| | **Centrifugo** | `refresh`-Command (und `sub_refresh` für Subscriptions): „Renew an expiring connection / subscription token." Client bekommt beim Connect ein `ttl`; „Centrifugo SDKs handle this `ttl` internally and automatically begin the refresh process." Nach Ablauf: Grace Period (Default 25 s), dann Disconnect `3005 connection expired` | Verbindung bleibt bestehen | https://centrifugal.dev/docs/server/authentication | | **Ably** | `authUrl`/`authCallback`: „the SDK automatically: 1. Calls your auth endpoint when connecting 2. Requests a new token **before** the current token expires 3. Maintains the connection seamlessly during refresh". `authorize()` „maintains the existing connection while updating permissions". Ohne `authUrl`/`authCallback`: „if your token expires and an SDK has no way to get a new token […] the connection will enter the `FAILED` state" | Verbindung bleibt bestehen | https://ably.com/docs/auth/token | | **AWS AppSync** | Authorization wird **pro Subscription-Registrierung** erneut mitgesendet (`start`-Message hat `extensions.authorization`) | Pro-Operation-Auth | https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html | | **SignalR** | `accessTokenFactory` wird „before **every** HTTP request made by SignalR" aufgerufen — bei WebSockets faktisch nur beim (Re-)Connect, da es nur einen Request gibt | de facto nur beim Reconnect | https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz | Ablys Fehlercodes: `40140` = „token error (unspecified)", Bereich `40140 ≤ code < 40150` = Token-Fehler, `40142` = „token expired"; dokumentierte Client-Regel: **einen** automatischen Reissue-Versuch, dann Request wiederholen (https://github.com/ably/ably-common/blob/main/protocol/errors.json). | | | |---|---| | **Pro** | Das neue Token wird **im WS-Frame** übertragen und landet damit in **keinem** Access-Log — der wesentliche Vorteil gegenüber reconnect-basiertem Refresh im gegebenen Bedrohungsmodell; keine Verbindungsabbrüche, kein Backfill-Zyklus, kein Reconnect-Sturm. | | **Contra** | Application-Layer-Auth statt HTTP-Statuscode; der Server muss den Refresh-Pfad selbst gegen Missbrauch härten (Rate-Limiting — Centrifugo braucht dafür eigene Codes `3013 too many requests`, `3509 too many errors`); zusätzliche Protokoll-Nachrichten im ansonsten als „Dumb Pipe" gesetzten Kanal (siehe Map: die Naht verläuft entlang *persistiert vs. flüchtig* — ein Re-Auth-Frame ist weder das eine noch das andere und braucht eine Einordnung). | ### 2.4 Option C — Serverseitige Revocation-Liste Firebase (https://firebase.google.com/docs/auth/admin/manage-sessions): > „Firebase ID tokens are short lived and last for an hour; the refresh token can be used to > retrieve new ID tokens." > „Because Firebase ID tokens are stateless JWTs, you can determine a token has been revoked only > by requesting the token's status from the Firebase Authentication backend." Revocation-Check ist dort **opt-in** (`verifyIdToken(idToken, checkRevoked)`); für die Realtime Database wird stattdessen ein Security-Rules-Muster dokumentiert (`auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()`). **Die Doku sagt nirgends, dass RTDB/Firestore bei Revocation eine bestehende Verbindung terminiert.** | | | |---|---| | **Pro** | Einziger Mechanismus, der auf „Logout auf allen Geräten" und Gerätediebstahl *sofort* reagiert, ohne die TTL abzuwarten; erlaubt lange Verbindungen ohne kurze Token-TTL. | | **Contra** | **Im Bedrohungsmodell dieses Projekts per Definition wirkungslos gegen den Betreiber** — er kontrolliert die Liste. Sie adressiert ausschließlich „gestohlenes Token bei ansonsten korrektem Server", also ein anderes Bedrohungsmodell, das im Entscheidungsticket getrennt geführt werden sollte. Praktisch: serverseitiger Zustand bei jedem Check (DB-Lookup pro Frame wäre teuer, Cache bringt Konsistenzfenster zurück). | ### 2.5 Randnotiz: RFC 8441 (WebSockets über HTTP/2) https://datatracker.ietf.org/doc/html/rfc8441 — Extended CONNECT mit `:protocol = "websocket"`, ausgehandelt via `SETTINGS_ENABLE_CONNECT_PROTOCOL`. Für Auth ändert sich **nichts**: > „The Origin, Sec-WebSocket-Version, Sec-WebSocket-Protocol, and Sec-WebSocket-Extensions header > fields are used in the CONNECT request." > „Other HTTP request and response-header fields, such as those for manipulating cookies, may be > included […] as usual." > „The security considerations of [RFC6455], Section 10 continue to apply." `Sec-WebSocket-Key`/`Sec-WebSocket-Accept` entfallen. Axum 0.8 unterstützt das (siehe §5.1); relevant vor allem, weil sich unter H2 die Logging-Formate ändern (`:path` statt Request-Line) — **nicht separat verifiziert**. --- ## 3. Heartbeat ### 3.1 Protokoll-Ebene (RFC 6455) **§5.5 Control Frames:** > „All control frames MUST have a payload length of 125 bytes or less and MUST NOT be fragmented." **§5.5.2 Ping** (Opcode `0x9`): > „Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already > received a Close frame. It SHOULD respond with Pong frame as soon as is practical." > „An endpoint MAY send a Ping frame any time after the connection is established and before the > connection is closed. A Ping frame may serve either as a keepalive or as a means to verify that > the remote endpoint is still responsive." **§5.5.3 Pong** (Opcode `0xA`): > „A Pong frame sent in response to a Ping frame must have identical Application data as found in > the message body of the Ping frame being replied to." > „A Pong frame MAY be sent unsolicited. This serves as a unidirectional heartbeat." (https://www.rfc-editor.org/rfc/rfc6455.txt) Konsequenz: Der Ping-Payload (≤125 B) ist der einzige Korrelationsmechanismus auf Protokollebene — z. B. Sequenz-Counter oder Timestamp für RTT-Messung. Unsolicited Pong ist normkonform, erzeugt aber **keine Antwort** und taugt daher nur zum Idle-Timer-Reset, nicht zur Liveness-Prüfung. ### 3.2 Die Browser-Beschränkung — hart belegt WHATWG WebSockets Standard, §5 „Ping and Pong frames" (https://websockets.spec.whatwg.org/): > „The WebSocket protocol defines Ping and Pong frames that can be used for keep-alive, > heart-beats, network status probing, latency instrumentation, and so forth. **These are not > currently exposed in the API.** > User agents may send ping and unsolicited pong frames as desired […] User agents must not use > pings or unsolicited pongs to aid the server; it is assumed that servers will solicit pongs > whenever appropriate for the server's needs." Die IDL kennt nur `send()` und `close()` — kein `ping()`, kein `pong`-Event; MDN listet exakt diese zwei Instance Methods und erwähnt Ping/Pong-Frames überhaupt nicht (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket). Browser *antworten* auf Server-Pings automatisch (RFC-konform), zeigen die Auto-Pongs aber nicht in den DevTools; Chrome sendet selbst keine Ping-Frames, sondern nutzt `SO_KEEPALIVE` auf TCP-Ebene (Issue-Tracker, keine normative Doku: https://issues.chromium.org/issues/41309915, https://bugzilla.mozilla.org/show_bug.cgi?id=920074). **Konsequenz für Tauri:** Läuft die Verbindung im WebView (WebView2 / WKWebView / WebKitGTK, https://v2.tauri.app/reference/webview-versions/), ist ein **Application-Level-Heartbeat** (z. B. `{"type":"ping"}` als Text-Frame) der **einzige** Weg, clientseitig Liveness festzustellen. Genau deshalb bauen Socket.IO/Engine.IO eigene Ping-Packet-Typen statt RFC-6455-Control-Frames. Läuft sie im Rust-Layer, sind Protokoll-Pings verfügbar. **Negativer Befund:** Weder https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API noch „Writing WebSocket client applications" enthalten heute einen Abschnitt „Proxy servers and firewalls" oder überhaupt eine Erwähnung von Proxies/Ping-Pong. Die früher oft zitierte MDN-Passage existiert nicht mehr und ist nicht zitierfähig. ### 3.3 Rust-Ebene — automatisches Pong axum 0.8 `Message`-Doku (https://docs.rs/axum/0.8.9/axum/extract/ws/enum.Message.html), wörtlich: > `Ping`: „Ping messages will be automatically responded to by the server, so you do not have to > worry about dealing with them yourself." > `Pong`: „Pong messages will be automatically sent to the client if a ping message is received, > so you do not have to worry about constructing them yourself **unless you want to implement a > unidirectional heartbeat**." tungstenite (https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocket.html): > „upon receiving ping messages tungstenite queues pong replies automatically. […] This means you > should not respond to ping frames manually." > „Note that if `read` returns a ping, you should `flush` before passing a custom pong to `write`, > otherwise the automatic queued response to the ping will not be sent as it will be replaced by > your custom pong message." > (an `read`) „This will also queue responses to ping and close messages. These responses will be > written and flushed on the next call to `read`, `write` or `flush`." Drei praktische Fallstricke daraus: (a) Das Auto-Pong wird nur **gequeued** — ohne nachfolgendes `read`/`write`/`flush` geht es nicht raus (in einer dauerhaft gepollten Split-Stream-Schleife erfüllt); (b) der Handler *sieht* `Ping`/`Pong` trotzdem im Stream, muss aber nicht reagieren; (c) ein eigener Pong-Send kann das Auto-Pong verdrängen. ### 3.4 Warum TCP-Keepalive nicht reicht — vier belegte Gründe `man 7 tcp` (https://man7.org/linux/man-pages/man7/tcp.7.html): > `tcp_keepalive_time (integer; default: 7200)` — „The number of seconds a connection needs to be > idle before TCP begins sending out keep-alive probes. **Keep-alives are sent only when the > SO_KEEPALIVE socket option is enabled.** […] **Note that underlying connection tracking > mechanisms and application timeouts may be much shorter.**" 1. **Per Default aus** — greift erst nach explizitem `SO_KEEPALIVE`. 2. **7200 s ist länger als jeder Proxy-Timeout** aus der Tabelle in §3.5 (30 s … 600 s). Der erste Probe kommt, wenn die Verbindung längst tot ist. 3. **Keepalive-Probes tragen keine Nutzdaten** und resetten L7-Idle-Timer nicht. AWS Classic ELB wörtlich: „TCP keep-alive probes do not prevent the load balancer from terminating the connection because they do not send data in the payload." (https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/config-idle-timeout.html) 4. **L7-Terminierung trennt die Verbindungen** — nginx/ALB/AppGW halten zwei getrennte TCP-Sessions; ein Keepalive auf dem Client-Leg erreicht das Origin nie. Gegenprobe: Bei L4-Load-Balancern (NLB, TCP-Listener) *resetten* TCP-Keepalives den Idle-Timer. HTTP/2-PING-Frames zählen beim ALB dagegen explizit **nicht**: „Application Load Balancers do not support HTTP/2 PING frames. These do not reset the connection idle timeout." (https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html) ### 3.5 Verhalten hinter Proxies und Corporate-Firewalls **Der härteste Beleg für „wss statt ws" — RFC 6455 §1.8:** > „At the time of writing of this specification, it should be noted that **connections on ports > 80 and 443 have significantly different success rates, with connections on port 443 being > significantly more likely to succeed**, though this may change with time." **RFC 6455 §4.1 zum expliziten Proxy:** > „If the client is configured to use a proxy when using the WebSocket Protocol to connect to host > /host/ and port /port/, then the client SHOULD connect to that proxy and ask it to open a TCP > connection" (Beispiel: `CONNECT example.com:80 HTTP/1.1`, optional mit `Proxy-authorization`) > „Implementations […] are encouraged to use a SOCKS5 [RFC1928] proxy for WebSocket connections, > if available, or failing that, **to prefer the proxy configured for HTTPS connections over the > proxy configured for HTTP connections**." **RFC 6455 §10.3** erklärt, warum Client-Masking überhaupt existiert: damit Intermediaries WS-Frames nicht als HTTP-Requests fehlinterpretieren („a client MUST mask all frames that it sends to the server […] whether or not the WebSocket Protocol is running over TLS"). **Idle-Timeouts der Infrastruktur — die Zahlen, die das Heartbeat-Intervall bestimmen:** | Hop | Default | Konfigurierbar | Quelle | |---|---|---|---| | **Azure Application Gateway request timeout** (gilt für die WS-Session!) | **20 s** | 1–86400 s | https://learn.microsoft.com/en-us/azure/application-gateway/application-gateway-websocket | | **GCP ALB backend service timeout** | **30 s** | 1 s–… | https://docs.cloud.google.com/load-balancing/docs/https/request-distribution | | **nginx `proxy_read_timeout`** | **60 s** | ja | https://nginx.org/en/docs/http/ngx_http_proxy_module.html | | **AWS ALB / Classic ELB idle** | **60 s** | 1–4000 s | https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html | | **Azure Load Balancer TCP idle** | **4 min** | 4–100 min | https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-tcp-idle-timeout | | **AWS NLB TCP/TLS** | **350 s** | 60–6000 s (TCP) | AWS-Doku | | **AWS API Gateway WS idle** | **10 min** | **nein** | s. §2.2 | | **AWS API Gateway WS max. Dauer** | **2 h** | **nein** | s. §2.2 | | **GCP global ext. ALB, aktive WS** | **24 h** (fix) | nein | GCP-Doku | | **Linux `tcp_keepalive_time`** | **7200 s** | ja | `man 7 tcp` | nginx, Schlusssatz der WebSocket-Seite (https://nginx.org/en/docs/http/websocket.html), wörtlich: > „By default, the connection will be closed if the proxied server does not transmit any data > within 60 seconds. This timeout can be increased with the `proxy_read_timeout` directive. > Alternatively, **the proxied server can be configured to periodically send WebSocket ping frames > to reset the timeout and check if the connection is still alive.**" Azure Application Gateway, wörtlich — der einzige Anbieter, der eine konkrete Faustregel für das Heartbeat-Intervall nennt: > „Ensure that your timeout value is greater than your server-defined ping/pong interval to avoid > experiencing timeout errors before a ping is sent from the client. **A typical value for a > WebSocket is 20 seconds, so, for example, a timeout value of 40 seconds** ensures that the > gateway doesn't send a timeout error before the client sends a ping. Otherwise, this condition > throws a **1006 error** on the client side." GCP, wichtige Unterscheidung: > Global external ALB: „Active websocket connections don't use the configured backend service > timeout […] closed after 24 hours (86,400 seconds). This 24-hour limit is fixed […] **Idle > websocket connections are closed after the backend service times out.**" > Classic ALB: „Websocket connections, **whether idle or active**, automatically close after the > backend service times out." (Default 30 s!) **Cloudflare — Korrektur einer verbreiteten Zahl:** Die kursierenden „100 Sekunden" stehen in **keiner** Cloudflare-Primärquelle. https://developers.cloudflare.com/network/websockets/ dokumentiert nur qualitativ: > „Cloudflare will close a WebSocket connection when no data is transmitted in either direction > for a period of time. Enterprise customers can contact their account team to configure a custom > idle timeout. **To keep long-lived connections alive during periods of inactivity, implement a > client-side heartbeat (ping/pong) mechanism.**" > „When Cloudflare releases new code to its global network, we may restart servers, **which > terminates WebSockets connections.**" **HAProxy — Korrektur:** Es gibt **keine** dokumentierten numerischen Defaults für `timeout client`/`server`/`tunnel`; die oft zitierten „50s" stammen aus Distro-Beispiel-Configs. Die Doku (https://docs.haproxy.org/3.0/configuration.html §4.2) sagt zu `timeout tunnel`: > „This timeout **supersedes both the client and server timeouts** once the connection becomes a > tunnel. […] In HTTP, this timeout is used when a connection is upgraded (e.g. when switching to > the WebSocket protocol…)" — Doku-Beispiel: `timeout tunnel 1h` **Tauri-spezifisch:** Der System-WebView respektiert die OS-Proxy-Konfiguration; eine Tauri-API zum Setzen von Proxy-Einstellungen fehlte lange (https://github.com/tauri-apps/tauri/issues/4263, wry-Limitierung). Bei WebView2 lässt sich `--proxy-server` über `AdditionalBrowserArguments` setzen. Für den Rust-seitigen WS-Client gilt §1.6: CONNECT-Tunnel selbst bauen. ### 3.6 Übliche Intervalle etablierter Systeme | System | Intervall | Timeout | Quelle | |---|---|---|---| | **Socket.IO v4** | `pingInterval` **25000 ms** | `pingTimeout` **20000 ms**, `connectTimeout` 45000 ms | https://socket.io/docs/v4/server-options/ | | **Phoenix JS** | `heartbeatIntervalMs` **30000 ms** | `DEFAULT_TIMEOUT` 10000 ms | https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js | | **Centrifugo** | `client.ping_interval` **„25s"** | `client.pong_timeout` **„8s"**, `stale_close_delay` „10s" | https://centrifugal.dev/docs/server/configuration | | **Discord Gateway** | server-diktiert via `heartbeat_interval` (Doku-Beispiel `45000`) | kein ACK → Reconnect | https://docs.discord.com/developers/events/gateway | | **Slack RTM (legacy)** | „every few seconds" | — | https://docs.slack.dev/legacy/legacy-rtm-api/ | | **MQTT v5.0** | `Keep Alive` (client-gewählt) | **1,5 × Keep Alive** | https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html (07.03.2019) | Socket.IO, wörtlich — die klarste Formulierung der beidseitigen Erkennung: > „The server sends a ping packet every `pingInterval` ms, and if the client does not answer with > a pong within `pingTimeout` ms, the server considers that the connection is closed. Similarly, > if the client does not receive a ping packet from the server within > `pingInterval + pingTimeout` ms, then the client also considers that the connection is closed." > Warnung: „Using a small value like `1000` (one heartbeat per second) will incur some load on > your server, which might become noticeable with a few thousands connected clients." Discord, wörtlich — **Jitter schon beim ersten Heartbeat**: > „your app should wait `heartbeat_interval * jitter` where `jitter` is any random value between > 0 and 1, then send its first Heartbeat." > „If a client does not receive a heartbeat ACK between its attempts at sending heartbeats, this > may be due to a failed or 'zombied' connection. The client should **immediately terminate the > connection with any close code besides `1000` or `1001`**, then reconnect and attempt to Resume." MQTT v5.0 §3.1.2.10: > „If the Keep Alive value is non-zero and the Server does not receive an MQTT Control Packet from > the Client within one and a half times the Keep Alive time period, it MUST close the Network > Connection" Slack RTM zeigt zusätzlich das Latenz-Muster: „a client could include a local timestamp in the ping message so it can calculate round-trip latency" (Server echot die Felder zurück) — das entspricht der RFC-6455-Payload-Korrelation aus §3.1. **Korrektur:** Der oft genannte Discord-Wert **41250 ms** ist nicht dokumentiert; die aktuelle Doku zeigt `45000` im Beispiel. Der Wert ist server-diktiert und nicht garantiert. ### 3.7 Optionen-Gegenüberstellung Heartbeat | Option | Pro | Contra | |---|---|---| | **Protokoll-Ping/Pong (RFC 6455)** | Vom Standard vorgesehen; von axum/tungstenite serverseitig **automatisch** beantwortet, also kein Applikationscode für die Antwort; Payload-Korrelation für RTT; resettet L7-Idle-Timer, weil es echte Frames sind (im Gegensatz zu TCP-Keepalive) | **Im Browser-WebView nicht auslösbar und nicht beobachtbar** (WHATWG: „not currently exposed in the API") → clientseitige Liveness-Erkennung unmöglich; unsolicited Pong erzeugt keine Antwort und beweist nichts | | **Application-Level-Heartbeat (JSON-Frame)** | Funktioniert in WebView und Rust-Layer identisch; beidseitige Liveness-Erkennung; kann Nutzlast tragen (Cursor, Server-Zeit, `reconnect_after`-Hinweis); von Socket.IO, Phoenix, Centrifugo, Discord durchgehend so gebaut | Zusätzliche Protokoll-Nachrichten im als „Dumb Pipe" gesetzten Kanal; kostet Serverlast bei vielen Verbindungen (Socket.IO warnt explizit ab ~1 s Intervall bei „a few thousands connected clients"); doppelt sich mit dem Protokoll-Ping, wenn beides läuft | | **Nur TCP-Keepalive** | Kein Applikationscode | Vier belegte Gründe dagegen (§3.4) — Default 7200 s, per Default aus, kein L7-Timer-Reset, greift nicht über L7-Terminierung hinweg | **Intervall-Korridor aus den Zahlen:** Die kleinsten dokumentierten Infrastruktur-Timeouts liegen bei 20 s (Azure AppGW) bzw. 30 s (GCP Classic ALB Default). Die etablierten Application-Heartbeats liegen bei 25–30 s. Azures eigene Faustregel („typical value for a WebSocket is 20 seconds […] timeout value of 40 seconds") beschreibt dasselbe Verhältnis von etwa 1:2 zwischen Heartbeat und Idle-Timeout. --- ## 4. Reconnect-Strategie ### 4.1 Exponentielles Backoff mit Jitter — die Referenz **Marc Brooker, „Exponential Backoff And Jitter", AWS Architecture Blog, 4. März 2015** (https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/), mit Autoren-Update: *„Update (May 2023): After 8 years, this solution continues to serve as a pillar for how Amazon builds remote client libraries for resilient systems."* **Verifikationshinweis:** Die Formeln stehen im Artikel nicht als Text, sondern als PNGs mit leerem `alt`-Attribut. Die kursierenden Textvarianten (`random(...)`, `tmp = ...`) sind falsch. Aus den Bildern ausgelesen: ``` # Capped Exponential Backoff ("No Jitter"), Figure 3 sleep = min(cap, base * 2 ** attempt) # Full Jitter, Figure 6 sleep = random_between(0, min(cap, base * 2 ** attempt)) # Equal Jitter, Figure 10 temp = min(cap, base * 2 ** attempt) sleep = temp / 2 + random_between(0, temp / 2) # Decorrelated Jitter, Figure 11 sleep = min(cap, random_between(base, sleep * 3)) ``` **Zweiter Verifikationshinweis:** Der Artikel enthält **keine Ergebnistabelle** — nur 13 Grafiken und Fließtext. Die quantitativen Aussagen sind qualitativ formuliert: > „With N clients contending, the total amount of work done by the system increases with N²." > „In the case with 100 contending clients, we've reduced our call count by more than half." > „**The no-jitter exponential backoff approach is the clear loser.** It not only takes more work, > but also takes more time than the jittered approaches." > „Of the jittered approaches, '**Equal Jitter' is the loser.** It does slightly more work than > 'Full Jitter', and takes much longer. The decision between 'Decorrelated Jitter' and 'Full > Jitter' is less clear. **The 'Full Jitter' approach uses less work, but slightly more time.**" > „It's worth noting that none of these approaches fundamentally change the N² nature of the work > to be done, but do substantially reduce work at reasonable levels of contention. **The return on > implementation complexity of using jittered backoff is huge, and it should be considered a > standard approach for remote clients.**" Simulator-Code: https://github.com/awslabs/aws-arch-backoff-simulator ### 4.2 Weitere Anbieter-Formeln und -Regeln **Google Cloud, „truncated exponential backoff"** (https://docs.cloud.google.com/memorystore/docs/redis/exponential-backoff, Stand 2026-07-29): > „The wait time is `min(((2^n)+random_number_milliseconds), maximum_backoff)`, with `n` > incremented by 1 for each iteration (request)." > „`random_number_milliseconds` is a random number of milliseconds less than or equal to 1000. > **This helps to avoid cases where many clients get synchronized by some situation and all retry > at once, sending requests in synchronized waves.**" > `maximum_backoff` „is typically **32 or 64 seconds**." > „It's okay to continue retrying once you reach the `maximum_backoff` time." Konkrete GCP-Client-Library-Defaults (https://docs.cloud.google.com/storage/docs/retry-strategy): Go 1 s / ×2.0 / max 30 s; Java 1 s / ×2.0 / max 32 s / total 50 s; Node.js 1 s / ×2 / max 64 s / total 600 s. **AWS SDK Retry Behavior** (https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) — Achtung, die Seite beschreibt inzwischen das 2026er-Verhalten (Opt-in via `AWS_NEW_RETRIES_2026=true`): `retry_mode` = **standard**, `max_attempts` = **3**. Formel wörtlich: `delay = random(0, 1) × min(20,000 ms, base_delay × 2^retry)` — „The SDK uses **exponential backoff with full jitter**." Base delay: transient **50 ms**, throttling **1000 ms**. Zusätzlich ein **Retry-Quota-Token-Bucket** (500 Tokens, 14 pro transientem Retry, 5 pro Throttling-Retry, +1 bei Erfolg ohne Retry) — d. h. Retries sind global gedeckelt, nicht nur pro Request. **Azure Transient-Fault-Guidance** (https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults, `ms.date: 2026-02-26`) — die dichteste Sammlung harter Regeln: > „**Add jitter, which is a small random delay, to each retry interval to prevent multiple clients > from syncing their retries and creating load spikes on the target service.**" > „**Use an exponential back-off strategy with jitter for background operations, and use immediate > or regular interval retry strategies for interactive operations.**" > „**Never implement an endless retry mechanism.** This approach typically prevents the resource or > service from recovering from overload situations." > „**Never do an immediate retry more than once.**" > „Implement a **retry budget** to limit the total number of retries across all requests within a > process or service… For example, you might allow a process to do no more than **60 retries per > minute** against a specified dependency." > „When a response includes a `Retry-After` header, follow it and wait at least the specified > duration before the next attempt. This server-provided signal reflects the service's recovery > timeline and **takes precedence over your client-side back-off calculation**." > „Most 4xx client errors, like 400, 401, 403, and 404, indicate problems that a retry doesn't > resolve." **Korrektur:** `learn.microsoft.com/.../best-practices/retry-service-specific` liefert HTTP 301 — die alten service-spezifischen Default-Tabellen sind nicht mehr zitierbar. ### 4.3 Konkrete Parameter dokumentierter WS-Clients **Socket.IO v4** (https://socket.io/docs/v4/client-options/): | Option | Default | Doku-Text | |---|---|---| | `reconnectionAttempts` | `Infinity` | — | | `reconnectionDelay` | `1000` | „The initial delay before reconnection in milliseconds (affected by the randomizationFactor value)." | | `reconnectionDelayMax` | `5000` | „The maximum delay between two reconnection attempts. **Each attempt increases the reconnection delay by 2x.**" | | `randomizationFactor` | `0.5` | „The randomization factor used when reconnecting (**so that the clients do not reconnect at the exact same time after a server crash**, for example)." | | `timeout` | `20000` | „The timeout in milliseconds for each connection attempt." | Rechenbeispiel aus der Doku: 1. Versuch zwischen 500 und 1500 ms, 2. zwischen 1000 und 3000 ms, 3. zwischen 2000 und 5000 ms, danach 5000 ms. *(Die Doku enthält an dieser Stelle einen Tippfehler — der Multiplikator ist uniform in [0.5, 1.5], nicht „-0.5 and 1.5". Beim 3. Versuch ist die Obergrenze 5000 statt 6000, weil `reconnectionDelayMax` die **gejitterte** Zahl deckelt.)* **Phoenix JS** (https://github.com/phoenixframework/phoenix/blob/main/assets/js/phoenix/socket.js): ```javascript this.reconnectAfterMs = (tries) => { … return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000 } this.rejoinAfterMs = (tries) => { … return [1000, 2000, 5000][tries - 1] || 10000 } ``` **Korrektur zur Ticket-Formulierung:** Es ist ein **9-elementiges** Array mit Fallback 5000 ab Versuch 10 — nicht 10 Elemente. `rejoinAfterMs`-Fallback ist 10000. Kein `maxReconnectAttempts`, Phoenix reconnected unbegrenzt. Bemerkenswert: **kein Jitter**, dafür ein sehr aggressiver Start (10 ms) für den häufigsten Fall — den kurzen Netz-Glitch. **centrifuge-js** (https://github.com/centrifugal/centrifuge-js): > `minReconnectDelay`: „When client disconnected from a server it will automatically try to > reconnect using a **backoff algorithm with jitter**. […] Default is `500` milliseconds." > `maxReconnectDelay`: „sets an upper reconnect delay value. Default is `20000` milliseconds" > `maxServerPingDelay`: „sets the maximum delay of server pings after which connection is > considered broken and client reconnects. […] Default is `10000`." **Ably** (https://ably.com/docs/connect/states): > „if the connection drops at any time it will attempt to re-establish it by making repeated > connection attempts **every 15 seconds for up to two minutes**." > `suspended`: „In the suspended state, an SDK will periodically attempt to open a new connection > **every 30 seconds**." (`disconnectedRetryTimeout` 15000 ms, `suspendedRetryTimeout` 30000 ms.) **Spannbreite der dokumentierten Praxis:** Startverzögerung 10 ms (Phoenix) bis 15 s (Ably); Obergrenze 5 s (Socket.IO) bis 30 s (Ably) bis 64 s (GCP). Jitter: Socket.IO ±50 %, GCP +≤1000 ms, AWS Full Jitter, centrifuge-js „mit Jitter" (Faktor nicht dokumentiert), Phoenix gar keiner. ### 4.4 Welcher Fehler rechtfertigt welchen Reconnect? **RFC 6455 §7.4.1**, die für dieses Projekt relevanten Codes: | Code | Bedeutung | |---|---| | 1000 | Normal Closure | | 1001 | Going Away (Server-Shutdown oder Browser-Navigation) | | 1005 | No Status Rcvd — **nur intern, nie auf dem Draht** | | 1006 | Abnormal Closure — **nur intern, kein Close-Frame empfangen** | | 1008 | Policy Violation | | 1009 | Message Too Big | | 1011 | Internal Error | **1006 ist der Code, den man bei jedem stillen Proxy-Kill sieht** — genau der Fall, den Azure AppGW dokumentiert („throws a 1006 error on the client side", §3.5). 1005/1006/1015 dürfen nie in einem Close-Frame gesendet werden. **Discord Gateway Close Event Codes** — die einzige öffentlich vollständige `reconnect: true/false`-Tabelle (https://docs.discord.com/developers/topics/opcodes-and-status-codes): | Code | Beschreibung | Reconnect | |---|---|---| | 4000 | Unknown error | true | | 4003 | Not authenticated | true | | **4004** | **Authentication failed** | **false** | | 4005 | Already authenticated | true | | 4008 | Rate limited | true | | 4009 | Session timed out | true | | 4010–4014 | Invalid shard / Sharding required / Invalid API version / Invalid+Disallowed intents | **false** | Discords Leitsatz: „In order to prevent broken reconnect loops, you should consider some close codes as a signal to stop reconnecting. This can be because your token expired, or your identification is invalid." **Das dokumentierte Muster ist eine 3-Klassen-Einteilung:** (a) **sofort/schnell** — server-initiierte Umverteilung (Discord Opcode 7, 4009, 1001 Going Away), (b) **Backoff** — transiente Fehler, 1006, 1011, Rate-Limits, (c) **gar nicht** — Auth-/Konfigurationsfehler, analog zu Azures „Most 4xx client errors […] indicate problems that a retry doesn't resolve." Pusher kodiert dieselbe Dreiteilung in Code-**Bereichen** (4000–4099 / 4100–4199 / 4200–4299, §1.8), Centrifugo in terminal/nicht-terminal. ### 4.5 Thundering Herd beim Server-Neustart **Discord, „You've Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage", publiziert 29. April 2026** (https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage) — der aktuellste datierte Praxisbericht zum Thema: - Auslöser: „17% of sessions across Discord were ungracefully stopped." → „each gateway process for each affected user gets a `:DOWN` message, signaling to the client that their session is now invalid and requires a reconnection." - Verstärkung: „a selective receive on a supervisor with a ~100k mailbox queue adds ~1ms to process spawn time" — die Supervisor-Mailbox erreichte ~1 Mio. Einträge; „new connections are blocked because they cannot be opened within the connection timeout." - Mitigationen: **Rate-Limiting** („low syncer spawn limits"), Architektur (`PartitionSupervisor` statt einzelnem GenServer), Kapazität, und **gestaffelte Wiederaufnahme statt Vollneustart**. - Kernaussage: **Der Recovery-Verkehr selbst war das Problem.** Erholung durch Reduktion eingehender Arbeit und schrittweises Zurückholen der Systeme. **Slack, „Migrating Millions of Concurrent Websockets to Envoy", 15. März 2021** (Update 21. Juni 2021), Ariane van der Stedt & Radha Kumari (https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/) — relevant fürs Deployment-Design: > „We'd keep running the old process for many hours to allow long-lived websocket connections to > drain and avoid frequent disconnections of users." *Einschränkung: Der Artikel behandelt Reconnect-Stürme und Client-Backoff nicht. Die verbreiteten Aussagen über Slacks „Flannel"- und „Governor"-Komponenten stammen aus Sekundär-Blogs und sind nicht zitierfähig.* *Negativbefund: „How Discord Handles Two and Half Million Concurrent Voice Users using WebRTC" (12.09.2018, https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc) behandelt WebRTC-Voice-Skalierung, **nicht** WebSocket-Reconnect-Stürme.* **Server-gesteuerte Reconnects — dokumentierte Muster:** | Muster | Beleg | |---|---| | **Server sagt „reconnect jetzt"** — Discord Opcode 7 „Reconnect": „You should attempt to reconnect and resume immediately." Der Server verteilt Verbindungen gestaffelt selbst um, statt sie alle gleichzeitig zu droppen | https://docs.discord.com/developers/topics/opcodes-and-status-codes | | **Server gibt die Ziel-URL vor** — Discord `resume_gateway_url` im Ready-Event → Lastverteilung beim Reconnect ist serverseitig steuerbar | ebd. | | **`Retry-After` beim 503 auf den Handshake** — RFC 9110 §10.2.3: „The 'Retry-After' header field indicates how long a user agent ought to wait before making a follow-up request." ABNF: `Retry-After = HTTP-date / delay-seconds`. §15.6.4: „The server SHOULD generate a 'Retry-After' header field to indicate when the client may retry the request." Da der WS-Handshake ein normaler HTTP-Request ist, voll anwendbar — und laut Azure überstimmt er die Client-Backoff-Berechnung | https://www.rfc-editor.org/rfc/rfc9110.txt | | **Verbindungs-Draining statt Hard-Cut** — alten Prozess stundenlang weiterlaufen lassen | Slack, 15.03.2021 | | **Rate-Limiting des Recovery-Pfads** | Discord, 29.04.2026 | **Optionen-Gegenüberstellung Thundering Herd:** | Option | Pro | Contra | |---|---|---| | **Nur Client-Jitter** | Kein Serverzustand, kein Protokoll; von AWS als „standard approach for remote clients" bezeichnet | Ändert laut Brooker die N²-Natur des Problems **nicht**, reduziert sie nur; hilft nicht, wenn der Server nach dem Neustart sofort wieder überlastet ist | | **Server-gesteuerter Reconnect-Hinweis** (Close-Code + `reconnect_after`, oder `Retry-After` beim 503) | Der Server kennt seinen Zustand; Azure sagt explizit, dass dieser Wert die Client-Berechnung überstimmt; erlaubt gestaffeltes Zurückholen wie bei Discord | Braucht ein Protokollfeld bzw. einen 503-Pfad und Client-Logik, die ihn respektiert; nützt nichts, wenn der Server gar nicht antwortet (Neustart-Fenster) | | **Draining beim Deployment** | Vermeidet den Sturm ganz statt ihn abzufedern | Zwei Prozessgenerationen parallel — kollidiert mit der Map-Regel „eine Binary + Postgres" und der In-Process-Fan-out-Entscheidung | | **Rate-Limiting der Accept-Rate** | Schützt den Server unabhängig vom Client-Verhalten; Discords wirksamste Maßnahme im Vorfall vom 25.03.2026 | Verschiebt das Problem in die Client-Backoff-Schleife; braucht sinnvolle 503-/Close-Code-Antworten, sonst produziert es blindes Retry | ### 4.6 Netzwerkwechsel-Erkennung MDN `Navigator.onLine` (https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine), wörtlich: > „On Windows, the online status is determined by whether it can reach a Microsoft home server, > **which may be blocked by firewalls or VPNs**, even if the computer has Internet access. > Therefore, this property is inherently unreliable, and you should not disable features based on > the online status, only provide hints when the user may seem offline." Praktische Asymmetrie (MDN `Window: offline_event`): `offline` ist verlässlich, `online` nicht (Netz ja, Internet vielleicht). → `online` taugt als **Trigger, den Backoff-Timer zurückzusetzen**, nicht als Beweis für Konnektivität. Für Firmen-VPNs ist die Windows-Heuristik zusätzlich unzuverlässig — direkt relevant für die Zielgruppe. **Tauri v2** hat kein Core-API für Netzwerk-Status. Community-Plugins: `tauri-plugin-network-information` (iOS/Android, `connection()` + Change-Listener, https://github.com/wtto00/tauri-plugin-network-information), `tauri-plugin-network` (Desktop, https://github.com/HuakunShen/tauri-plugin-network). Auf Mobile im Foreground gilt zusätzlich: OS-seitige App-Suspension beendet die WS-Verbindung **ohne Close-Frame** → `1006` beim Wiederaufwachen. --- ## 5. Axum 0.8 konkret ### 5.0 Bestandsaufnahme rustification-server Gelesen, nichts geändert. Relevante Dateien: - `C:\Projekte\Privat\Rustification\rustification-server\Cargo.toml` - `C:\Projekte\Privat\Rustification\rustification-server\src\main.rs` - `C:\Projekte\Privat\Rustification\rustification-server\src\route.rs` - `C:\Projekte\Privat\Rustification\rustification-server\src\middleware.rs` - `C:\Projekte\Privat\Rustification\rustification-server\src\model.rs` (`Claims { sub: String, exp: usize }`) ```toml edition = "2024" axum = "0.8.9" # KEIN features = ["ws"] tokio = { version = "1", features = ["full"] } sqlx = { version = "0.9", features = ["runtime-tokio","tls-rustls-ring-webpki","postgres","chrono","uuid"] } jsonwebtoken = "9" ``` Nicht vorhanden und für WS nötig: `axum`-Feature `ws`; `futures`/`futures-util` (für `StreamExt::split`); `tokio-util` (für `CancellationToken`/`TaskTracker`); `axum-extra` (für `TypedHeader`). - **AppState** (`main.rs:14-17`): `pub struct AppState { pub db: PgPool, pub jwt_secret: String }` — **nicht** `Clone`, überall als `Arc`; State-Typ ist also `State>`. - **Router** (`main.rs:46-48`): `Router::new().nest("/api/v1", route::router(state.clone())).with_state(state)`. - **Auth-Middleware**: `axum::middleware::from_fn_with_state(state, require_auth)` als `.layer(...)` auf dem `protected`-Subrouter (`route.rs:104`). `require_auth` validiert das Bearer-JWT und macht `req.extensions_mut().insert(data.claims)` (`middleware.rs:31`). **Kein** `FromRequestParts`-Extractor. - **Auffällig:** `Extension` wird in **keinem** Handler gelesen — die eingefügte Extension ist derzeit ungenutzt. - **Graceful Shutdown existiert nicht:** `main.rs:52` ist `axum::serve(listener, app).await.unwrap();` Auch kein `into_make_service_with_connect_info` — `ConnectInfo` würde zur Laufzeit fehlschlagen. ### 5.1 WebSocket-Upgrade mit State `axum::extract::ws` ist „available via the `ws` crate feature" (https://docs.rs/axum/0.8.9/axum/extract/ws/index.html). **Breaking Changes 0.7 → 0.8** (https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md): > **breaking:** `axum::extract::ws::Message` now uses `Bytes` in place of `Vec`, and a new > `Utf8Bytes` type in place of `String`, for its variants (#3078) > **changed:** Upgraded `tokio-tungstenite` to 0.26 (#3078) > **breaking:** Remove `WebSocket::close`. Users should explicitly send close messages themselves. (#2974) > **added:** Add support for WebSockets over HTTP/2. They can be enabled by changing > `get(ws_endpoint)` handlers to `any(ws_endpoint)` (#2894) > **breaking:** Make `serve` generic over the listener and IO types (#2941) *(Hinweis: Die Überschrift im Repo lautet `# 0.8.0 (27. November, 2023)` — ein Tippfehler im axum-Repo; das echte 0.8.0-Release ist https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0 vom 01.01.2025. Der Blogpost erwähnt WebSockets nicht; die WS-Änderungen stehen nur im CHANGELOG.)* **`Message`-Varianten:** `Text(Utf8Bytes)`, `Binary(Bytes)`, `Ping(Bytes)`, `Pong(Bytes)`, `Close(Option)`. **Extractor-Reihenfolge — `WebSocketUpgrade` ist `FromRequestParts`.** Quellcode (https://github.com/tokio-rs/axum/blob/main/axum/src/extract/ws.rs): ```rust impl FromRequestParts for WebSocketUpgrade where S: Send + Sync { type Rejection = WebSocketUpgradeRejection; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { // HTTP/1.1: Methode MUSS GET sein, Connection: upgrade, Upgrade: websocket, Sec-WebSocket-Key // HTTP/2: Methode MUSS CONNECT sein, hyper::ext::Protocol == "websocket" // Sec-WebSocket-Version MUSS "13" sein let on_upgrade = parts.extensions.remove::() .ok_or(ConnectionNotUpgradable)?; … } } ``` Die Regel „Extractors always run in the order of the function parameters, from left to right" und „der letzte Extractor muss `FromRequest` implementieren" gilt in 0.8 unverändert (https://docs.rs/axum/0.8.9/axum/extract/index.html) — **betrifft WS aber nicht**, weil `WebSocketUpgrade`, `State`, `Extension`, `Query`, `Path`, `TypedHeader`, `ConnectInfo` und `HeaderMap` **alle** `FromRequestParts` sind. Die Reihenfolge ist frei: ```rust async fn ws_handler( ws: WebSocketUpgrade, State(state): State>, Extension(claims): Extension, ConnectInfo(addr): ConnectInfo, ) -> Response { ws.on_upgrade(move |socket| handle(socket, state, claims, addr)) } ``` **Handshake-Konfiguration in 0.8.9** (https://docs.rs/axum/0.8.9/axum/extract/ws/struct.WebSocketUpgrade.html): | Methode | Default / Semantik | |---|---| | `max_message_size(usize)` | **Default 64 MB** | | `max_frame_size(usize)` | **Default 16 MB** | | `read_buffer_size(usize)` | 128 KiB | | `write_buffer_size(usize)` / `max_write_buffer_size(usize)` | 128 KiB / unlimited | | `accept_unmasked_frames(bool)` | `false` | | `protocols(I)` | Subprotokoll-Negotiation | | `requested_protocols()` / `set_selected_protocol(HeaderValue)` | **neu in 0.8.9** (#3597) — erlaubt eigene Auswahl-Logik, relevant für Option 1.3 | | `selected_protocol()` | gewähltes Subprotokoll | | `on_failed_upgrade(C)` | Callback, wenn das Upgrade in der Hintergrund-Task fehlschlägt | Struct-Doku: „For HTTP/1.1 requests, this extractor requires the request method to be `GET`; in later versions, `CONNECT` is used instead. **To support both, it should be used with `any`.**" Serverseitig das ausgehandelte Subprotokoll lesen: `WebSocket::protocol() -> Option<&HeaderValue>` — „Return the selected WebSocket subprotocol, if one has been chosen." (https://docs.rs/axum/0.8.9/axum/extract/ws/struct.WebSocket.html) **`close_code`-Konstanten** (aus `axum/src/extract/ws.rs`) — der Name für 1001 ist in axum **`AWAY`**, nicht `GOING_AWAY`: ```rust NORMAL = 1000, AWAY = 1001, PROTOCOL = 1002, UNSUPPORTED = 1003, STATUS = 1005, ABNORMAL = 1006, INVALID = 1007, POLICY = 1008, SIZE = 1009, EXTENSION = 1010, ERROR = 1011, RESTART = 1012 /* "the server is restarting" */, AGAIN = 1013 /* overloaded */ ``` `1012 RESTART` und `1013 AGAIN` sind für §4.5 (Thundering Herd) unmittelbar relevant. **Verbindungsdaten:** `ConnectInfo` — „Note this extractor requires you to use `Router::into_make_service_with_connect_info` to run your app **otherwise it will fail at runtime**" (https://docs.rs/axum/0.8.9/axum/extract/struct.ConnectInfo.html). Das fehlt im aktuellen `main.rs`. `TypedHeader` lebt seit 0.7 in `axum-extra` (Feature `typed-header`). **Offizielle Beispiele (alle vorhanden):** - `examples/websockets/src/main.rs` — `WebSocketUpgrade` + `TypedHeader` + `ConnectInfo`, `into_make_service_with_connect_info::()`, `Message::Ping` als Probe, `socket.split()` + zwei Tasks + `tokio::select!` (https://github.com/tokio-rs/axum/blob/main/examples/websockets/src/main.rs) - `examples/websockets-http2/src/main.rs` — Route ist `any(ws_handler)`; setzt `server.http_builder().http2().enable_connect_protocol();` (https://github.com/tokio-rs/axum/blob/main/examples/websockets-http2/src/main.rs) - `examples/chat/src/main.rs` — **nutzt `tokio::sync::broadcast`**: `struct AppState { user_set: Mutex>, tx: broadcast::Sender }`, `broadcast::channel(100)`, `split()` + `tokio::select!` mit `abort()` (https://github.com/tokio-rs/axum/blob/main/examples/chat/src/main.rs) - Weiter relevant: `examples/testing-websockets`, `examples/graceful-shutdown`. ### 5.2 Greift `middleware::from_fn_with_state` beim Upgrade-Request? — **Ja.** Der Beleg steckt im Quellcode von `on_upgrade` (https://github.com/tokio-rs/axum/blob/main/axum/src/extract/ws.rs): ```rust pub fn on_upgrade(self, callback: C) -> Response { tokio::spawn(async move { let upgraded = match on_upgrade.await { … }; // hyper::upgrade::OnUpgrade let socket = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config)).await; callback(WebSocket { inner: socket, protocol }).await; }); let mut response = Response::builder() .status(StatusCode::SWITCHING_PROTOCOLS) .header(header::CONNECTION, UPGRADE) .header(header::UPGRADE, WEBSOCKET) .header(header::SEC_WEBSOCKET_ACCEPT, sign(sec_websocket_key.as_bytes())) .body(Body::empty()).unwrap(); … response } ``` **Der Handler kehrt sofort mit einem gewöhnlichen `Response` (101) zurück.** Der Upgrade-Request ist bis dahin ein normaler HTTP/1.1-`GET` (bzw. HTTP/2-`CONNECT`) und durchläuft die volle Tower-Kette wie jede REST-Route. Erst `hyper::upgrade::OnUpgrade` resolved in der separaten `tokio::spawn`-Task, nachdem hyper die 101 geschrieben und die IO gehijackt hat (https://docs.rs/hyper/latest/hyper/upgrade/index.html). **Kann Middleware mit 401 ablehnen? Ja.** Die bestehende `require_auth` gibt `Err((StatusCode::UNAUTHORIZED, …))` zurück, bevor `next.run(req)` läuft — der Handler wird nie aufgerufen, `on_upgrade` nie erreicht, hyper sendet keine 101. Standard-Tower-Pfad, nichts WS-Spezifisches. Was der Client davon sieht, hängt an der Vorbemerkung (WebView: nur `1006`). **Extensions — zwei Fallstricke:** 1. Von der Middleware via `extensions_mut().insert(claims)` eingefügte Werte sind im Handler über `Extension` sichtbar (Standardmechanik von `from_fn`). 2. **`WebSocketUpgrade::from_request_parts` macht `parts.extensions.remove::()`.** Baut eine Middleware den Request neu, ohne `extensions` zu übernehmen, ist `OnUpgrade` weg → Rejection `ConnectionNotUpgradable`. **Das ist der wichtigste Middleware-Fallstrick bei WS.** 3. **Die `on_upgrade`-Closure ist `'static`** (`C: FnOnce(WebSocket) -> Fut + Send + 'static`). Request-Extensions überleben **nicht** — `Parts` wird nach dem Handler gedroppt. Alles, was die Task braucht (Claims, `Arc`, `SocketAddr`), muss vorher extrahiert/geklont und per `move` hineingezogen werden. **Response-Middleware nach 101 / Timeouts:** `tower_http::timeout::TimeoutLayer` misst bis zum Response — bei WS ist das **sofort** (101 mit `Body::empty()`). Die spätere WS-Task ist davon nicht betroffen; `ResponseBodyTimeoutLayer` greift auf einem leeren Body ebenfalls nicht (https://docs.rs/tower-http/latest/tower_http/timeout/index.html). ⚠️ **Nicht belegbar:** Es wurde **kein** Issue gefunden, das nachweist, dass `TimeoutLayer` eine bereits geupgradete WS-Verbindung abbricht. Gefundene, aber andere Probleme betreffende Issues: [axum#1260](https://github.com/tokio-rs/axum/issues/1260) („server stucks when large of websocket connection closed"), [axum Discussion #2597](https://github.com/tokio-rs/axum/discussions/2597), [axum#2964](https://github.com/tokio-rs/axum/issues/2964). Das realere Risiko ist Middleware, die den **Request-Body puffert** oder den Request rekonstruiert und dabei Extensions verliert. **Alternative: Auth im Handler per eigenem `FromRequestParts`-Extractor** (https://docs.rs/axum/0.8.9/axum/extract/trait.FromRequestParts.html): | | Middleware (`from_fn_with_state`) | Eigener `FromRequestParts`-Extractor | |---|---|---| | **Pro** | Ein Ort für die ganze Route-Gruppe; kann 401 senden, ohne den Handler zu bauen; **ist der heutige Bestandscode** | Typsicher — kein `Extension`-Panic bei vergessenem Layer; kann Query-/Subprotokoll-Token im selben Schritt lesen (relevant für Optionen 1.2/1.3); pro Handler komponierbar | | **Contra** | `Extension` panikt zur Laufzeit, wenn der Layer fehlt; liest Token nur aus dem Header, es sei denn man baut das aus | Muss an jeden Handler geschrieben werden; State-Zugriff braucht `FromRef` oder `S = Arc` | Beide sind kombinierbar: Middleware für REST, zusätzlicher Extractor für WS mit anderem Token-Kanal. ### 5.3 Verbindungsverwaltung und Graceful Shutdown **Was `axum::serve(...).with_graceful_shutdown(...)` tut** — Quellcode `axum/src/serve/mod.rs` (https://github.com/tokio-rs/axum/blob/main/axum/src/serve/mod.rs): ```rust let (signal_tx, signal_rx) = watch::channel(()); executor.execute(async move { signal.await; drop(signal_rx); }); let (close_tx, close_rx) = watch::channel(()); loop { let (io, remote_addr) = match select(pin!(listener.accept()), pin!(signal_tx.closed())).await { Either::Left((conn, _)) => conn, Either::Right(_) => break, // keine neuen Verbindungen mehr }; handle_connection(&mut make_service, &signal_tx, &close_rx, io, remote_addr, &executor).await; } drop(close_rx); drop(listener); close_tx.closed().await; // wartet auf die Connection-Tasks ``` **Belege, dass upgegradete Verbindungen NICHT beendet und NICHT abgewartet werden:** 1. Der `close_rx`-Guard hängt **nur** an der hyper-Connection-Task. Die WS-Task ist ein eigenständiges `tokio::spawn` in `on_upgrade` **ohne** `close_rx` ⇒ `close_tx.closed().await` wartet nicht auf sie. 2. `hyper_util`-`UpgradeableConnection::graceful_shutdown` (https://docs.rs/hyper-util/latest/hyper_util/server/conn/auto/struct.UpgradeableConnection.html): > „This should only be called while the `Connection` future is still pending. **If called after > `UpgradeableConnection::poll` has resolved, this does nothing.**" Nach dem Hijack ist die Connection-Future resolved ⇒ der Aufruf ist ein No-op; das Socket gehört jetzt dem `WebSocketStream`. 3. **[axum Issue #3003 — „Graceful Shutdown ignores Websocket handler task"](https://github.com/tokio-rs/axum/issues/3003)**, eröffnet **21.10.2024**, **Status offen**: > „the task spawned in `ws.on_upgrade` is not connected to the graceful shutdown future at all" Und: selbst mit eigenem `CancellationToken` ist nicht garantiert, dass die Verbindung geschlossen ist, wenn die Shutdown-Future resolved. 4. **[axum PR #3831 — „fix(serve): include WebSocket tasks in graceful shutdown"](https://github.com/tokio-rs/axum/pull/3831)**, eingereicht **11.07.2026**, letzte Aktivität **28.07.2026**, **Status offen**: > „WebSocket handlers escape all of it … the shutdown future then resolves while sockets are > still live, so handler state outlives 'shutdown'" Der PR fügt eine `GracefulPeer`-Struktur mit Connection-Guards in die Request-Extensions ein und sendet beim Shutdown einen Close-Frame. 5. Gegenbeispiel zur Abgrenzung: [axum#2673](https://github.com/tokio-rs/axum/issues/2673) — **SSE blockiert** den Graceful Shutdown (kein Upgrade, hyper-Connection bleibt offen), **WS nicht**. ⇒ **Faktenlage:** Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen **selbst** tracken und beenden. `WithGracefulShutdown`-Doku (https://docs.rs/axum/0.8.9/axum/serve/struct.WithGracefulShutdown.html) sagt zu Upgrades **nichts**; `axum::serve` ist dokumentiert als „intentionally simple and doesn't support any configuration" (https://docs.rs/axum/0.8.9/axum/fn.serve.html). **Tracking-Bausteine, jeweils belegt:** | Baustein | Doku-Zitat | Eignung | |---|---|---| | **`tokio_util::sync::CancellationToken`** (https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html) | `cancelled()`: „Returns a Future that gets fulfilled when cancellation is requested. […] **This method is cancel safe.**" · `child_token()`: „Creates a CancellationToken which will get cancelled whenever the current token gets cancelled." (Cancel eines Childs cancelt den Parent nicht) · `cancelled_owned()` für `'static`-Closures | Ein Token pro Verbindung, abgeleitet vom globalen Shutdown-Token | | **`tokio_util::task::TaskTracker`** (https://docs.rs/tokio-util/latest/tokio_util/task/task_tracker/struct.TaskTracker.html) | „A task tracker used for waiting until tasks exit." · `wait`: „Waits until this `TaskTracker` is both closed and empty" (cancel safe) · gegenüber `JoinSet`: „When tasks exit, a `TaskTracker` will allow the task to immediately free its memory"; „does not require mutable access to insert tasks"; „can be cloned to share it with many tasks" | Warten auf N WS-Tasks beim Shutdown; die Doku enthält ein vollständiges Beispiel `TaskTracker` + `CancellationToken` genau dafür | | **`tokio::sync::watch`** (https://docs.rs/tokio/latest/tokio/sync/watch/index.html) | „`Sender::is_closed` and `Sender::closed` allow the producer to detect when all `Receiver` handles have been dropped." | Genau das Muster, das `axum::serve` intern benutzt (`signal_tx`/`close_tx`) | | **`JoinSet`** (https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html) | braucht `&mut`, hält Rückgabewerte | Laut TaskTracker-Doku für viele lang laufende unabhängige Tasks die schlechtere Wahl | **Muster in der Verbindungsschleife** (Ableitung aus den obigen Docs + axum-Beispiel): ```rust let token = shutdown_token.child_token(); loop { tokio::select! { _ = token.cancelled() => { // cancel safe (Doku oben) let _ = sender.send(Message::Close(Some(CloseFrame { code: close_code::AWAY, // 1001 "going away" reason: "server shutting down".into(), }))).await; break; } msg = receiver.next() => { … } ev = rx.recv() => { … } // broadcast::Receiver::recv, cancel safe } } ``` `WebSocket::close` existiert seit 0.8 **nicht mehr** (CHANGELOG #2974) → Close-Frame explizit über `send`. Für einen Neustart wäre alternativ `close_code::RESTART` (1012) protokollierbar (§5.1) — was direkt an §4.4 anschließt. **Cancel-Safety** — `tokio::select!`-Doku (https://docs.rs/tokio/latest/tokio/macro.select.html): > „Cancellation safety describes what happens when a future is dropped before it completes." > Grundregel: „If you have a future that has not yet completed, then it must be a no-op to drop > that future and recreate it" Als cancel-**sicher** gelistet: `mpsc::Receiver::recv`, **`broadcast::Receiver::recv`**, `watch::Receiver::changed`, `AsyncReadExt::read`, sowie pauschal „Stream operations via `tokio_stream` and `futures` crates". **Nicht** cancel-sicher: `read_exact`, `read_to_end`, `write_all`, `Mutex::lock`, `RwLock::read`/`write`, `Semaphore::acquire`, `Notify::notified`. `broadcast::Receiver::recv` explizit (https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html): > „**Cancel safety:** This method is cancel safe. If `recv` is used as a branch in `tokio::select!` > and another branch completes first, it is guaranteed that no messages were received on this > channel." ⚠️ **`SplitStream::next()` ist nur indirekt belegt.** https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.next dokumentiert nur „Creates a future that resolves to the next item in the stream" — **keine explizite Cancel-Safety-Aussage**. Der einzige Beleg ist die pauschale Zeile in der `select!`-Doku. Das ist vermutlich der Grund, warum die axum-Beispiele (`chat`, `websockets`) durchweg **split-in-zwei-Tasks** verwenden statt `select!` über den Socket-Stream — dieser Ansatz umgeht die Frage. --- ## 6. `tokio::sync::broadcast` und langsame Empfänger Doku: Modul https://docs.rs/tokio/latest/tokio/sync/broadcast/index.html · `Sender` https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Sender.html · `Receiver` https://docs.rs/tokio/latest/tokio/sync/broadcast/struct.Receiver.html · `RecvError` https://docs.rs/tokio/latest/tokio/sync/broadcast/error/enum.RecvError.html ### 6.1 Grundsemantik > „A multi-producer, multi-consumer broadcast queue. Each sent value is seen by all consumers." > „When a value is sent, **all** `Receiver` handles are notified and will receive the value. The > value is stored once inside the channel and **cloned on demand for each receiver**." > „When a `Receiver` handle is dropped, any messages not read by the receiver will be marked as read." Kapazität: „The provided `capacity` is rounded **up** to the next power of two." (3 → 4); `capacity == 0` panikt. **Senden blockiert nie.** `Sender::send` ist nicht `async`: „Attempts to send a value to all active Receiver handles, returning it back if it could not be sent." Bei vollem Ring gilt (Modul-Doku): **„the oldest value currently held by the channel is overwritten."** Rückgabe `Ok(usize)` = Anzahl aktiver Receiver; `Err(SendError)` „when there is no active `Receiver` handle" — der Wert kommt zurück. `Sender::receiver_count`: „It is not guaranteed that a sent message will reach this number of receivers." ### 6.2 `RecvError::Lagged(u64)` — exakte Semantik > `Lagged(u64)`: „The receiver lagged too far behind: one or more messages were overwritten in the > ring buffer before this receiver could read them. **The receiver remains subscribed. Its internal > cursor has been advanced to the oldest message still retained by the channel;** the next > successful `recv` call returns that message (unless further sends overwrite it first). The `u64` > is the number of messages that were skipped." `Receiver::recv`: > „If the `Receiver` handle falls behind, once the channel is full, newly sent values overwrite old > values in the ring buffer. The next call to `recv` then returns `Err(RecvError::Lagged(n))`, > where `n` is the number of overwritten messages the receiver missed." > „The receiver stays subscribed; its internal cursor is advanced to the oldest value still held by > the channel." **Antwort auf die drei Teilfragen des Tickets:** (a) `n` = Anzahl übersprungener Nachrichten; (b) der Receiver bleibt **brauchbar**, `Lagged` ist kein Abbruch; (c) er springt auf die **älteste noch vorhandene** Nachricht — er verliert also genau `n` Nachrichten, nicht mehr und nicht weniger. ### 6.3 Weitere API, die für das „Resync"-Problem relevant ist - **`Receiver::len`:** „Returns the number of messages that were sent into the channel and that this `Receiver` has yet to receive." → **direkt als Backlog-Metrik/Frühwarnung nutzbar, bevor `Lagged` eintritt.** - **`Receiver::resubscribe`:** „This `Receiver` handle will receive a clone of all values sent **after** it has resubscribed." → verwirft explizit alles Alte; das ist der „Cursor auf jetzt zurücksetzen"-Reset. - **`Receiver::try_recv`** — nicht blockierend, `TryRecvError::{Closed, Empty, Lagged(n)}`. - **Speicher:** Der Wert wird **einmal** gespeichert und „cloned on demand for each receiver" ⇒ `T` sollte billig klonbar sein (`Arc`, `bytes::Bytes`). ⚠️ Ob der Ring bei `channel(n)` sofort vollständig alloziert wird, sagt die Doku **nicht** ausdrücklich — nur die Power-of-Two-Rundung und der Hinweis „Allocation failure may result in a panic". ### 6.4 „Lagged = Resync jetzt" — Optionen ⚠️ **Wichtiger Negativbefund:** Es wurde **keine** tokio-Issue, -Discussion oder Doku-Passage gefunden, die „Lagged als Resync-Trigger" als *empfohlenes* Muster benennt. Es ist gängige Community-Praxis, aber ohne Primärquelle. Belegbar sind nur die einzelnen Bausteine. | Ansatz | Belegter Baustein | Pro | Contra | |---|---|---|---| | **Kapazität erhöhen** | `channel(n)`-Doku (Power-of-Two-Rundung) | Trivial; verschiebt die Schwelle | Löst nichts, verschiebt nur; Speicher × Kapazität; ein dauerhaft langsamer Client laggt trotzdem | | **`resubscribe()` + REST-Backfill** | `resubscribe`-Doku (s. o.) + Map-Entscheidung „`?since`-Backfill, die DB *ist* die Queue" | Passt exakt zur bereits gesetzten Zustellsemantik der Map; der Client hat mit `?since` ohnehin den Mechanismus, um die Lücke zu schließen; `Lagged(n)` liefert sogar die Größe der Lücke | Backfill-Last auf der DB genau dann, wenn der Server ohnehin unter Druck steht; braucht einen sauberen Cursor (siehe Council-Risiko „Cursor-Lücke") | | **`Receiver::len()` als Frühwarnung** | `len`-Doku (s. o.) | Erlaubt Reaktion *vor* dem Datenverlust (z. B. Client drosseln oder trennen) | Zusätzlicher Zustand pro Verbindung; Schwellenwert ist Tuning | | **Per-Verbindung-Puffer-Task** (broadcast → `mpsc::try_send`) | `mpsc::Sender::try_send`: „differs from `send` by returning immediately if the channel's buffer is full" (https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html) | **Drop-/Trenn-Entscheidung pro Client** statt global; ein langsamer Client trifft nur sich selbst | Ein Channel + Fan-out-Loop pro Verbindung; `T` wird pro Client geklont | | **Coalescing / latest-only** | `watch` (s. §6.5) | Kein Lag-Fehler möglich | Nur sinnvoll, wenn Zwischenwerte entbehrlich sind | Referenz-Implementierung im Repo von axum selbst: `examples/chat/src/main.rs` mit `broadcast::channel(100)`. ### 6.5 Alternative Kanaltypen | Option | Doku | Semantik | Trade-off | |---|---|---|---| | **`tokio::sync::watch`** | https://docs.rs/tokio/latest/tokio/sync/watch/index.html | „a multi-producer, multi-consumer channel that **only retains the last sent value**"; `changed()` ist cancel safe; `borrow_and_update()` „should be preferred […] if the receiver intends to await notifications from `changed` in a loop […] avoids a potential race where a new value is sent between `changed` being ready and the value being read" | **Kein Lag-Fehler möglich**, aber Zwischenwerte gehen verloren ⇒ taugt als reines **Resync-Signal / Versionszähler** (z. B. „Cursor der Conversation X ist jetzt N, hol dir den Rest per REST"), ungeeignet als Event-Stream, in dem jede Nachricht zählt. Passt bemerkenswert gut zur Map-Entscheidung „Dumb Pipe `{conversation, cursor}`" | | **`tokio::sync::mpsc` + `try_send` pro Verbindung** | https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html | `send`: „Sends a value, waiting until there is capacity."; `try_send`: sofortiges `TrySendError::Full`/`Closed`; `reserve`: „Waits for channel capacity. Once capacity […] is available, it is reserved for the caller." | Backpressure- bzw. Drop-Entscheidung **pro Client**; erlaubt „langsamen Client trennen" statt globalem Lag. Kosten: ein Channel + Fan-out-Loop pro Verbindung | | **`async_broadcast` 0.7.2** | https://docs.rs/async-broadcast/latest/async_broadcast/ | `broadcast`: „If the channel is full, this method waits until there is space for a message" (echte Backpressure, `async`). `set_overflow(true)`: „broadcasting […] will succeed even if the channel is full. It achieves that by removing the oldest message". `InactiveReceiver` hält den Kanal offen ohne zu konsumieren. Eigene Abgrenzung zu tokio in der Doku: „While this implementation does provide overflow mode, it is the default behavior and not opt-in. There is no equivalent of inactive receivers." | **Backpressure statt Drop** — ein langsamer WS-Client kann den **gesamten Producer** bremsen (Head-of-Line-Blocking über alle Clients). Nur sinnvoll, wenn Verlust teurer ist als Latenz — was der Map-Entscheidung „Best-effort + `?since`-Backfill" widerspricht | | **`flume`** | https://docs.rs/flume, https://crates.io/crates/flume | „A blazingly fast multi-producer, multi-consumer channel" mit „MPMC support". Crates.io: „in casual maintenance mode … critical security and bug fixes, but heavy feature development has stopped" | ⚠️ **Kein Broadcast** — MPMC heißt hier Work-Queue: jede Nachricht geht an genau **einen** Receiver. Für Fan-out an N WS-Clients ungeeignet, nur als Worker-Queue. *(„genau ein Receiver" konnte in der flume-Doku nicht wörtlich belegt werden.)* | | **`postage::broadcast`** | https://docs.rs/postage/latest/postage/broadcast/ | „Provides a **lossless**, MPMC channel. All receivers are guaranteed to recieve each message." | Lossless impliziert Backpressure. ⚠️ **Nicht belegbar:** Die Modul-Doku sagt **nicht** explizit, ob der Sender bei langsamem Receiver wartet oder was sonst geschieht. Kleinere Community | ### 6.6 PostgreSQL `LISTEN/NOTIFY` als zweite Fan-out-Implementierung Die Map sieht `LISTEN/NOTIFY` als zweite Implementierung hinter dem Fan-out-Trait vor. Faktenlage: `sqlx::postgres::PgListener` (https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgListener.html): > „A stream of asynchronous notifications from Postgres." > „This listener will **auto-reconnect**. If the active connection being used ever dies, this > listener will detect that event, create a new connection, will re-subscribe to all of the > originally specified channels, and will resume operations as normal." > `recv()`: „If the connection to PostgreSQL is lost, it is automatically reconnected on the next > call to `recv()`, and should be entirely transparent (as long as it was just an intermittent > network failure or long-lived connection reaper)." > `listen()`: „The channel name is quoted here to ensure case sensitivity." PostgreSQL `NOTIFY` (https://www.postgresql.org/docs/current/sql-notify.html): > „In the default configuration it must be shorter than **8000 bytes**." > „There is a queue that holds notifications that have been sent but not yet processed by all > listening sessions. **If this queue becomes full, transactions calling `NOTIFY` will fail at > commit.** The queue is quite large (8GB in a standard installation)." | | | |---|---| | **Pro** | Multi-Instanz-fähiger Fan-out ohne zusätzlichen Broker — hält die Map-Regel „eine Binary + Postgres"; `PgListener` reconnected selbst; `NOTIFY` ist transaktional (feuert erst bei COMMIT), was die Konsistenz mit der DB als Queue erzwingt; der Dumb-Pipe-Payload `{conversation, cursor}` liegt weit unter 8 kB | | **Contra** | 8000-Byte-Payload-Grenze — nur IDs/Deltas, Volldaten per Query nachladen; die Notify-Queue kann Transaktionen **beim Commit fehlschlagen** lassen, wenn Listener hängen; jeder `PgListener` belegt eine **dedizierte** Postgres-Connection außerhalb des `PgPool` (aktuell `max_connections = 10`) — muss beim Sizing eingeplant werden; `NOTIFY` liefert keine Historie, ein verpasstes Signal ist weg (dieselbe Resync-Frage wie bei `broadcast`, nur eine Ebene tiefer) | --- ## Anhang: Was nicht belegt werden konnte Diese Punkte sollten im Entscheidungsticket **nicht** als Fakt behandelt werden: 1. **Referrer-Leak und Browser-History für WS-URLs** (§1.2) — RFC 6750 §5.3 spricht von „page URLs"; für `new WebSocket(url)` kein Beleg gefunden. 2. **APM/Tracing-Leak** — plausibel (OpenTelemetry `url.full`), aber keine Semantic-Convention-Seite geprüft. 3. **OWASP WebSocket Security Cheat Sheet** trägt kein verifizierbares Datum/keine Version. 4. **SameSite-Wirksamkeit für WS-Handshakes** (§1.4) — aus rfc6265bis §5.6.7.1 abgeleitet, das Wort „WebSocket" kommt dort nicht vor. 5. **Auth0/Okta** haben keine auffindbare WS-spezifische Doku zum Token-Ablauf. 6. **Header-Größenlimits** von nginx/Caddy für lange JWTs im `Sec-WebSocket-Protocol` (§1.3). 7. **`tauri-plugin-websocket` `headers`-Feldtyp** — nur der Feldname aus der JS-Referenz belegt. 8. **Cloudflare „100 s WS-Idle-Timeout"** — in keiner Cloudflare-Primärquelle; sie dokumentieren bewusst keinen Zahlenwert. 9. **MDN „Proxy servers and firewalls"** — dieser Abschnitt existiert in der aktuellen MDN-Fassung nicht mehr. 10. **HAProxy numerische Timeout-Defaults** — upstream nicht dokumentiert; kursierende „50 s" stammen aus Distro-Beispiel-Configs. 11. **Slack-Reconnect-Sturm mit Datum** — der Envoy-Artikel (15.03.2021) behandelt das Thema nicht; „Flannel/Governor"-Aussagen sind nur sekundärbelegt. 12. **Discord `heartbeat_interval` = 41250 ms** — nicht dokumentiert; die Doku zeigt `45000` im Beispiel, der Wert ist server-diktiert. 13. **`tower_http::timeout` bricht geupgradete WS ab** — kein Issue gefunden, das das nachweist. 14. **`SplitStream::next()` Cancel-Safety** — nur indirekt über die pauschale `select!`-Doku-Zeile. 15. **„Lagged als Resync-Trigger"** — kein tokio-Doku- oder Issue-Beleg als empfohlenes Muster. 16. **broadcast-Ring: Sofort-Allokation** — Doku sagt dazu nichts. 17. **flume: „jede Nachricht an genau einen Receiver"** — in der flume-Doku nicht wörtlich belegt. --- ## Quellenverzeichnis **Spezifikationen** - RFC 6455 (The WebSocket Protocol) — https://www.rfc-editor.org/rfc/rfc6455.txt · §1.8, §4.1, §4.2.2, §5.5, §5.5.1–3, §7.4.1–2, §10.2, §10.3, §10.5 - RFC 6750 (Bearer Token Usage) — https://datatracker.ietf.org/doc/html/rfc6750 · §2.3, §5.3 - RFC 8441 (Bootstrapping WebSockets with HTTP/2) — https://datatracker.ietf.org/doc/html/rfc8441 - RFC 9110 (HTTP Semantics) — https://www.rfc-editor.org/rfc/rfc9110.txt · §10.2.3, §15.6.4 - RFC 2616 §2.2 (Separatoren) — https://datatracker.ietf.org/doc/html/rfc2616#section-2.2 - draft-ietf-httpbis-rfc6265bis — https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis - WHATWG WebSockets Standard — https://websockets.spec.whatwg.org/ - IANA WebSocket Registries — https://www.iana.org/assignments/websocket/websocket.xhtml - MQTT v5.0 (OASIS, 07.03.2019) — https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html - MDN WebSocket / error_event / Navigator.onLine — https://developer.mozilla.org/en-US/docs/Web/API/WebSocket - `man 7 tcp` — https://man7.org/linux/man-pages/man7/tcp.7.html - PostgreSQL `NOTIFY` — https://www.postgresql.org/docs/current/sql-notify.html **Rust-Bibliotheken** - axum 0.8.9 `extract::ws` — https://docs.rs/axum/0.8.9/axum/extract/ws/index.html - axum CHANGELOG — https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md - axum 0.8.0 Announcement (01.01.2025) — https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0 - axum Quellcode `extract/ws.rs`, `serve/mod.rs` — https://github.com/tokio-rs/axum/blob/main/axum/src/ - axum Beispiele `websockets`, `websockets-http2`, `chat` — https://github.com/tokio-rs/axum/tree/main/examples - axum Issue #3003 (21.10.2024, offen) — https://github.com/tokio-rs/axum/issues/3003 - axum PR #3831 (11.07.2026, offen) — https://github.com/tokio-rs/axum/pull/3831 - axum Issue #2673 (SSE-Gegenbeispiel) — https://github.com/tokio-rs/axum/issues/2673 - tokio `sync::broadcast` / `watch` / `mpsc` / `select!` — https://docs.rs/tokio/latest/tokio/sync/ - tokio-util `CancellationToken` / `TaskTracker` — https://docs.rs/tokio-util/latest/tokio_util/ - tungstenite / tokio-tungstenite — https://docs.rs/tungstenite/, https://docs.rs/tokio-tungstenite/ - hyper `upgrade`, hyper-util `UpgradeableConnection` — https://docs.rs/hyper/latest/hyper/upgrade/ - tower-http `timeout` — https://docs.rs/tower-http/latest/tower_http/timeout/ - sqlx `PgListener` — https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgListener.html - async-broadcast — https://docs.rs/async-broadcast/latest/async_broadcast/ - postage — https://docs.rs/postage/latest/postage/broadcast/ **Infrastruktur / Proxies** - nginx WebSocket + Log-Modul + Proxy-Modul — https://nginx.org/en/docs/http/websocket.html - Apache httpd Logs — https://httpd.apache.org/docs/2.4/logs.html - Caddy Logging — https://caddyserver.com/docs/logging - HAProxy 3.0 Configuration §4.2 — https://docs.haproxy.org/3.0/configuration.html - AWS ALB / Classic ELB Idle Timeout — https://docs.aws.amazon.com/elasticloadbalancing/ - AWS API Gateway WebSocket Limits — https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-execution-service-websocket-limits-table.html - Azure Load Balancer / Application Gateway WebSocket — https://learn.microsoft.com/en-us/azure/ - GCP Load Balancing Request Distribution — https://docs.cloud.google.com/load-balancing/docs/https/request-distribution - Cloudflare WebSockets — https://developers.cloudflare.com/network/websockets/ **Auth-/Realtime-Anbieter und -Frameworks** - Microsoft SignalR Auth + Security — https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz - Azure Web PubSub — https://learn.microsoft.com/en-us/azure/azure-web-pubsub/ - AWS AppSync Realtime WebSocket Client — https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html - AWS SDK Retry Behavior — https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html - Kubernetes apiserver WebSocket-Protocol-Authenticator — https://github.com/kubernetes/apiserver/blob/master/pkg/authentication/request/websocket/protocol.go - graphql-ws PROTOCOL.md — https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md - Centrifugo (Auth, Codes, Configuration) — https://centrifugal.dev/docs/ - centrifuge-js — https://github.com/centrifugal/centrifuge-js - Discord Gateway + Opcodes — https://docs.discord.com/developers/events/gateway - Pusher Channels Protocol — https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/ - Socket.IO Server/Client Options + Middlewares — https://socket.io/docs/v4/ - Phoenix Channels + `socket.js` — https://phoenix.hexdocs.pm/channels.html - Ably Auth + Connection States — https://ably.com/docs/auth/token - Firebase Manage Sessions — https://firebase.google.com/docs/auth/admin/manage-sessions - Home Assistant WebSocket API — https://developers.home-assistant.io/docs/api/websocket/ - Slack RTM (Legacy) — https://docs.slack.dev/legacy/legacy-rtm-api/ - python-websockets Authentication — https://websockets.readthedocs.io/en/stable/topics/authentication.html - OWASP: WSTG-CLNT-10, REST Security Cheat Sheet, WebSocket Security Cheat Sheet **Praxisberichte (mit Datum)** - Marc Brooker, „Exponential Backoff And Jitter", AWS Architecture Blog, **04.03.2015** (Update Mai 2023) — https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ - Simulator-Code — https://github.com/awslabs/aws-arch-backoff-simulator - Discord, „Behind the Scenes of the 3/25/26 Voice Outage", **29.04.2026** — https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage - Slack, „Migrating Millions of Concurrent Websockets to Envoy", **15.03.2021** — https://slack.engineering/migrating-millions-of-concurrent-websockets-to-envoy/ - Azure, „Transient fault handling", `ms.date: 26.02.2026` — https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults - Google Cloud, „Exponential backoff", Stand **29.07.2026** — https://docs.cloud.google.com/memorystore/docs/redis/exponential-backoff **Tauri** - Tauri v2 WebSocket-Plugin — https://v2.tauri.app/plugin/websocket/ - Tauri v2 JS-Referenz WebSocket — https://v2.tauri.app/reference/javascript/websocket/ - Tauri WebView-Versionen — https://v2.tauri.app/reference/webview-versions/ - Tauri v2.4.0 Release (Cookie-API) — https://v2.tauri.app/release/tauri/v2.4.0/ - Tauri Issue #13045 (Cookie-Store-Divergenz) — https://github.com/tauri-apps/tauri/issues/13045 - Tauri Issue #4263 (Proxy-Konfiguration) — https://github.com/tauri-apps/tauri/issues/4263