docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with /wayfinder before any code was written. They live next to the code they explain rather than in the issue tracker: they are reasoning documents, not open work, and in a tracker they would drift from the code with no diff and no review. The value is in the rejected alternatives. Why a counter under a row lock and not BIGSERIAL, why an event log and not a column on messages, why resync is a 200 with a discriminated body and not a 4xx, why the fan-out trait was deleted again, why voice is peer-to-peer with a hard six-person cap and no speculative SFU seam, and why Linux needs a native media stack while the other three platforms do not. Two entries record where the spec was wrong and got corrected: created_at was never a safe sort key, and an unread count cannot be derived without fetching because seq counts events rather than messages. Open work is tracked as issues, not here.
This commit is contained in:
87
docs/realtime/issues/01-research-webrtc-tauri-mobile.md
Normal file
87
docs/realtime/issues/01-research-webrtc-tauri-mobile.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Recherche: WebRTC im Tauri-WebView (Mobile)
|
||||
|
||||
Type: research
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Funktioniert WebRTC — insbesondere `getUserMedia` für Mikrofonzugriff und `RTCPeerConnection` —
|
||||
im WebView einer Tauri-v2-App auf **Android** und **iOS**? Und wenn ja, unter welchen
|
||||
Bedingungen?
|
||||
|
||||
Das ist die riskanteste Unbekannte des gesamten Efforts. Fällt die Antwort negativ aus,
|
||||
ist das Voice-Signaling-Design für die Hälfte der Zielplattformen wertlos, und die
|
||||
Voice-Topologie-Frage sieht völlig anders aus.
|
||||
|
||||
### Zu klären
|
||||
|
||||
- **iOS**: Tauri v2 rendert in `WKWebView`, nicht in Safari. `getUserMedia` ist dort
|
||||
historisch problematisch gewesen. Gilt das noch? Ab welcher iOS-Version? Braucht es
|
||||
Entitlements, `Info.plist`-Einträge (`NSMicrophoneUsageDescription`), oder
|
||||
WebView-Konfiguration, die Tauri nicht standardmäßig setzt?
|
||||
- **Android**: Tauri v2 nutzt Android System WebView. Wie werden
|
||||
`PermissionRequest`-Callbacks (`RESOURCE_AUDIO_CAPTURE`) durchgereicht? Muss die
|
||||
Tauri-App das abfangen, oder passiert es automatisch?
|
||||
- **Berechtigungen**: Wie beantragt eine Tauri-v2-App Mikrofonrechte auf beiden
|
||||
Plattformen? Gibt es dafür ein offizielles Plugin oder Muster?
|
||||
- **Alternative**: Falls WebView-WebRTC ausfällt — gibt es einen brauchbaren nativen
|
||||
Pfad? (`webrtc-rs` im Tauri-Rust-Layer, Audio-Streams zwischen Rust und WebView).
|
||||
Reifegrad, Mobile-Tauglichkeit, Aufwand.
|
||||
- **Desktop-Gegenprobe**: Läuft es auf Windows (WebView2) und Linux (WebKitGTK)?
|
||||
Auf Linux ist WebRTC in WebKitGTK-Builds nicht selbstverständlich einkompiliert.
|
||||
|
||||
### Quellenqualität
|
||||
|
||||
Primärquellen bevorzugen: Tauri-v2-Dokumentation und Issue-Tracker, WebKit-/Chromium-
|
||||
Release-Notes, Apple-Entwicklerdoku. Blogposts nur als Hinweisgeber, nicht als Beleg —
|
||||
und immer mit Datum, dieses Feld bewegt sich.
|
||||
|
||||
### Erwartetes Ergebnis
|
||||
|
||||
Eine Markdown-Datei im Repo mit: klarer Ja/Nein/Teilweise-Antwort pro Plattform,
|
||||
den nötigen Konfigurationsschritten, den Versionsuntergrenzen, und den Quellen. Wenn
|
||||
irgendwo „unklar" steht, muss dabeistehen, welches Experiment die Frage entscheiden würde.
|
||||
|
||||
## Answer
|
||||
|
||||
Vollständiges Ergebnis: [`../research/01-webrtc-tauri-mobile.md`](../research/01-webrtc-tauri-mobile.md)
|
||||
|
||||
| Plattform | Antwort | Aufwand |
|
||||
|---|---|---|
|
||||
| **iOS** (WKWebView) | **Ja**, ab 14.3 (praktisch 15.0) | eine Zeile: `NSMicrophoneUsageDescription` in `Info.ios.plist` |
|
||||
| **Android** (System WebView) | **Ja**, ab API 21 | zwei Manifest-Zeilen: `RECORD_AUDIO` **und** `MODIFY_AUDIO_SETTINGS` |
|
||||
| **Windows** (WebView2) | **Ja**, ohne Konfiguration | — |
|
||||
| **Linux** (WebKitGTK 4.1) | **Nein** | drei unabhängige Gates, eines davon distro-seitig unlösbar |
|
||||
|
||||
**Council-Risiko 2 ist positiv aufgelöst.** Mobile trägt WebRTC im WebView; das
|
||||
Voice-Signaling-Design ist für Android und iOS nicht wertlos. *Voice-Topologie* ist entblockt.
|
||||
|
||||
Belegt gegen die tatsächlich aufgelösten Versionen (`wry 0.55.1` über
|
||||
`tauri-runtime-wry 2.11.4` mit `^0.55.0`), nicht gegen `dev` — wichtig, weil wry 0.56
|
||||
die Permission-Lage geändert hat, aber von Tauri 2.11.x nicht gezogen werden kann.
|
||||
|
||||
### Drei Befunde, die andere Tickets verändern
|
||||
|
||||
1. **Der Problemfall ist Linux, nicht Mobile.** `ENABLE_WEB_RTC` folgt
|
||||
`ENABLE_EXPERIMENTAL_FEATURES` (= OFF); Debian/Ubuntu und Alpine setzen es belegbar
|
||||
nicht. Dazu `WebKitSettings:enable-webrtc` Default FALSE (von wry nie gesetzt) und
|
||||
`permission-request` Default Deny (in 0.55.1 nicht verbunden). Gate 2 und 3 wären per
|
||||
`with_webview` lösbar — Gate 1 hängt am Distro-Paket des Nutzers.
|
||||
→ eigenes Ticket *Voice auf Linux*.
|
||||
2. **Der Signaling-Server kann DTLS-Fingerprints tauschen** (RFC 8827) und damit den
|
||||
Audio-Stream vollständig MITMen. Der spezifizierte Gegenmechanismus (W3C Identity for
|
||||
WebRTC, IdP) ist in **keiner** der vier Ziel-Engines implementiert. Fingerprints müssen
|
||||
durch einen E2EE-authentifizierten Kanal übertragen und gepinnt werden.
|
||||
→ harte Randbedingung für *Voice-Signaling-Protokoll*.
|
||||
3. **wry vergibt Mikrofonrechte ohne App-Beteiligung** — auf iOS in 0.55.1 bedingungslos
|
||||
`Grant` für jeden Origin. Unkritisch, solange nur eigener gebündelter Code im WebView
|
||||
läuft; sofort kritisch, sobald fremde Origins (iframe, Preview-Einbettung) dazukommen.
|
||||
→ Architekturregel in den Notes der Map.
|
||||
|
||||
### Offen (blockiert die Spec nicht, gehört vor die Implementierung)
|
||||
|
||||
Sieben benannte Experimente in §8 der Ergebnisdatei. Höchste Priorität E1: `getUserMedia`
|
||||
auf einem physischen iPhone, je einmal mit und ohne Info.plist-Key — entscheidet zugleich
|
||||
das offene Upstream-Issue tauri#12068.
|
||||
229
docs/realtime/issues/02-monotoner-cursor.md
Normal file
229
docs/realtime/issues/02-monotoner-cursor.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Monotoner Cursor
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Worauf zeigt der `?since=<cursor>`-Backfill, sodass ein Client garantiert **keine**
|
||||
Nachricht überspringt?
|
||||
|
||||
Das ist Risiko 1 aus dem Council-Verdikt und die Voraussetzung dafür, dass die
|
||||
Best-effort-Zustellung überhaupt tragfähig ist. Ohne monotonen Cursor ist „WS-Event
|
||||
verpasst, hol es per REST nach" ein Versprechen, das der Server nicht halten kann.
|
||||
|
||||
### Warum die naheliegenden Antworten falsch sind
|
||||
|
||||
- **`created_at`** — Zeitstempel kollidieren bei gleichzeitigen Sendern, und ein
|
||||
Cursor auf einem nicht-eindeutigen Wert verliert oder dupliziert an der Grenze.
|
||||
- **`BIGSERIAL` / `id`** — Sequenzen vergeben IDs bei `INSERT`, aber Transaktionen
|
||||
werden in **Commit-Reihenfolge** sichtbar. Transaktion A holt ID 100, Transaktion B
|
||||
holt ID 101 und committet zuerst. Ein Client liest bis `since=101`, dann committet A.
|
||||
Nachricht 100 wird **nie** ausgeliefert. Bei zwei gleichzeitigen Sendern ist das kein
|
||||
Randfall, sondern der Normalfall.
|
||||
|
||||
Unter E2EE kann der Server den Verlust weder erkennen noch reparieren — er sieht den
|
||||
Inhalt nicht und kann keine Lückenprüfung über die Nachrichtenfolge machen.
|
||||
|
||||
### Optionen, die zu bewerten sind
|
||||
|
||||
1. **Sequenz pro Conversation unter Lock** — beim Insert `SELECT ... FOR UPDATE` auf
|
||||
eine Zählerzeile der Conversation, dann `seq = counter + 1`. Lückenlos und monoton
|
||||
*pro Conversation*, was genau die Granularität des Dumb-Pipe-Events ist. Kosten:
|
||||
Serialisierung der Schreiber pro Conversation. Ist das bei den Zielgrößen ein Problem?
|
||||
2. **Snowflake-/ULID-artige ID** — zeitbasiert + Zufall, monoton generierbar ohne
|
||||
DB-Rundtrip. Löst die Eindeutigkeit, aber **nicht** das Commit-Order-Problem.
|
||||
3. **Sichtbarkeitsbasierter Cursor** — `pg_current_snapshot()` / `xmin`-basiertes
|
||||
Nachlesen, sodass der Cursor die Transaktionssichtbarkeit abbildet statt der
|
||||
Insert-Reihenfolge. Korrekt, aber deutlich exotischer.
|
||||
4. **Outbox-Tabelle mit eigener Sequenz**, geschrieben in derselben Transaktion,
|
||||
gelesen in Commit-Reihenfolge.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- Welche Option — und warum überwiegt sie die anderen bei den Faktoren Simplizität
|
||||
(harte Regel: eine Binary + Postgres) und Korrektheit?
|
||||
- Ist der Cursor **pro Conversation** oder **pro Server/Nutzer**? Das Dumb-Pipe-Event
|
||||
trägt `{conversation, cursor}`, also spricht viel für pro Conversation — aber ein
|
||||
Client, der 40 Conversations offen hat, muss dann 40 Cursor verwalten.
|
||||
- Wie verhalten sich **Edits** und **Soft-Deletes**? Beide ändern eine bestehende Zeile,
|
||||
ohne einen neuen Cursor-Wert zu erzeugen — ein Client, der nur ab `since` nachlädt,
|
||||
sieht sie nie. Braucht es einen zweiten Kanal, eine `updated_at`-Achse, oder wird jede
|
||||
Änderung als neuer Cursor-Eintrag geführt?
|
||||
- Wie sieht die **Migration** aus? `messages` hat heute keine solche Spalte.
|
||||
|
||||
### Abnahmekriterium
|
||||
|
||||
Das Ticket ist gelöst, wenn es einen kleinen, lauffähigen Test gibt, der unter zwei
|
||||
gleichzeitigen Sendern die Lücke **im gewählten Ansatz nicht mehr** reproduziert — und
|
||||
sie im naiven `BIGSERIAL`-Ansatz reproduziert, damit der Test beweist, dass er misst,
|
||||
was er messen soll.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026. Vier Entscheidungen, die zusammen einen Mechanismus ergeben.
|
||||
|
||||
### 1. Ein Zähler pro Conversation, vergeben unter Zeilen-Lock
|
||||
|
||||
```sql
|
||||
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
Jede Mutation zieht die nächste Nummer **in derselben Transaktion** wie die Änderung:
|
||||
|
||||
```sql
|
||||
UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq;
|
||||
```
|
||||
|
||||
Der `UPDATE` nimmt einen Zeilen-Lock auf die Conversation. Ein zweiter Schreiber in
|
||||
dieselbe Conversation blockiert bis zum Commit des ersten — **damit ist Commit-Reihenfolge
|
||||
gleich `seq`-Reihenfolge**, lückenlos und monoton. Genau das ist die Eigenschaft, die
|
||||
`BIGSERIAL` nicht hat: dort wird die Nummer beim `INSERT` vergeben, sichtbar wird die Zeile
|
||||
aber erst beim Commit, und diese beiden Reihenfolgen können auseinanderlaufen.
|
||||
|
||||
Die Korrektheit folgt hier aus der Konstruktion, nicht aus einer Heuristik: solange die
|
||||
Nummer unter demselben Lock vergeben wird, unter dem die Zeile entsteht, kann ein Leser
|
||||
mit `seq > N` nichts überspringen.
|
||||
|
||||
**Bonus, der die Wahl mitentschieden hat:** der Zähler *ist* der aktuelle Maximal-Cursor.
|
||||
Erstabruf, Ungelesen-Anzeige und der Dumb-Pipe-Event lesen ein Feld statt zu scannen.
|
||||
|
||||
**Bewusst in Kauf genommen:** Schreiber derselben Conversation serialisieren (bei Chat
|
||||
irrelevant — Menschen tippen), und pro Mutation entsteht eine neue Zeilenversion auf
|
||||
`conversations`, die Autovacuum abräumt.
|
||||
|
||||
**Verworfen:** Snowflake/ULID lösen nur Eindeutigkeit, nicht die Commit-Reihenfolge.
|
||||
Advisory-Lock + `MAX(seq)+1` ist gleich korrekt und vermeidet den Schreib-Churn, braucht
|
||||
aber einen Hash der UUID auf int64 (Kollisionen kosten Durchsatz) und einen Scan für den
|
||||
Maximal-Cursor. Sichtbarkeitsbasierte Cursor (`pg_current_snapshot()`, `xmin`) sind
|
||||
theoretisch am saubersten, brechen aber die Dumb-Pipe-Form `{conversation, cursor}`, weil
|
||||
der Cursor kein `bigint` mehr wäre.
|
||||
|
||||
### 2. Die Änderungsachse ist ein Event-Log, keine Spalte auf `messages`
|
||||
|
||||
```sql
|
||||
CREATE TABLE conversation_events (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
seq BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL, -- message_created | message_edited | message_deleted | reaction_added | ...
|
||||
target_id UUID NOT NULL,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, seq)
|
||||
);
|
||||
```
|
||||
|
||||
Jede Mutation hängt eine Zeile an, mit der Nummer aus dem Conversation-Zähler. Das löst
|
||||
das Problem, an dem eine bloße Spalte auf `messages` scheitert: eine bearbeitete oder
|
||||
soft-gelöschte Nachricht erzeugt keinen neuen Cursor-Wert und wäre für einen
|
||||
`?since`-Backfill unsichtbar.
|
||||
|
||||
Das Log deckt zusätzlich alles ab, was **keine** Nachrichtenzeile hat — Reactions,
|
||||
Attachments, Mitgliederwechsel, Umbenennungen — und trägt damit die Fog-Themen Presence,
|
||||
Typing und Read-Receipts später ohne Umbau mit. Und: **die Log-Zeile ist buchstäblich der
|
||||
Dumb-Pipe-Event**; `NOTIFY` kann direkt darauf feuern.
|
||||
|
||||
Preis, bewusst akzeptiert: eine zweite Tabelle und ein zusätzlicher Insert pro Mutation.
|
||||
|
||||
### 3. Genau ein Zähler, und `created_at` verliert seine Sortierrolle
|
||||
|
||||
Es gibt **einen** Zähler pro Conversation, den das Log verbraucht. Eine Nachricht speichert
|
||||
die `seq` ihres `message_created`-Events als **unveränderlichen Sortierschlüssel**:
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages ADD COLUMN seq BIGINT NOT NULL;
|
||||
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
|
||||
```
|
||||
|
||||
Damit fällt `created_at` als Sortierkriterium weg und wird reine Anzeigeinformation. Das
|
||||
ist kein Nebeneffekt, sondern eine Korrektur: `created_at` ist heute `NOW()`, in Postgres
|
||||
der **Transaktionsstart**. Zwei gleichzeitige Sender bekommen Zeitstempel, deren
|
||||
Reihenfolge der Commit-Reihenfolge widersprechen kann — und der Wert ist nicht einmal
|
||||
eindeutig. Die bestehende Sortierung in `list_messages` ist damit schon heute falsch,
|
||||
unabhängig vom Realtime-Umbau.
|
||||
|
||||
Eine bearbeitete Nachricht bekommt ein neues Event mit höherer `seq`, behält aber ihre
|
||||
eigene `seq` — sie springt also im Cursor-Raum ans Ende, nicht im Chatverlauf.
|
||||
|
||||
### 4. Der Backfill liefert aufgelösten Zustand, nicht rohe Events
|
||||
|
||||
`GET …?since=N` liest das Log ab `N`, sammelt die betroffenen IDs und gibt den **aktuellen
|
||||
Zustand** dieser Entitäten zurück, plus den neuen Cursor. Der Client ersetzt schlicht, was
|
||||
er bekommt — keine Event-Interpretation, keine Abspiel-Logik.
|
||||
|
||||
Drei Eigenschaften, die daraus folgen:
|
||||
|
||||
- **Idempotent.** Ein doppelt zugestelltes Signal kann nichts kaputtmachen.
|
||||
- **Koaleszierend.** Zehn Edits derselben Nachricht ergeben eine Zeile im Ergebnis.
|
||||
- **Das Log ist reine Signalisierung** und damit wegwerfbar — es ist nicht die
|
||||
Wahrheitsquelle, die Tabellen sind es.
|
||||
|
||||
**Wichtig:** der Backfill darf Soft-Deletes **nicht** herausfiltern. Er muss Tombstones
|
||||
liefern, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
|
||||
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
|
||||
|
||||
Verworfen wurde die Alternative „rohe Events, Client spielt sie ab": sie böte eine echte
|
||||
Änderungshistorie, macht aber das Log zur Wahrheitsquelle (also unbeschneidbar), verlangt
|
||||
idempotente Client-Logik pro Event-Typ, und unter E2EE müssten die Events selbst
|
||||
verschlüsselt werden — sonst leckt das Log genau die Metadaten, die der Blob schützen soll.
|
||||
|
||||
### 5. Keine Retention, aber ein Antwortlimit und ein `resync`-Pfad
|
||||
|
||||
Das Log wird vorerst **nicht** beschnitten. Eine Zeile pro Mutation ist für Postgres nichts,
|
||||
und eine Aufräum-Mechanik, die niemand braucht, ist Wartungslast mit Fehlerpotenzial —
|
||||
zumal sie bei der Regel „eine Binary + Postgres" entweder als Timer-Task in den Server
|
||||
wandert oder `pg_cron` dazuzieht.
|
||||
|
||||
Stattdessen bekommt der Backfill ein hartes Limit pro Antwort. Wird es überschritten,
|
||||
antwortet der Server mit `resync` statt mit einem Delta; der Client verwirft seinen Cache
|
||||
für diese Conversation und lädt frisch.
|
||||
|
||||
**Dieser Pfad wird ohnehin gebraucht** — für das Handy, das drei Wochen aus war, für die
|
||||
allererste Verbindung ohne Cursor, und laut
|
||||
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) auch als
|
||||
Reaktion auf `RecvError::Lagged`. Damit sind drei verschiedene Situationen auf **einen**
|
||||
Mechanismus abgebildet.
|
||||
|
||||
Retention lässt sich später nachrüsten, ohne das Protokoll zu ändern.
|
||||
|
||||
### Begriffe
|
||||
|
||||
Für die einheitliche Sprache im Projekt festgehalten:
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|---|---|
|
||||
| **`seq`** | Monoton steigende Nummer pro Conversation, vergeben unter dem Zeilen-Lock der Conversation. Der einzige Ordnungsbegriff — nicht `created_at`, nicht `id`. |
|
||||
| **Cursor** | Der `seq`-Stand, den ein Client für eine Conversation zuletzt gesehen hat. Ein `bigint`, mehr nicht. |
|
||||
| **Event** | Eine Zeile in `conversation_events`. Signalisiert, *dass* sich etwas geändert hat; nicht *was* der neue Zustand ist. |
|
||||
| **Backfill** | `?since=<cursor>`: der aufgelöste aktuelle Zustand aller seither betroffenen Entitäten. |
|
||||
| **Resync** | Antwort des Servers, wenn ein Delta nicht sinnvoll bildbar ist (Cursor zu alt, kein Cursor, Limit überschritten). Der Client verwirft seinen Cache für die Conversation. |
|
||||
| **Tombstone** | Eine soft-gelöschte Zeile, die der Backfill **mitliefert**, damit der Client die Löschung erfährt. |
|
||||
|
||||
### Abnahmekriterium — Status
|
||||
|
||||
Das Kriterium verlangte einen lauffähigen Test. **Er ist spezifiziert, aber nicht
|
||||
geschrieben** — dieser Effort plant, und der Test setzt die Migration und den geänderten
|
||||
Handler voraus, also Implementierungsarbeit. Die Spezifikation:
|
||||
|
||||
> Zwei Transaktionen A und B fügen gleichzeitig in dieselbe Conversation ein. A beginnt
|
||||
> zuerst und committet zuletzt. Ein Leser pollt zwischen den Commits mit `?since=<cursor>`
|
||||
> und merkt sich alles Gelieferte. Erwartung: nach beiden Commits hat der Leser **beide**
|
||||
> Nachrichten gesehen.
|
||||
>
|
||||
> Der Test muss in der naiven Variante (`BIGSERIAL` oder `created_at` als Cursor)
|
||||
> **fehlschlagen** — sonst misst er nicht, was er messen soll.
|
||||
|
||||
Der eigentliche Beweis liegt in der Konstruktion (§1); der Test bewacht die Implementierung
|
||||
dagegen, dass jemand den `UPDATE … RETURNING` später aus der Transaktion herauszieht oder
|
||||
durch ein `SELECT max(...)` ersetzt.
|
||||
|
||||
### Offene Kopplung
|
||||
|
||||
**Wo die Event-Zeile geschrieben wird — im Handler oder per DB-Trigger — ist hier bewusst
|
||||
nicht entschieden.** Diese Frage steht in
|
||||
[*Fan-out-Architektur*](08-fan-out-architektur.md), weil sie dort mit `NOTIFY` zusammenfällt:
|
||||
ein Trigger feuert garantiert auf jedem Schreibweg und macht die zweite
|
||||
Fan-out-Implementierung fast geschenkt, ein Handler-Aufruf ist dafür leichter zu lesen.
|
||||
116
docs/realtime/issues/03-research-ws-auth-reconnect.md
Normal file
116
docs/realtime/issues/03-research-ws-auth-reconnect.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Recherche: WS-Auth, Heartbeat und Reconnect
|
||||
|
||||
Type: research
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Welche etablierten Muster gibt es für Authentifizierung, Verbindungserhalt und Reconnect
|
||||
bei langlebigen WebSocket-Verbindungen — und welche davon passen zu Axum 0.8 und einem
|
||||
JWT-basierten Auth-Modell?
|
||||
|
||||
### Zu klären
|
||||
|
||||
- **Token-Übergabe beim Handshake.** Die Browser-`WebSocket`-API erlaubt keine
|
||||
benutzerdefinierten Header. Übliche Auswege: Token im Query-String (landet in
|
||||
Server-Logs — bei diesem Bedrohungsmodell heikel), `Sec-WebSocket-Protocol`-Missbrauch,
|
||||
Cookie, oder ein erstes Auth-Frame nach dem Handshake mit kurzem Timeout. Was ist
|
||||
gängige Praxis, was sind die dokumentierten Nachteile? **Achtung:** falls die
|
||||
Verbindung im Tauri-Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*),
|
||||
entfällt die Header-Beschränkung komplett — die Recherche soll beide Fälle abdecken.
|
||||
- **Token-Ablauf bei laufender Verbindung.** Das JWT ist beim Handshake gültig und läuft
|
||||
nach Minuten ab, während die Verbindung Stunden steht. Muster: Verbindungs-TTL mit
|
||||
erzwungenem Reconnect, In-Band-Re-Auth-Frame, serverseitige Revocation-Liste. Was
|
||||
machen andere?
|
||||
- **Heartbeat.** WebSocket hat Ping/Pong auf Protokollebene. Wann reicht das, wann braucht
|
||||
es Application-Level-Heartbeats? Übliche Intervalle. Verhalten hinter Proxies und
|
||||
Corporate-Firewalls (relevant: Firmenkunden sind Zielgruppe).
|
||||
- **Reconnect-Strategie.** Exponentielles Backoff mit Jitter — konkrete Parameter aus
|
||||
der Praxis. Thundering-Herd-Verhalten, wenn ein Server neu startet und alle Clients
|
||||
gleichzeitig zurückkommen.
|
||||
- **Axum 0.8 konkret.** Wie sieht ein WebSocket-Upgrade mit State-Zugriff und Middleware
|
||||
aus? Greift `middleware::from_fn_with_state` beim Upgrade-Request? Wie werden viele
|
||||
gleichzeitige Verbindungen sauber verwaltet und beim Shutdown beendet?
|
||||
- **`tokio::sync::broadcast` und langsame Empfänger.** Der Kanal verwirft Nachrichten für
|
||||
hinterherhinkende Receiver (`RecvError::Lagged`). Wie behandelt man das korrekt, wenn
|
||||
„Lagged" eigentlich „Resync jetzt" bedeuten soll? Gibt es dafür ein etabliertes Muster
|
||||
oder bessere Kanaltypen für diesen Zweck?
|
||||
|
||||
### Quellenqualität
|
||||
|
||||
Axum-/Tokio-Dokumentation und Quellcode, RFC 6455, MDN. Praxisberichte nur mit Datum.
|
||||
|
||||
### Erwartetes Ergebnis
|
||||
|
||||
Eine Markdown-Datei im Repo: pro Teilfrage die Optionen mit ihren dokumentierten Vor-
|
||||
und Nachteilen, jeweils belegt. **Keine Empfehlung** — die Entscheidung fällt im Ticket
|
||||
*Verbindungsaufbau & Auth über WS*, dieses Ticket liefert nur die Faktenlage.
|
||||
|
||||
## Answer
|
||||
|
||||
Vollständiges Ergebnis: [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md)
|
||||
— Faktenlage zu allen sechs Teilfragen, Quellenverzeichnis und ein Anhang „Was nicht belegt
|
||||
werden konnte" mit 17 Punkten. Keine Empfehlung, wie gefordert.
|
||||
|
||||
### Die vier Befunde, die andere Tickets verändern
|
||||
|
||||
**1. Der `Authorization`-Header ist der leiseste Kanal — und das ist belegbar, nicht Folklore.**
|
||||
In den Default-Konfigurationen von nginx, Apache und Caddy landet der Query-String im
|
||||
Access-Log; Caddy redigiert `Authorization` aktiv zu `REDACTED`, `request.uri` dagegen nicht.
|
||||
Kein Transportweg schützt vor dem Betreiber selbst — der Server *muss* das Token sehen. Der
|
||||
Unterschied zwischen den Optionen ist ausschließlich, was den Handshake in Logfiles, Backups
|
||||
und APM-Systemen **überlebt**. Nebenbei entkräftet: die verbreiteten Referrer- und
|
||||
Browser-History-Argumente gegen den Query-String ließen sich für `new WebSocket(url)` **nicht**
|
||||
belegen und gehören nicht ins Entscheidungsticket.
|
||||
|
||||
**2. `Sec-WebSocket-Protocol` ist für dieses Bedrohungsmodell schlechter als gedacht** — Caddy
|
||||
loggt per Default *alle* Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu
|
||||
kommt: ein JWT ist zwar ein legaler Subprotocol-Token, aber `=` ist als Separator verboten
|
||||
(Base64-Padding), und der Browser erzwingt ein Echo des Subprotokolls durch den Server.
|
||||
|
||||
**3. Die Browser-API verschluckt alle Fehlerdetails — der Rust-Layer nicht.** WHATWG schreibt
|
||||
vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler, Subprotocol-Mismatch und Abbruch nach
|
||||
dem Handshake für das Skript **ununterscheidbar** sein müssen: immer `CloseEvent.code === 1006`.
|
||||
Im WebView ist „Token abgelaufen" also nicht von „Server down" unterscheidbar. `tungstenite::Error::Http`
|
||||
trägt dagegen die vollständige HTTP-Response inklusive Statuscode und Body. Diese Asymmetrie
|
||||
zieht sich durch fast jede Teilfrage → starkes Argument in *Wo lebt die WS-Verbindung im Client*.
|
||||
|
||||
**4. `axum::serve(...).with_graceful_shutdown(...)` beendet WebSocket-Verbindungen nicht und
|
||||
wartet nicht auf sie.** Belegt vierfach: der `close_rx`-Guard hängt nur an der hyper-Task, das
|
||||
`on_upgrade`-`tokio::spawn` nicht; `UpgradeableConnection::graceful_shutdown` ist nach dem Hijack
|
||||
laut hyper-util-Doku „does nothing"; axum-Issue #3003 (offen seit 21.10.2024) und PR #3831
|
||||
(offen seit 11.07.2026) beschreiben genau das. **Das Projekt muss WS-Verbindungen selbst
|
||||
tracken und beenden** — `CancellationToken` + `TaskTracker` sind die belegten Bausteine.
|
||||
|
||||
### Weitere Faktenlage, kompakt
|
||||
|
||||
- **`middleware::from_fn_with_state` greift beim Upgrade-Request** — das Upgrade ist ein normaler
|
||||
GET, 401/403 sind regulär sendbar.
|
||||
- **Token-Ablauf:** SignalR dokumentiert ausdrücklich, dass Auth **nicht** revalidiert wird.
|
||||
Optionen sind TTL/Reconnect (AWS API GW: 2 h / 10 min idle, hart), In-Band-Refresh (Centrifugo,
|
||||
Ably — Token landet dann in keinem Log) oder eine Revocation-Liste (im Betreiber-Angreifer-Modell
|
||||
per Definition wirkungslos).
|
||||
- **Heartbeat:** Protokoll-Ping ist aus dem WebView **nicht auslösbar** (WHATWG), im Rust-Layer
|
||||
antwortet tungstenite automatisch. Proxy-Korridor der dokumentierten Idle-Timeouts: Azure
|
||||
AppGW 20 s, GCP 30 s, nginx 60 s. Die verbreiteten Cloudflare-„100 s" und die HAProxy-Defaults
|
||||
sind Mythen und ließen sich nicht belegen. Etablierte Intervalle: Socket.IO 25 s, Phoenix 30 s,
|
||||
Centrifugo 25 s.
|
||||
- **Reconnect:** Praxiswerte reichen von 10 ms (Phoenix, ohne Jitter) bis 15 s (Ably). Gegen
|
||||
Thundering Herd sind vier Ansätze belegt: Client-Jitter, server-gesteuerter Hinweis
|
||||
(`Retry-After`, Discord Op 7), Draining und Accept-Rate-Limiting — mit Postmortems von Discord
|
||||
(29.04.2026) und Slack (15.03.2021).
|
||||
- **`RecvError::Lagged(n)`:** `n` = übersprungene Nachrichten, der Receiver bleibt brauchbar, der
|
||||
Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ **„Lagged = Resync" ist nirgends
|
||||
als offizielles Muster dokumentiert** — gängige Community-Praxis, aber ohne Primärquelle.
|
||||
Belegbar sind nur die Bausteine (`resubscribe()`, `Receiver::len()` als Frühwarnung,
|
||||
`mpsc::try_send` pro Client).
|
||||
- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe** — „retains only the last sent value"
|
||||
ist exakt die Semantik von „der Cursor der Conversation X ist jetzt N". Kein Lag-Fehler möglich.
|
||||
→ *Fan-out-Architektur*.
|
||||
- **`PgListener` belegt eine dedizierte Postgres-Connection außerhalb des Pools** (aktuell
|
||||
`max_connections = 10`), reconnected aber selbst. `NOTIFY` ist transaktional. Neue Gefahr:
|
||||
eine volle Notify-Queue lässt Transaktionen **beim Commit fehlschlagen**.
|
||||
- **Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
|
||||
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||
151
docs/realtime/issues/04-voice-topologie.md
Normal file
151
docs/realtime/issues/04-voice-topologie.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Voice-Topologie: Mesh oder SFU
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Läuft Audio direkt zwischen den Clients (P2P-Mesh) oder über einen serverseitigen
|
||||
Medien-Router (SFU) — und was davon legt das Signaling-Protokoll heute schon fest?
|
||||
|
||||
Diese Frage wurde beim Charting bewusst offengelassen (Q6). **Entblockt am 14.08.2026:**
|
||||
[*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) hat
|
||||
bestätigt, dass Android, iOS und Windows WebRTC im WebView tragen. Linux nicht — das ist
|
||||
eine eigene Frage in [*Voice auf Linux*](11-voice-auf-linux.md), deren Antwort auf diese
|
||||
hier zurückwirken kann.
|
||||
|
||||
### Spannungsfeld
|
||||
|
||||
- **Mesh** passt zum Bedrohungsmodell: der Server sieht nie Klartext-Audio, DTLS-SRTP
|
||||
bleibt zwischen den Peers. Passt außerdem zur harten Deployment-Regel — nichts
|
||||
Zusätzliches zu betreiben. **Skaliert aber nicht**: bei n Teilnehmern hält jeder n−1
|
||||
Verbindungen und lädt sein Audio n−1 mal hoch. Praktische Obergrenze liegt bei etwa
|
||||
vier bis sechs Teilnehmern, auf Mobile eher darunter.
|
||||
- **SFU** skaliert auf Dutzende Teilnehmer, **terminiert aber DTLS** und sieht damit den
|
||||
Audio-Klartext. Das widerspricht dem Bedrohungsmodell „Betreiber ist Angreifer"
|
||||
frontal. E2EE über einen SFU braucht Insertable Streams / SFrame — das ist ein
|
||||
eigenes Forschungsprojekt und im Rust-Ökosystem dünn besiedelt. Zusätzlich ein
|
||||
weiterer zu betreibender Dienst, gegen die Simplizitätsregel.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- Welche **Teilnehmerzahl** muss ein Voice-Room realistisch tragen? Zwei Personen im
|
||||
Gespräch, ein Team-Standup mit acht, oder ein Discord-artiger Kanal mit dreißig?
|
||||
Diese Zahl entscheidet die Frage fast allein.
|
||||
- Wenn Mesh: was passiert **oberhalb** der Grenze — harte Begrenzung mit klarer
|
||||
Fehlermeldung, oder Degradation?
|
||||
- Wenn SFU irgendwann: welche **Nähte** muss das Signaling-Protokoll heute schon haben,
|
||||
damit der Wechsel kein Bruch ist? (Wer bietet an, wer antwortet, wie werden Streams
|
||||
identifiziert.)
|
||||
- **TURN**: P2P scheitert in restriktiven Firmennetzen ohne Relay — und Firmen sind
|
||||
Zielgruppe. Bestätigt ist bereits: TURN bleibt optionale externe Abhängigkeit. Was
|
||||
genau ist die Zusage an den Nutzer, wenn kein TURN konfiguriert ist? Und wie merkt
|
||||
der Client das früh genug, um es ehrlich anzuzeigen statt in einen Timeout zu laufen?
|
||||
- Wie werden **TURN-Credentials** übermittelt, ohne sie fest in den Client zu backen?
|
||||
(Zeitbeschränkte HMAC-Credentials sind der übliche Weg — gehört das ins
|
||||
Signaling-Protokoll?)
|
||||
|
||||
### Zwei SDP-Fragen aus der WebRTC-Recherche, die hierher gehören
|
||||
|
||||
Beide sind formal Codec-/Transport-Themen, aber es sind SDP- bzw. ICE-Entscheidungen und
|
||||
damit Topologie-nah:
|
||||
|
||||
- **`iceTransportPolicy: "relay"` erzwingen?** Verhindert, dass der Peer die IP-Adresse
|
||||
des Gegenübers lernt (W3C WebRTC 1.0 nennt das ausdrücklich als Zweck). Der Server
|
||||
kennt die IP ohnehin aus der WebSocket-Verbindung, also kostet es nichts an Vertraulichkeit
|
||||
gegenüber dem Betreiber — es reduziert strikt die Exposition gegenüber dem *Peer*. Preis:
|
||||
ohne TURN gibt es dann gar keine Verbindung mehr, auch im LAN nicht.
|
||||
- **Opus mit `cbr=1` (und ggf. `usedtx=0`) verhandeln?** Variable Bitrate leckt über
|
||||
Paketgrößen ein Sprech-/Pausen-Muster — ein feindlicher TURN-Betreiber bekommt damit
|
||||
eine Voice-Activity-Detection frei Haus, obwohl er den Klartext nicht sieht. Konstante
|
||||
Bitrate schließt das gegen Bandbreite.
|
||||
|
||||
### Randbedingung
|
||||
|
||||
Diese Entscheidung darf die Grenze „dieser Effort endet beim Signaling" nicht
|
||||
überschreiten. Codecs, Echo-Cancellation und Audio-Pipeline sind out of scope — die beiden
|
||||
Punkte oben sind die Ausnahme, weil sie im SDP ausgehandelt werden und damit ins Protokoll
|
||||
gehören.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### 1. Mesh mit harter Obergrenze von sechs Teilnehmern
|
||||
|
||||
P2P-Mesh, DTLS-SRTP bleibt zwischen den Peers, **der Server fasst nie Audio an**. Das
|
||||
entspricht dem Bedrohungsmodell direkt und braucht keinen weiteren Dienst — die harte Regel
|
||||
„eine Binary + Postgres" bleibt für Voice unangetastet.
|
||||
|
||||
Oberhalb von sechs wird der Beitritt **abgelehnt**, nicht degradiert: eine klare Meldung ist
|
||||
ehrlicher als ein Gespräch, das für alle schlechter wird, ohne dass jemand versteht warum.
|
||||
Bei sechs Teilnehmern lädt jeder Client fünf Ströme hoch, also grob 120 kbit/s aufwärts — die
|
||||
Grenze, ab der Mesh in der Praxis kippt.
|
||||
|
||||
**Kein spekulativer Vorbau für einen späteren SFU.** Das ist der Kern der Entscheidung und
|
||||
bewusst gegen den Reflex getroffen: ein SFU-Protokoll ist client-zu-Server adressiert, ein
|
||||
Mesh-Protokoll peer-zu-peer. Eine „SFU-taugliche Naht" heute wäre Selbsttäuschung — sie würde
|
||||
Komplexität kosten und im Ernstfall trotzdem nicht passen. **Ein SFU wäre ein bewusster
|
||||
Protokollbruch in einem eigenen Effort**, und das steht so in der Map.
|
||||
|
||||
Verworfen: SFU von Anfang an. Er trüge Dutzende Teilnehmer, und SFrame/Insertable Streams
|
||||
sind laut Recherche auf allen Zielplattformen verfügbar (WKWebView ab 15.4, Chromium ab 141).
|
||||
Dagegen stehen drei Kosten: ein weiterer zu betreibender Dienst gegen die Deployment-Regel,
|
||||
ein erheblich schwereres Krypto-Design zulasten des E2EE-Efforts, und selbst mit SFrame
|
||||
behielte der SFU Zugriff auf RTP-Metadaten — die `audio-level`-Header-Erweiterung ist ein
|
||||
direktes Sprechaktivitäts-Orakel und müsste eigens wegverhandelt werden.
|
||||
|
||||
Ebenfalls verworfen: den SFU dauerhaft auszuschließen. Das hätte das Profil geschärft, aber
|
||||
die Tür für Firmenkunden mit größeren Runden zugezogen — eine Produktentscheidung, die es
|
||||
ohne Not heute nicht braucht.
|
||||
|
||||
### 2. Eine Datenschutz-Politik je Instanz, vom Server gesetzt
|
||||
|
||||
Die Instanz konfiguriert **einen** Schalter, der zwei Dinge zugleich regelt und im Signaling
|
||||
an den Client übermittelt wird; der Client zeigt sichtbar an, welcher Modus gilt.
|
||||
|
||||
| Schalterstellung | `iceTransportPolicy` | Opus |
|
||||
|---|---|---|
|
||||
| **streng** | `"relay"` erzwungen — der Peer lernt die IP-Adresse nie | `cbr=1`, `usedtx=0` |
|
||||
| **offen** | Standard-ICE (host / srflx / relay) | Standard (VBR mit DTX) |
|
||||
|
||||
Die Kopplung ist nicht Bequemlichkeit, sondern folgt der Bedrohung: **beide Lecks entstehen
|
||||
am selben Ort.** Läuft Audio über das Relay, sieht der Betreiber — der erklärte Angreifer —
|
||||
die Paketgrößen und hat damit einen Sprechaktivitäts-Detektor, obwohl er den Inhalt nicht
|
||||
lesen kann. Läuft es direkt, bekommt er die Pakete gar nicht zu sehen, dafür lernt der Peer
|
||||
die IP. Ein Schalter, ein anzuzeigender Zustand.
|
||||
|
||||
**Warum je Instanz und nicht global:** auf einem öffentlichen Server telefonieren Fremde
|
||||
miteinander, und dass zwei Unbekannte gegenseitig ihre Anschlussadresse lernen, ist für ein
|
||||
Datenschutzprodukt untragbar — dort gehört „streng" hin, und TURN wird auf dieser Instanz zur
|
||||
Voraussetzung. Auf einer Firmeninstanz sind die Peers Kollegen im selben Netz; dort wäre
|
||||
erzwungenes Relay nur Bandbreitenverschwendung, und ein Gespräch zwischen zwei Rechnern im
|
||||
selben Büro liefe ohne Not über den Server.
|
||||
|
||||
**Bewusst in Kauf genommen:** im offenen Modus bleibt das Sprechmuster für Netzbeobachter auf
|
||||
dem Pfad sichtbar, und die Zusage an den Nutzer hängt am Betreiber — deshalb muss der Client
|
||||
sie anzeigen und nicht nur intern kennen.
|
||||
|
||||
**Damit bleibt TURN die optionale Abhängigkeit**, die die Map festgelegt hat: ohne TURN läuft
|
||||
Voice im offenen Modus eingeschränkt (Firmen-NATs), im strengen Modus gar nicht — und der
|
||||
Betreiber weiß, worauf er sich einlässt, weil er den Schalter selbst umlegt.
|
||||
|
||||
### 3. Beide Medienpfade müssen die Politik umsetzen
|
||||
|
||||
Aus [*Voice auf Linux*](11-voice-auf-linux.md): `RTCPeerConnection` im WebView auf drei
|
||||
Plattformen, `livekit/rust-sdks` auf Linux. Sowohl `iceTransportPolicy` als auch die
|
||||
Opus-`fmtp`-Parameter müssen auf beiden Wegen gesetzt werden können — beides ist
|
||||
Standard-WebRTC und in beiden Stacks vorhanden, gehört aber in die Spec, damit es nicht auf
|
||||
einem der Pfade vergessen wird.
|
||||
|
||||
### Folgen
|
||||
|
||||
[*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md) ist damit entblockt. Zwei
|
||||
Dinge gehen von hier dorthin: die **Raumgröße** muss serverseitig durchgesetzt werden (der
|
||||
Beitritt wird abgelehnt, nicht nur in der UI verhindert), und die **Politik-Übermittlung**
|
||||
gehört ins Signaling — zusammen mit den TURN-Zugangsdaten, die im strengen Modus zwingend
|
||||
sind.
|
||||
194
docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md
Normal file
194
docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Wo lebt die WS-Verbindung im Client
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Läuft die WebSocket-Verbindung im **Tauri-Rust-Layer** oder im **WebView-JavaScript** —
|
||||
und wie geht das mit N Verbindungen zu N unabhängigen Servern zusammen?
|
||||
|
||||
### Ausgangslage
|
||||
|
||||
Heute liegt die gesamte Serverkommunikation in Rust: `src-tauri/src/state.rs` hält den
|
||||
`reqwest::Client`, das Token und die User-ID; `commands/*.rs` exponiert Tauri-Commands
|
||||
ans Frontend. `BASE_URL` ist eine Konstante — das trägt das Multi-Server-Modell nicht
|
||||
und muss ohnehin fallen.
|
||||
|
||||
### Die Spannung
|
||||
|
||||
- **Rust** (`tokio-tungstenite`, Events per `app.emit()` ans Frontend): konsistent mit
|
||||
dem bestehenden Muster, Token bleibt im Rust-State und nie im WebView, eine
|
||||
Implementierung für alle vier Plattformen, volle Header-Kontrolle beim Handshake
|
||||
(löst das Token-im-Query-String-Problem aus *Recherche: WS-Auth, Heartbeat und
|
||||
Reconnect*), robusterer Reconnect ohne Abhängigkeit vom WebView-Lebenszyklus.
|
||||
- **JavaScript** (`WebSocket`-API im WebView): eine Zeile statt einer Abhängigkeit,
|
||||
kein IPC-Hop zwischen Rust und Frontend — und **WebRTC lebt später ohnehin im
|
||||
WebView**. Ein Signaling-Kanal in Rust und ein Peer-Connection-Objekt in JS bedeutet
|
||||
zwei Lebenszyklen, die konsistent gehalten werden müssen.
|
||||
|
||||
Genau diese Aufteilung ist der Kern der Frage: Chat-Push in Rust ist unstrittig
|
||||
angenehm, Voice-Signaling in Rust ist es nicht.
|
||||
|
||||
### Neues Argument aus der Auth-Recherche (14.08.2026)
|
||||
|
||||
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) hat eine
|
||||
Asymmetrie gefunden, die schwerer wiegt als erwartet: **die Browser-API verschluckt alle
|
||||
Fehlerdetails.** WHATWG schreibt vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler,
|
||||
Subprotocol-Mismatch und Abbruch nach dem Handshake für das Skript ununterscheidbar sein
|
||||
müssen — immer `CloseEvent.code === 1006`, das `error`-Event ist ein leeres `Event`.
|
||||
|
||||
Im WebView ist damit **„Token abgelaufen" nicht von „Server down" unterscheidbar.**
|
||||
`tungstenite::Error::Http` trägt dagegen die vollständige HTTP-Response inklusive Statuscode
|
||||
und Body. Bei N Servern gleichzeitig — wo der Nutzer pro Server einen aussagekräftigen
|
||||
Zustand sehen soll — ist das kein Detail. Dazu kommt: nur im Rust-Layer ist
|
||||
`Authorization: Bearer` überhaupt möglich, der laut derselben Recherche einzige Kanal, der in
|
||||
den Proxy-Defaults nicht mitgeloggt wird.
|
||||
|
||||
Beides zeigt in dieselbe Richtung. Wer sich für JS entscheidet, muss diese beiden Kosten
|
||||
ausdrücklich in Kauf nehmen — der WebRTC-Nähe zuliebe kann das trotzdem richtig sein, aber
|
||||
es ist jetzt eine bewusste Abwägung und keine Nebenwirkung mehr.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- Ein Ort für beides, oder eine bewusste Trennung (Dumb-Pipe in Rust, Voice-Signaling
|
||||
in JS)? Falls Trennung: was ist der Preis an doppelter Auth- und
|
||||
Reconnect-Verwaltung, und rechtfertigt ihn die WebRTC-Nähe?
|
||||
- **Multi-Server**: N Verbindungen zu N Servern. Wo liegt der Verbindungs-Pool, wie
|
||||
werden Events dem richtigen Server zugeordnet, wie sieht das im Frontend-State aus?
|
||||
- Wie ersetzt `BASE_URL` sich durch eine Server-Liste? Wo wird sie persistiert
|
||||
(heute `tauri-plugin-store` laut README, im Code noch nicht vorhanden)?
|
||||
- Wenn Rust: welche Ereignisse gehen per `emit` ans Frontend, und in welcher Granularität?
|
||||
- Wenn JS: wie kommt das Token dorthin, ohne es dauerhaft im WebView zu halten?
|
||||
|
||||
### Randbedingung
|
||||
|
||||
Die Antwort beeinflusst *Verbindungsaufbau & Auth über WS* (Header-Verfügbarkeit) und
|
||||
*Client-Integration*. Sie ist bewusst **nicht** von der WebRTC-Recherche blockiert —
|
||||
falls diese negativ ausfällt, wird das Ticket neu bewertet.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### 1. Die Verbindung lebt im Tauri-Rust-Layer, eigene Implementierung
|
||||
|
||||
`tokio-tungstenite`, Verbindungs-Pool im `AppState` neben Token und Server-Liste, Zustellung
|
||||
ans Frontend per `app.emit()`. Das ist keine neue Architektur, sondern die bestehende —
|
||||
`reqwest`, Token und `user_id` liegen bereits dort.
|
||||
|
||||
Vier Gründe, jeder einzeln belegt in
|
||||
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md):
|
||||
|
||||
- **`Authorization: Bearer` ist möglich.** Laut der Log-Matrix der Recherche der einzige
|
||||
Kanal, den die Defaults von nginx, Apache und Caddy nicht mitschreiben — Caddy redigiert
|
||||
ihn sogar aktiv.
|
||||
- **Fehler sind diagnostizierbar.** `tungstenite::Error::Http` trägt die vollständige
|
||||
Server-Antwort. Im WebView schreibt WHATWG vor, dass alle Fehlerdetails ununterscheidbar
|
||||
sind (immer `1006`) — bei N Servern, für die je ein aussagekräftiger Zustand angezeigt
|
||||
werden soll, ist das disqualifizierend.
|
||||
- **Das Token verlässt den Rust-Prozess nie.**
|
||||
- **Eine Implementierung statt vier WebView-Engines** (WebView2, WKWebView, Android-Chromium,
|
||||
WebKitGTK), und Reconnect und Pool überleben einen WebView-Reload.
|
||||
|
||||
**Geprüft und verworfen: `tauri-plugin-websocket`.** Der Mittelweg wäre real — der Socket
|
||||
läuft dort in Rust, `connect()` nimmt `headers`. Dagegen sprechen: die API ist dünn
|
||||
(connect/send/addListener/disconnect), Reconnect, Backoff und Multi-Server-Pool schreibt man
|
||||
trotzdem selbst, nur eben in TypeScript; es gibt ein dokumentiertes Rennen mit
|
||||
Nachrichtenverlust zwischen `connect()` und `addListener()`
|
||||
([plugins-workspace#3152](https://github.com/tauri-apps/plugins-workspace/issues/3152));
|
||||
WebView-Reloads hinterlassen hängende Verbindungen
|
||||
([#1507](https://github.com/tauri-apps/plugins-workspace/issues/1507)); und die Maintainer
|
||||
schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken
|
||||
([Discussion #10096](https://github.com/orgs/tauri-apps/discussions/10096)). Man bekäme
|
||||
JS-Lebenszyklus, IPC-Hop und eine ungepflegte Abhängigkeit zugleich.
|
||||
|
||||
### 2. Die WebRTC-Spannung löst sich auf
|
||||
|
||||
Voice-Signaling läuft **ebenfalls über den Rust-Socket**; nur die `RTCPeerConnection` selbst
|
||||
lebt im WebView. Eine SDP-Nachricht kostet damit zwei IPC-Hops — bei einer Handvoll
|
||||
Nachrichten pro Anrufaufbau irrelevant, IPC liegt im Sub-Millisekunden-Bereich.
|
||||
|
||||
Die im Ticket formulierte Sorge („zwei Lebenszyklen, die konsistent gehalten werden müssen")
|
||||
war die falsche Abwägung: eine zweite Browser-WebSocket nur für Signaling würde eine zweite
|
||||
Auth **ohne Header**, einen zweiten Reconnect und ein Token im WebView zurückholen — also
|
||||
genau die drei Nachteile, die gerade verworfen wurden.
|
||||
|
||||
### 3. Rust macht den Backfill und emittiert aufgelöste Entitäten
|
||||
|
||||
Rust empfängt den Dumb-Pipe-Event, ruft selbst `?since=<cursor>` über das bestehende
|
||||
`reqwest` und emittiert die fertigen Entitäten. Damit liegt die **Cursor-Buchhaltung pro
|
||||
Conversation und pro Server dort, wo Verbindung, Token und Server-Liste ohnehin liegen** —
|
||||
an einer Stelle statt an zweien. Das Debouncing (zehn Signale in zwei Sekunden ergeben einen
|
||||
Fetch) sitzt ebenfalls in Rust.
|
||||
|
||||
Das Frontend hat **keine Cursor-Logik**. Es bekommt „hier sind aktualisierte Entitäten für
|
||||
Conversation X auf Server Y" und ersetzt.
|
||||
|
||||
Der `resync`-Fall aus [*Monotoner Cursor*](02-monotoner-cursor.md) muss als **eigenes
|
||||
Signal** ans Frontend durchgereicht werden, damit es seinen Cache für die betroffene
|
||||
Conversation verwirft — er ist kein Datenereignis, sondern eine Anweisung.
|
||||
|
||||
**Bewusst in Kauf genommen:** Rust lädt auch für Conversations nach, die gerade nicht
|
||||
sichtbar sind. Ob das gedrosselt werden soll — „nur einen Ungelesen-Punkt setzen statt zu
|
||||
laden" — ist eine Frage des Zustandsmodells und gehört nach
|
||||
[*Client-Integration*](10-client-integration.md).
|
||||
|
||||
### 4. Alle konfigurierten Server sind verbunden, solange die App im Vordergrund ist
|
||||
|
||||
Nicht nur der sichtbare. Das ist der Zweck des Multi-Server-Modells: Ungelesen-Anzeigen über
|
||||
Server hinweg funktionieren nur, wenn alle verbunden sind — sonst ist der Realtime-Layer für
|
||||
alles außer dem aktiven Server wertlos. Bei realistischen zwei bis fünf Servern sind N
|
||||
Sockets plus N Heartbeats unkritisch, und Background-Betrieb ist ohnehin out of scope, die
|
||||
Akkufrage stellt sich also nur im Vordergrund.
|
||||
|
||||
Keine plattformabhängige Politik: fehlende Ungelesen-Punkte auf dem Handy, die auf dem
|
||||
Desktop da sind, werden als Fehler wahrgenommen und nicht als Sparmaßnahme.
|
||||
|
||||
### 5. `BASE_URL` fällt; Geheimnisse kommen hinter eine Naht
|
||||
|
||||
Die Konstante `BASE_URL` in `src-tauri/src/state.rs` wird durch eine **Server-Liste**
|
||||
ersetzt (URL, Anzeigename, User-ID), persistiert über `tauri-plugin-store` — unkritische
|
||||
Metadaten.
|
||||
|
||||
Tokens und Refresh-Tokens laufen über ein schmales **`SecretStore`-Trait** mit zwei
|
||||
Methoden. Dessen Implementierung darf vorerst das Einfachste sein, das funktioniert; die
|
||||
endgültige Wahl trifft der **E2EE-Folge-Effort**, weil er exakt dasselbe Problem für
|
||||
Geräteschlüssel hat — in verschärfter Form, denn ein Geräteschlüssel ist unwiederbringlich,
|
||||
ein Token läuft ab. Dasselbe Problem zweimal zu lösen wäre Verschwendung; es jetzt falsch zu
|
||||
lösen teurer als es zu vertagen.
|
||||
|
||||
Zur Einordnung der verworfenen Alternative: der OS-Keychain ist inhaltlich das richtige
|
||||
Ziel, hat aber auf Linux — einer erklärten Client-Plattform — die schwächste Stelle, weil
|
||||
Secret Service einen laufenden gnome-keyring oder KWallet voraussetzt.
|
||||
|
||||
### Begriffe
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|---|---|
|
||||
| **Server-Liste** | Die im Client eingetragenen, voneinander unabhängigen Instanzen. Kein Server-zu-Server-Verkehr; der Client ist der einzige Aggregator. |
|
||||
| **Verbindungs-Pool** | Die N offenen Sockets im `AppState`, einer je eingetragenem Server. |
|
||||
| **`SecretStore`** | Naht für alles, was nicht im Klartext auf die Platte darf. Implementierung offen, wird vom E2EE-Effort gewählt. |
|
||||
|
||||
### Bestandskorrektur
|
||||
|
||||
Der Client pollt heute **doch** — `MessageList.tsx:33` fährt ein `setInterval(load, 3000)`
|
||||
zusätzlich zum `refreshKey`. Die frühere Notiz in der Map („kein Realtime, nur `refreshKey`")
|
||||
war falsch; der Client-README lag richtig. Ersetzt werden also **beide** Mechanismen.
|
||||
|
||||
### Folgen für andere Tickets
|
||||
|
||||
- [*Verbindungsaufbau & Auth über WS*](07-verbindungsaufbau-und-auth.md): die
|
||||
Token-Transportfrage ist damit praktisch entschieden (`Authorization: Bearer`). Offen
|
||||
bleiben dort Token-Ablauf, Autorisierung pro Conversation, Heartbeat-Intervall,
|
||||
Verbindungslimits und der selbstgebaute Graceful Shutdown.
|
||||
- [*Client-Integration*](10-client-integration.md): das Zustandsmodell im Frontend wird
|
||||
deutlich kleiner, weil dort keine Cursor-Logik mehr liegt. Neu dazu kommt die Frage, ob
|
||||
Rust für unsichtbare Conversations überhaupt nachladen soll.
|
||||
- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): Signaling läuft über den
|
||||
Rust-Socket, die `RTCPeerConnection` im WebView. Wo der signierte DTLS-Fingerprint
|
||||
entsteht — Rust oder WebCrypto im WebView — bleibt dort zu entscheiden.
|
||||
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Dumb-Pipe-Event & Backfill-Endpunkt
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie sieht der Änderungs-Event genau aus, und welche Semantik hat der
|
||||
`?since`-Backfill-Endpunkt, der ihn einlöst?
|
||||
|
||||
Die Protokollform ist entschieden (Dumb Pipe: der Socket trägt „hier hat sich etwas
|
||||
geändert", der Client lädt per REST nach). Dieses Ticket macht sie präzise.
|
||||
|
||||
**Entblockt am 14.08.2026** durch [*Monotoner Cursor*](02-monotoner-cursor.md) — das hat
|
||||
mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier **nicht**
|
||||
neu aufgemacht wird:
|
||||
|
||||
- Ein Zähler `seq` pro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist ein `bigint`.
|
||||
- Änderungsachse ist die Tabelle `conversation_events`; **eine Log-Zeile ist der Event**.
|
||||
Damit sind Edits, Soft-Deletes, Reactions und Attachments abgedeckt — sie brauchen hier
|
||||
keine eigene Achse mehr.
|
||||
- Der Backfill liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events.
|
||||
- Initialer Zustand ohne Cursor, zu alter Cursor und Antwortlimit laufen alle über
|
||||
**denselben `resync`-Pfad**.
|
||||
- Sortierung erfolgt über `messages.seq`, nicht über `created_at`.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- **Event-Format.** `{"conversation": "<uuid>", "cursor": <n>}` ist die Arbeitsannahme.
|
||||
Reicht das? Braucht es einen Ressourcen-Diskriminator (`messages` vs. `reactions` vs.
|
||||
`members`), oder ist ein Cursor pro Conversation, der alle Änderungen umfasst,
|
||||
einfacher und ausreichend? Ein Diskriminator spart Fetches, kostet aber die Einfachheit,
|
||||
die den Dumb Pipe überhaupt rechtfertigt.
|
||||
- **Serialisierung.** JSON oder etwas Kompakteres. Bei ~40 Byte Nutzlast ist der
|
||||
Unterschied vermutlich irrelevant — dann ist JSON die faule, richtige Antwort. Prüfen,
|
||||
nicht annehmen.
|
||||
- **Versionierung.** Braucht ein Protokoll mit einem Event-Typ überhaupt eine Version?
|
||||
Was ist die Regel für die Zukunft, wenn Presence und Typing dazukommen (siehe Fog)?
|
||||
- **Backfill-Endpunkt.** Erweiterung von `GET /conversations/{id}/messages` um `?since`,
|
||||
oder ein neuer Endpunkt? Paginierung, Obergrenze pro Antwort, Verhalten bei sehr
|
||||
großem Rückstand (Client war zwei Wochen offline).
|
||||
- **Genaue Form der `resync`-Antwort.** Eigener HTTP-Status, ein Feld in der normalen
|
||||
Antwort, oder ein eigener Endpunkt? Und wie groß ist das Antwortlimit tatsächlich?
|
||||
- **`kind`-Taxonomie im Log.** Welche Werte gibt es, und wie fein? `message_created` vs.
|
||||
`message_edited` vs. `message_deleted` einzeln, oder ein gemeinsames `message_changed`?
|
||||
Der Backfill löst ohnehin auf — feinere Werte nützen also nur der Server-Seite und
|
||||
späteren Konsumenten.
|
||||
- **Endpunkt-Zuschnitt.** Ein Backfill pro Conversation, oder einer über alle Conversations
|
||||
eines Servers (der Client hat womöglich 40 offene)? Letzteres spart Requests beim
|
||||
Wiederverbinden, verkompliziert aber Limit und `resync`, weil beides dann pro Conversation
|
||||
unterschiedlich ausfallen kann.
|
||||
- **Was bleibt im Klartext.** Auch wenn der Inhalt später ein Blob ist: Conversation-ID,
|
||||
Cursor, Absender und Zeitstempel muss der Server sehen, um zu routen und zu ordnen.
|
||||
Diese Grenze hier explizit festhalten — sie ist die Zusage an den E2EE-Folge-Effort.
|
||||
|
||||
### Randbedingung
|
||||
|
||||
Der Event muss unter dem **8-kB-Payload-Limit von Postgres `NOTIFY`** bleiben. Das ist
|
||||
kein Zufall, sondern der Grund, warum diese Protokollform gewählt wurde — siehe
|
||||
*Fan-out-Architektur*.
|
||||
|
||||
Flüchtiges (SDP/ICE, später Typing) läuft **nicht** über diesen Event. Die Naht verläuft
|
||||
entlang persistiert vs. flüchtig; das flüchtige Format entsteht in
|
||||
*Voice-Signaling-Protokoll*.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### 1. Der Event ist minimal: `{conversation, seq}`
|
||||
|
||||
Rund 60 Byte. Die Server-Identität steckt nicht drin — sie ergibt sich aus dem Socket, über
|
||||
den der Event kam. Der `seq` ist kein Luxus: der Empfänger verwirft damit sofort, was er
|
||||
schon hat, statt blind nachzuladen.
|
||||
|
||||
**Verworfen wurden `kind` und `target_id` im Event.** Das entscheidende Argument war die
|
||||
Umkehrbarkeit: ein zusätzliches Feld lässt sich später **additiv** ergänzen, weil ein
|
||||
unbekanntes Feld ignorierbar ist. Es gibt also keinen Grund, heute dafür zu bezahlen. Sollte
|
||||
sich der vorhergesehene Fall einstellen — Read-Receipts aus der Fog bumpen den Zähler in
|
||||
einem aktiven Channel und lösen bei allen Clients Backfills aus, die nichts Sichtbares
|
||||
ändern — wird `kind` nachgerüstet, ohne dass Bestehendes bricht.
|
||||
|
||||
Damit bleibt `conversation_events.kind` **rein serverintern**. Die Taxonomie ist ein
|
||||
Implementierungsdetail, kein Protokollbestandteil, und muss nicht versioniert werden.
|
||||
|
||||
### 2. Aufholen nach Reconnect: Manifest, dann gezielte Backfills
|
||||
|
||||
Ein leichter Endpunkt liefert für alle Conversations, in denen der Nutzer Mitglied ist, den
|
||||
aktuellen `seq`-Stand — eine Liste `{conversation_id, seq}`. Das ist **eine** günstige
|
||||
Abfrage, weil `conversations.seq` seit [*Monotoner Cursor*](02-monotoner-cursor.md) als
|
||||
Spalte existiert: ein Join über `conversation_members`, kein Scan über Nachrichten.
|
||||
|
||||
Der Client vergleicht mit seinen eigenen Cursorn und holt nur die tatsächlich geänderten.
|
||||
Bei vierzig Conversations und drei geänderten sind das 1 + 3 Requests statt 40 — pro Server,
|
||||
und der Client hält mehrere gleichzeitig.
|
||||
|
||||
Der Endpunkt beantwortet zwei weitere Fragen gratis: die Ungelesen-Anzeige über alle
|
||||
Conversations, und den Erstverbindungsfall ohne jeden Cursor.
|
||||
|
||||
Verworfen: ein Sammel-Backfill mit Cursor-Map (müsste ein POST mit Body sein, Limit und
|
||||
`resync` würden pro Conversation unterschiedlich, und eine große Antwort ist bei Abbruch
|
||||
komplett verloren) sowie N parallele Backfills (vierzig Requests pro Reconnect, davon
|
||||
siebenunddreißig leer — genau das Verhalten, das beim Server-Neustart einen Thundering Herd
|
||||
erzeugt).
|
||||
|
||||
### 3. `resync` ist ein unterschiedener Antworttyp, kein Fehlerstatus
|
||||
|
||||
Der Backfill antwortet immer mit 200:
|
||||
|
||||
```json
|
||||
{ "kind": "delta", "cursor": 4711, "messages": [...], "reactions": [...], "attachments": [...] }
|
||||
{ "kind": "resync", "cursor": 4711 }
|
||||
```
|
||||
|
||||
Der Grund ist praktisch, nicht ästhetisch: `resync` ist kein Fehler, sondern ein erwarteter
|
||||
Ausgang — er tritt bei jeder Erstverbindung auf. Als 4xx liefe er durch
|
||||
`error_for_status()`-Reflexe, landete in Fehler-Logs und Alarmen, und die Reconnect-Logik
|
||||
könnte ihn mit einem echten Netzfehler verwechseln. Ein unterschiedener Antworttyp zwingt
|
||||
den Client, beide Fälle explizit zu behandeln — was er ohnehin muss.
|
||||
|
||||
**Antwortlimit: 500 Entitäten als Startwert.** Eine Stellschraube, nachträglich änderbar
|
||||
ohne Protokolländerung. Wird es überschritten, ist die Antwort `resync`.
|
||||
|
||||
Der `delta`-Fall liefert **Tombstones mit** — soft-gelöschte Zeilen müssen im Ergebnis
|
||||
erscheinen, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
|
||||
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
|
||||
|
||||
### 4. Die Klartext-Grenze, konkret gezogen
|
||||
|
||||
Bindend für den E2EE-Folge-Effort. **Klartext bleibt**, weil der Server es zum Routen,
|
||||
Ordnen, Autorisieren und Durchsetzen von Eindeutigkeit braucht:
|
||||
|
||||
`conversation_id` · `seq` · alle IDs · `user_id` (nur der Autor darf bearbeiten) ·
|
||||
`created_at` / `updated_at` / `deleted_at` · `attachments.size_bytes` · `attachments.url`
|
||||
|
||||
Zeitstempel zu verschlüsseln wurde erwogen und verworfen: der Betreiber sieht den Zeitpunkt
|
||||
ohnehin am Socket-Verkehr, der Gewinn wäre null.
|
||||
|
||||
**Blob wird:**
|
||||
|
||||
| Feld | Begründung |
|
||||
|---|---|
|
||||
| `messages.content` | der eigentliche Inhalt |
|
||||
| `attachments.filename` | ein Dateiname wie `Kündigung_Mueller.pdf` ist so sensibel wie der Nachrichtentext |
|
||||
| `attachments.mime_type` | verrät die Art des Dokuments |
|
||||
| `reactions.emoji` | **deterministisch** verschlüsselt, damit `UNIQUE (message_id, user_id, emoji)` weiter greift — der Server weist Doppelreaktionen ab, ohne sie lesen zu können |
|
||||
|
||||
`size_bytes` bleibt zwangsläufig sichtbar.
|
||||
|
||||
Der **Mechanismus** bleibt dem E2EE-Effort überlassen, die **Feldliste** nicht. Begründung:
|
||||
die Migration über diese Tabellen fällt in diesem Effort an — die Grenze später zu ziehen
|
||||
hieße, dieselben Tabellen ein zweites Mal zu migrieren, und bis dahin lägen Dateinamen im
|
||||
Klartext auf einem Server, dessen Betreiber als Angreifer gilt.
|
||||
|
||||
### 5. Abgeleitet, nicht eigens entschieden
|
||||
|
||||
Diese drei folgen aus dem Obigen; sie sind als Vorschlag zu lesen und jederzeit umkehrbar:
|
||||
|
||||
- **Serialisierung: JSON.** Bei 60 Byte Nutzlast bringt ein Binärformat nichts, was den
|
||||
Werkzeugverlust aufwöge.
|
||||
- **Keine Protokollversion.** Ein Event-Typ, Felder werden additiv ergänzt, unbekannte
|
||||
Felder ignoriert. Ein Versionsfeld wäre Zeremonie ohne Gegenwert.
|
||||
- **Drei Endpunkte statt einer Erweiterung von `/messages`:**
|
||||
|
||||
| Endpunkt | Zweck |
|
||||
|---|---|
|
||||
| `GET /sync` | Manifest: `{conversation_id, seq}` für alle Mitgliedschaften |
|
||||
| `GET /conversations/{id}/changes?since=N` | Delta oder `resync` |
|
||||
| `GET /conversations/{id}/messages?before=<seq>&limit=` | seitenweise Historie — für `resync` und für Zurückscrollen |
|
||||
|
||||
`/changes` bekommt einen eigenen Endpunkt, weil seine Antwort nicht nur Nachrichten
|
||||
enthält, sondern auch Reactions, Attachments und Tombstones — eine andere Form als die von
|
||||
`/messages`.
|
||||
|
||||
### Nebenbefund
|
||||
|
||||
`list_messages` gibt heute **alle** Nachrichten einer Conversation unbegrenzt zurück
|
||||
(`ORDER BY created_at ASC`, kein Limit). Das ist unabhängig vom Realtime-Umbau ein Problem
|
||||
und wird durch die Seitenaufteilung oben mit behoben.
|
||||
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Verbindungsaufbau & Auth über WS
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie authentifiziert sich eine WebSocket-Verbindung, wie bleibt sie am Leben, und was
|
||||
passiert, wenn das Token abläuft, während sie steht?
|
||||
|
||||
Die Faktenlage liegt vor: [*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md)
|
||||
(**entblockt am 14.08.2026**), Volltext in
|
||||
[`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md). Dieses Ticket
|
||||
entscheidet.
|
||||
|
||||
### Vier Befunde, die die Optionen bereits verschoben haben
|
||||
|
||||
1. **Kein Transportweg schützt vor dem Betreiber** — der Server muss das Token sehen. Der
|
||||
Unterschied ist ausschließlich, was den Handshake in Logs, Backups und APM-Systemen
|
||||
**überlebt**. In den Defaults von nginx, Apache und Caddy landet der Query-String im
|
||||
Access-Log; Caddy redigiert `Authorization` aktiv, `request.uri` nicht.
|
||||
2. **`Sec-WebSocket-Protocol` ist schlechter als erwartet** — Caddy loggt per Default alle
|
||||
Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu: `=` ist als
|
||||
Separator verboten (JWT-Base64-Padding), und der Server muss das Subprotokoll echoen.
|
||||
3. **Die Browser-API verschluckt alle Fehlerdetails** (WHATWG-Vorgabe): im WebView ist
|
||||
„Token abgelaufen" nicht von „Server down" unterscheidbar — immer `CloseEvent.code === 1006`.
|
||||
`tungstenite::Error::Http` trägt dagegen die vollständige Response. Wenn die Verbindung im
|
||||
Rust-Layer lebt, ist `Authorization: Bearer` möglich **und** die Fehlerdiagnose brauchbar;
|
||||
beides zusammen macht die Entscheidung in
|
||||
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) fast zur Vorentscheidung
|
||||
für dieses Ticket.
|
||||
4. **Verworfen, nicht übernehmen:** Referrer-Leak und Browser-History als Argumente gegen den
|
||||
Query-String ließen sich für `new WebSocket(url)` **nicht** belegen. Belegbar bleiben
|
||||
Access-Logs, Log-Aggregation und deren Backups — das reicht als Argument.
|
||||
|
||||
### Zusätzlich zu entscheiden: Graceful Shutdown selbst bauen
|
||||
|
||||
`axum::serve(...).with_graceful_shutdown(...)` **beendet WebSocket-Verbindungen nicht und
|
||||
wartet nicht auf sie** — vierfach belegt (hyper-util-Doku „does nothing" nach dem Hijack,
|
||||
axum-Issue #3003 offen seit 21.10.2024, PR #3831 offen seit 11.07.2026, und der `close_rx`-Guard
|
||||
hängt nur an der hyper-Task). Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen
|
||||
selbst tracken. Belegte Bausteine: `CancellationToken` (ein Child-Token pro Verbindung) plus
|
||||
`TaskTracker` (auf N Tasks warten). Beim Shutdown einen Close-Frame senden — `WebSocket::close`
|
||||
existiert seit axum 0.8 nicht mehr, der Frame geht explizit über `send`; Code 1001 „going away"
|
||||
oder 1012 „service restart".
|
||||
|
||||
Weitere belegte Randbedingungen: `middleware::from_fn_with_state` **greift** beim
|
||||
Upgrade-Request (401/403 regulär sendbar); Protokoll-Ping ist aus dem WebView **nicht auslösbar**;
|
||||
dokumentierter Proxy-Idle-Korridor: Azure AppGW 20 s, GCP 30 s, nginx 60 s (die verbreiteten
|
||||
Cloudflare- und HAProxy-Werte sind Mythen und ließen sich nicht belegen).
|
||||
|
||||
**Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
|
||||
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026. Bestand: Access-Token 15 Minuten (`auth.rs:22`), Refresh-Token
|
||||
30 Tage, gehasht in `auth_tokens` und löschbar — Widerruf existiert bereits auf Refresh-Ebene.
|
||||
|
||||
### 1. Handshake: `Authorization: Bearer` (abgeleitet)
|
||||
|
||||
Folgt aus [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): die
|
||||
Verbindung lebt in Rust, eigene Header sind also möglich. Laut Recherche der einzige Kanal,
|
||||
den die Defaults von nginx, Apache und Caddy nicht mitschreiben.
|
||||
`middleware::from_fn_with_state` greift beim Upgrade-Request, ein 401 mit Body ist regulär
|
||||
sendbar — und `tungstenite::Error::Http` trägt ihn vollständig zum Client.
|
||||
|
||||
### 2. Token-Ablauf: In-Band-Re-Auth über den offenen Socket
|
||||
|
||||
Der Client erneuert sein Access-Token wie bisher per REST und schiebt es als Frame über den
|
||||
bestehenden Socket; der Server prüft und verlängert. Kein Reconnect, keine Handshake-Welle,
|
||||
kein erneuter Manifest-Abruf — bei 15 Minuten Tokenlebensdauer und drei verbundenen Servern
|
||||
wären das sonst zwölf Reconnects pro Stunde.
|
||||
|
||||
Der Socket ist für Voice-Signaling ohnehin bidirektional; dies ist damit der **zweite**
|
||||
client→server-Frame-Typ, nicht der erste.
|
||||
|
||||
**Widerruf wirkt trotzdem:** schlägt die REST-Erneuerung fehl, weil das Refresh-Token
|
||||
gelöscht wurde, kann der Client nicht verlängern, und der Server schließt bei Ablauf mit
|
||||
`4001`. Die Wirkungsverzögerung ist damit auf die Restlaufzeit des Access-Tokens begrenzt,
|
||||
also höchstens 15 Minuten — dieselbe Schranke wie bei einer Verbindungs-TTL.
|
||||
|
||||
Verworfen: eine serverseitige Widerrufsliste. Sie machte Widerruf sofort wirksam, kostet aber
|
||||
geteilten Zustand über Instanzen hinweg plus eine Prüfung je Event — und gegen den Betreiber
|
||||
als Angreifer wirkt sie per Definition gar nicht, weil er die Liste kontrolliert.
|
||||
|
||||
### 3. Autorisierung: Registry ist maßgeblich, REST prüft je Anfrage
|
||||
|
||||
Die Zustellung folgt der Registry aus [*Fan-out-Architektur*](08-fan-out-architektur.md). Die
|
||||
**Sicherheitsgrenze liegt bei den REST-Endpunkten** — Manifest, Backfill und Historie prüfen
|
||||
Mitgliedschaft bei jeder Anfrage.
|
||||
|
||||
Das trägt, weil der Event nur `{conversation, seq}` enthält und **keinen Inhalt**. Eine
|
||||
Abweichung der Registry von der Datenbank leckt folglich nur Metadaten („Conversation X war
|
||||
um 14:03 aktiv"), niemals Inhalte — und Metadaten gegenüber dem Server hat die Map
|
||||
ausdrücklich als akzeptabel eingestuft.
|
||||
|
||||
Verworfen: eine Datenbankprüfung je Event (bei einem Channel mit fünfhundert Mitgliedern
|
||||
fünfhundert Abfragen für eine Nachricht, zusätzlich zum Backfill, der ohnehin prüft) und
|
||||
periodische Neuvalidierung (ein weiterer Mechanismus ohne nachgewiesenes Driftproblem —
|
||||
bleibt als billiges Sicherheitsnetz in der Hinterhand).
|
||||
|
||||
### 4. Heartbeat: der Client pingt alle 15 Sekunden
|
||||
|
||||
Protokoll-Ping, kein Application-Frame — im WebView wäre das nicht möglich, in Rust schon.
|
||||
Der Server antwortet (tungstenite automatisch) und führt `last_seen`; nach zwei ausbleibenden
|
||||
Intervallen räumt er die Registry auf. Der Client erkennt eine tote Verbindung am
|
||||
ausbleibenden Pong. **Eine Richtung bedient beide Bedürfnisse.**
|
||||
|
||||
Der Client treibt, weil er hinter NAT und Proxy sitzt und sein Intervall situativ anpassen
|
||||
kann — im Hintergrund oder bei schlechtem Funk seltener, ohne dass der Server davon wissen
|
||||
muss. Bei N verbundenen Servern kann er die Pings zeitlich verteilen statt sie zu bündeln.
|
||||
|
||||
**15 Sekunden**, um unter dem striktesten dokumentierten Proxy-Idle-Timeout der Recherche zu
|
||||
bleiben (Azure Application Gateway, 20 s). Konfigurierbar — die meisten Aufstellungen sind
|
||||
nginx mit 60 s. Die verbreiteten Cloudflare- und HAProxy-Werte ließen sich laut Recherche
|
||||
nicht belegen und sind nicht Grundlage dieser Wahl.
|
||||
|
||||
### 5. Verbindungslimit: acht je Nutzer, die älteste weicht
|
||||
|
||||
Bei Überschreitung wird die **älteste** Verbindung mit `4002` getrennt, nicht die neue
|
||||
abgewiesen. Das ist der wichtige Teil: ein Gerät mit hängengebliebener Verbindung könnte sich
|
||||
sonst selbst aussperren — und genau das passiert auf Mobile regelmäßig, wenn das
|
||||
Betriebssystem eine App abräumt, ohne dass der Socket sauber schließt.
|
||||
|
||||
Acht ist großzügig gegenüber dem legitimen Fall (Desktop, Handy, Tablet, Zweitrechner) und
|
||||
trotzdem eine harte Obergrenze. Gezählt wird in der `user_id`-Registry, die es seit
|
||||
Ticket 08 ohnehin gibt.
|
||||
|
||||
Ein Rate-Limit auf Handshakes wurde erwogen und **nicht** jetzt entschieden: es trifft ein
|
||||
anderes Missbrauchsmuster, ist aber ohne Betriebserfahrung nicht kalibrierbar — ein
|
||||
Reconnect-Sturm nach einem Server-Neustart sieht genauso aus wie ein Angriff. Bleibt in der
|
||||
Fog-Notiz zum Rate-Limiting.
|
||||
|
||||
### 6. Close-Codes und Reconnect-Politik
|
||||
|
||||
Drei Handlungsklassen im Client:
|
||||
|
||||
| Code | Bedeutung | Klasse |
|
||||
|---|---|---|
|
||||
| `1012` | Server-Neustart | **sofort neu verbinden**, mit Jitter |
|
||||
| `4001` | Token abgelaufen, keine gültige Re-Auth | Token erneuern, dann sofort neu verbinden; scheitert die Erneuerung → Anmeldung |
|
||||
| `4002` | durch neuere Verbindung verdrängt | **nicht** neu verbinden |
|
||||
| `4003` | Konto gesperrt oder gelöscht | **nicht** neu verbinden, zur Anmeldung |
|
||||
| `1001`, `1006`, alles Übrige | Netzfehler, unbekannt | mit Backoff neu verbinden |
|
||||
|
||||
`4002` ist der Grund, warum es diese Tabelle überhaupt gibt: ohne ein „nicht neu verbinden"
|
||||
verdrängt ein verdrängtes Gerät sofort das nächste, und bei mehr aktiven Geräten als erlaubt
|
||||
entsteht ein Kreisverkehr.
|
||||
|
||||
Der Nutzen reicht über den Reconnect hinaus: der Client muss je Server einen aussagekräftigen
|
||||
Zustand anzeigen, und „Anmeldung abgelaufen" von „Server nicht erreichbar" zu unterscheiden
|
||||
geht nur so.
|
||||
|
||||
**Backoff (abgeleitet):** Full Jitter nach dem in der Recherche belegten Muster —
|
||||
`warte = random(0, min(30 s, 0,5 s · 2^Versuch))`. Der Zufallsanteil ist nicht optional; ohne
|
||||
ihn kehren nach einem Neustart alle Clients gleichzeitig zurück.
|
||||
|
||||
### 7. Graceful Shutdown selbst bauen (abgeleitet)
|
||||
|
||||
Vierfach belegt in der Recherche: `axum::serve(...).with_graceful_shutdown(...)` beendet
|
||||
WebSocket-Verbindungen nicht und wartet nicht auf sie (axum-Issue #3003 offen seit
|
||||
21.10.2024, PR #3831 offen seit 11.07.2026). Bausteine:
|
||||
|
||||
- Ein `CancellationToken` je Verbindung, abgeleitet vom globalen Shutdown-Token.
|
||||
- Ein `TaskTracker`, um beim Herunterfahren auf die Verbindungstasks zu warten.
|
||||
- Beim Shutdown Close-Frame mit `1012` senden — `WebSocket::close` gibt es seit axum 0.8
|
||||
nicht mehr, der Frame geht explizit über `send`.
|
||||
- `broadcast::Receiver::recv` und `CancellationToken::cancelled` sind dokumentiert cancel-safe;
|
||||
für `SplitStream::next()` fehlt eine explizite Zusage, weshalb die axum-Beispiele
|
||||
durchweg in zwei Tasks aufteilen statt über den Socket-Stream zu selektieren.
|
||||
|
||||
Sobald PR #3831 gemerged ist, kann das eigene Tracking entfallen — bis dahin ist es zwingend.
|
||||
|
||||
### Nebenbefund
|
||||
|
||||
`axum = "0.8.9"` ist heute **ohne das `ws`-Feature** eingebunden; es fehlt also die
|
||||
Voraussetzung überhaupt. Und `Extension<Claims>` wird nirgends gelesen — die Middleware
|
||||
authentifiziert, aber kein Handler nutzt die Identität. Beides gehört in die Umsetzung.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- **Handshake-Auth.** Token im Query-String ist bei diesem Bedrohungsmodell heikel
|
||||
(Server-Logs, Proxy-Logs, Browser-History). Welcher Weg wird es? Falls die Verbindung
|
||||
im Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), ist ein
|
||||
`Authorization`-Header möglich — dann ist die Frage fast beantwortet.
|
||||
- **Token-Ablauf bei laufender Verbindung.** Verbindungs-TTL mit erzwungenem Reconnect,
|
||||
In-Band-Re-Auth, oder Revocation-Prüfung. Wichtig: eine Verbindung darf einen
|
||||
**Token-Widerruf nicht überleben** — bei „Nutzersicherheit als Erfolgsfaktor" ist eine
|
||||
Sitzung, die nach dem Logout weiterläuft, ein echter Fehler, kein Schönheitsfleck.
|
||||
- **Autorisierung.** Der Nutzer darf nur Events für Conversations sehen, in denen er
|
||||
Mitglied ist. Wird die Mitgliedschaft beim Verbindungsaufbau einmal geladen oder pro
|
||||
Event geprüft? Was passiert, wenn er während der Verbindung aus einer Conversation
|
||||
entfernt wird — oder hinzugefügt?
|
||||
- **Heartbeat.** Protokoll-Ping/Pong oder Application-Level, mit welchen Intervallen.
|
||||
Zielgruppe Firmen heißt: hinter Proxies, die Idle-Verbindungen abräumen.
|
||||
- **Reconnect-Parameter.** Backoff-Kurve und Jitter, konkret. Was passiert beim
|
||||
Server-Neustart, wenn alle Clients gleichzeitig zurückkommen?
|
||||
- **Verbindungslimits.** Wieviele Verbindungen pro Nutzer sind erlaubt? Multi-Device ist
|
||||
gefordert (Desktop + Mobile parallel), also mehr als eine — aber nicht unbegrenzt,
|
||||
sonst ist es ein Vektor auf einem öffentlichen Server.
|
||||
- **Sauberes Herunterfahren.** Wie werden offene Verbindungen beim Server-Shutdown
|
||||
beendet, sodass Clients einen geordneten Reconnect machen statt in Timeouts zu laufen?
|
||||
203
docs/realtime/issues/08-fan-out-architektur.md
Normal file
203
docs/realtime/issues/08-fan-out-architektur.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Fan-out-Architektur
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie kommt ein Änderungs-Event von „Nutzer A hat geschrieben" zu allen betroffenen offenen
|
||||
Verbindungen — und wie sieht die Naht aus, hinter der später `LISTEN/NOTIFY` steckt?
|
||||
|
||||
Entschieden ist bereits: Start mit In-Process-Fan-out, aber hinter einem Trait; `NOTIFY`
|
||||
als zweite Implementierung; **kein Redis** (harte Regel: eine Binary + Postgres).
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- **Registry-Struktur.** `conversation_id → Menge von Sendern`, `user_id → Menge von
|
||||
Verbindungen`, oder beides? Ein Nutzer kann in vielen Conversations sein und mehrere
|
||||
Geräte haben — welche Indizierung hält beides aus, ohne bei jedem Event über alles zu
|
||||
iterieren?
|
||||
- **Kanaltyp.** `tokio::sync::broadcast` pro Conversation, `mpsc` pro Verbindung, oder
|
||||
ein globaler Broadcast mit clientseitigem Filter. Bei `broadcast` ist zu klären, wie
|
||||
`RecvError::Lagged` behandelt wird — es bedeutet hier „Resync", nicht „Fehler", und
|
||||
das muss explizit im Design stehen (Council-Hinweis des Engineer).
|
||||
- **Auf- und Abräumen.** Wann wird ein Eintrag entfernt? Verbindungsabbrüche sind der
|
||||
Normalfall, nicht die Ausnahme — Leaks in der Registry sind der klassische Fehler.
|
||||
- **Trait-Zuschnitt.** Wie schmal muss die Schnittstelle sein, damit die
|
||||
`NOTIFY`-Implementierung sie wirklich erfüllen kann? `NOTIFY` ist *fire and forget*
|
||||
ohne Zustellgarantie und ohne Rückkanal — ein Trait, das mehr verspricht als das,
|
||||
ist eine Lüge, die beim Wechsel auffliegt.
|
||||
- **`NOTIFY`-Pfad konkret genug, um ihn zu bewerten.** Ein Channel pro Conversation
|
||||
(Postgres hat ein Limit an Channels pro Verbindung), oder ein Channel mit
|
||||
Conversation-ID im Payload? Wer hält die `LISTEN`-Verbindung, und was passiert, wenn
|
||||
sie abreißt?
|
||||
- **Wo wird die `conversation_events`-Zeile geschrieben?** [*Monotoner Cursor*](02-monotoner-cursor.md)
|
||||
hat die Tabelle festgelegt, diese Frage aber bewusst hierher verschoben, weil sie mit
|
||||
`NOTIFY` zusammenfällt. Also: im Handler in derselben Transaktion, oder per
|
||||
Datenbank-Trigger? Ein Trigger feuert garantiert bei jedem Schreibweg, ein
|
||||
Handler-Aufruf ist einfacher zu verstehen. Bei Trigger + `NOTIFY` fällt die zweite
|
||||
Implementierung fast von selbst ab — das ist ein Argument, das geprüft gehört.
|
||||
### Faktenlage aus der Auth-Recherche (14.08.2026)
|
||||
|
||||
Aus [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md) §6, belegt:
|
||||
|
||||
- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe.** Es „retains only the last sent
|
||||
value" — was exakt die Semantik von „der Cursor der Conversation X ist jetzt N, hol dir den
|
||||
Rest per REST" ist. **Kein Lag-Fehler möglich**, weil Zwischenwerte per Konstruktion
|
||||
entbehrlich sind. Das ist ein ernstzunehmender Kandidat gegen `broadcast` und sollte hier
|
||||
gegen es antreten, nicht stillschweigend übergangen werden.
|
||||
- **`RecvError::Lagged(n)` präzise:** `n` = Anzahl übersprungener Nachrichten, der Receiver
|
||||
bleibt **brauchbar**, sein Cursor springt auf die älteste noch vorhandene Nachricht.
|
||||
⚠️ **„Lagged = Resync" ist nirgends als offizielles Muster dokumentiert** — gängige
|
||||
Community-Praxis ohne Primärquelle. Belegbar sind nur die Bausteine: `resubscribe()`
|
||||
(verwirft alles Alte), `Receiver::len()` als Frühwarnung *vor* dem Datenverlust, und
|
||||
`mpsc::try_send` pro Verbindung, um die Drop-Entscheidung **pro Client** statt global zu
|
||||
treffen.
|
||||
- **`broadcast` speichert einmal und klont pro Receiver** ⇒ der Event-Typ sollte billig
|
||||
klonbar sein (`Arc<…>`, `bytes::Bytes`). Kapazität wird auf die nächste Zweierpotenz
|
||||
aufgerundet.
|
||||
- **`async_broadcast` scheidet vermutlich aus:** es bietet echte Backpressure, womit ein
|
||||
langsamer WS-Client den **gesamten Producer** bremst (Head-of-Line über alle Clients) — das
|
||||
widerspricht der Map-Entscheidung „Best-effort + `?since`-Backfill" frontal.
|
||||
**`flume` scheidet sicher aus:** MPMC heißt dort Work-Queue, jede Nachricht geht an genau
|
||||
einen Receiver — kein Broadcast.
|
||||
- **`PgListener` (sqlx)** reconnected selbst und re-subscribed alle Channels, belegt **aber
|
||||
eine dedizierte Postgres-Connection außerhalb des `PgPool`** (heute `max_connections = 10`)
|
||||
— beim Sizing einplanen. `NOTIFY` ist transaktional, feuert also erst bei COMMIT, was gut
|
||||
zur DB-als-Queue-Semantik passt. **Neue Gefahr:** wird die Notify-Queue voll (Listener
|
||||
hängen), schlagen Transaktionen **beim Commit fehl**. Und `NOTIFY` liefert keine Historie —
|
||||
ein verpasstes Signal ist weg, also dieselbe Resync-Frage eine Ebene tiefer.
|
||||
|
||||
- **Reihenfolge.** Garantiert das Fan-out die Reihenfolge der Events pro Conversation?
|
||||
Falls nein: ist das egal, weil der Cursor monoton ist und der Client ohnehin nachlädt?
|
||||
(Vermutlich ja — aber das explizit festhalten.)
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### Vorbemerkung, die das ganze Ticket entschärft
|
||||
|
||||
Weil der Event `{conversation, seq}` ist und der Backfill über den Cursor läuft, zählt **nur
|
||||
der jeweils neueste `seq` je Conversation**. 5, dann 6, dann 7 zuzustellen ist gleichwertig
|
||||
damit, nur 7 zuzustellen. Das Fan-out darf zusammenfassen und sogar verlieren, ohne dass
|
||||
Daten verlorengehen — schlimmstenfalls ist ein Client kurz veraltet, und das Manifest beim
|
||||
nächsten Reconnect fängt alles auf.
|
||||
|
||||
Damit löst sich die `RecvError::Lagged`-Sorge aus
|
||||
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) weitgehend
|
||||
auf: Lag ist hier kein Datenverlust, sondern Latenz.
|
||||
|
||||
### 1. Registry: `conversation_id → Verbindungen`, ein Kanal je Verbindung
|
||||
|
||||
Eine `HashMap<ConversationId, Set<Verbindung>>` im Serverzustand. Beim Verbindungsaufbau
|
||||
werden die Mitgliedschaften einmal geladen und die Verbindung unter jeder eingetragen, beim
|
||||
Trennen entfernt. Der Publisher schlägt einmal nach und stellt gezielt zu.
|
||||
|
||||
Entscheidend: **jede Verbindung hat genau einen Empfänger**. Die Verbindungsschleife bleibt
|
||||
ein `select!` mit zwei Zweigen, statt über vierzig Kanäle gleichzeitig warten zu müssen. Die
|
||||
Mitgliedschaftsliste braucht die Verbindung ohnehin zur Autorisierung — die Registry ist also
|
||||
kein zusätzlicher Zustand.
|
||||
|
||||
Verworfen: ein Kanal je Conversation (eine Verbindung müsste auf N Empfängern warten,
|
||||
`SelectAll` plus dynamisches An- und Abmelden während des Wartens) und ein globaler Kanal mit
|
||||
Filter (zehn Zeilen Code, aber jede Verbindung wird bei jedem Event serverweit geweckt —
|
||||
bei tausend Verbindungen und hundert Events pro Sekunde hunderttausend Aufwachvorgänge, die
|
||||
fast alle nichts tun).
|
||||
|
||||
### 2. Zwei Registries, weil Mitgliedschaftswechsel anders adressiert werden
|
||||
|
||||
Ein Beitritt kann nicht über die Conversation-Registry laufen: der Nutzer ist dort noch nicht
|
||||
eingetragen, das ist ja gerade der Punkt. Es gibt daher eine zweite Karte
|
||||
`user_id → Verbindungen`. Mitgliedschafts-Events werden **pro Nutzer** zugestellt; die
|
||||
Instanz, die diese Verbindung hält, trägt sie daraufhin in die Conversation-Registry ein oder
|
||||
aus.
|
||||
|
||||
Damit funktioniert es auch über Instanzgrenzen: die Mitgliedschaftsänderung reist als `NOTIFY`
|
||||
mit der `user_id` im Payload, und jede Instanz aktualisiert nur ihre eigenen Verbindungen.
|
||||
|
||||
### 3. Puffer je Verbindung: Dirty-Set mit `Notify`
|
||||
|
||||
Je Verbindung eine `HashMap<ConversationId, seq>` hinter einem Mutex plus ein `Notify`. Der
|
||||
Publisher trägt ein und weckt; die Schreibtask leert die Karte und schickt einen Event je
|
||||
Conversation.
|
||||
|
||||
Das kodiert „nur der neueste zählt" direkt im Datentyp: perfektes Zusammenfassen, kein
|
||||
Verlust, Speicher begrenzt durch die Anzahl der Conversations statt durch die Event-Rate.
|
||||
**Kein Überlauf, also kein Überlauf-Fehlerpfad.**
|
||||
|
||||
⚠️ **Wichtiges Detail:** der Eintrag ist `max(vorhandener, neuer)`, kein blindes `insert`.
|
||||
`NOTIFY` garantiert Reihenfolge nur je Listener-Verbindung; ein blindes Überschreiben würde
|
||||
sonst im ungünstigen Fall einen neueren `seq` durch einen älteren ersetzen.
|
||||
|
||||
Verworfen: `mpsc` + `try_send`. Bei vollem Puffer verwirft es den **neuen** Event und behält
|
||||
die alten — also ausgerechnet den einzigen, auf den es ankommt, und im Log nicht von
|
||||
Normalbetrieb unterscheidbar. Die Variante „bei Überlauf trennen" wäre korrekt, macht die
|
||||
Puffergröße aber zur Stellschraube zwischen „trennt zu früh" und „merkt Blockade zu spät",
|
||||
mit Pendelrisiko bei langsamen Mobilclients.
|
||||
|
||||
### 4. Log-Zeile im Handler, `NOTIFY` per Trigger auf `conversation_events`
|
||||
|
||||
Die Fachlogik bleibt in Rust: der Handler zieht den `seq`
|
||||
(`UPDATE conversations … RETURNING`, was laut [*Monotoner Cursor*](02-monotoner-cursor.md)
|
||||
ohnehin in seiner Transaktion passieren muss) und schreibt die Log-Zeile mit passendem `kind`.
|
||||
|
||||
Auf `conversation_events` sitzt ein Trigger, der **nichts** tut außer `pg_notify` zu feuern —
|
||||
drei Zeilen PL/pgSQL ohne Fachlogik. Damit ist der Benachrichtigungspfad unvergesslich: wer
|
||||
immer eine Log-Zeile schreibt, löst das Signal aus, auch künftige Handler und manuelle
|
||||
Eingriffe. Und weil `NOTIFY` transaktional ist, feuert es erst beim Commit — genau dann, wenn
|
||||
die Zeile sichtbar wird.
|
||||
|
||||
Verworfen: alles im Handler (jeder künftige Schreibweg müsste daran denken — die Sorte
|
||||
Vergessen, die sich als „manchmal aktualisiert sich der Client nicht" äußert) und alles per
|
||||
Trigger auf den Fachtabellen (die Kernlogik des Realtime-Layers läge dann in PL/pgSQL,
|
||||
verteilt über drei Trigger, schlecht testbar und schwer zu lesen).
|
||||
|
||||
### 5. Kein Trait — immer über `NOTIFY`, auch bei einer Instanz
|
||||
|
||||
**Das revidiert eine Randbedingung der Map.** Sie sah „In-Process hinter einem Trait,
|
||||
`NOTIFY` als zweite Implementierung" vor. Das wurde formuliert, bevor feststand, dass der
|
||||
Event 60 Byte groß ist und der Trigger `NOTIFY` ohnehin feuert.
|
||||
|
||||
Der Server hört sich per `PgListener` selbst zu und speist die Registry. **Ein** Pfad für
|
||||
Ein- und Mehr-Instanz-Betrieb: kein Trait, keine zweite Implementierung, und die
|
||||
Mehr-Instanz-Fähigkeit ist ab Tag eins da statt nur vorgesehen. Der getestete Pfad ist
|
||||
automatisch der Produktionspfad — der eigentliche Gewinn, denn beim Trait wäre ausgerechnet
|
||||
der `NOTIFY`-Pfad beim ersten Skalierungsschritt der ungetestete gewesen.
|
||||
|
||||
Ein einziger Channel mit der Conversation-ID im Payload, nicht einer je Conversation:
|
||||
dynamisches `LISTEN`/`UNLISTEN` über alle Conversations wäre unbegrenzt, und 60 Byte liegen
|
||||
weit unter der 8-kB-Grenze.
|
||||
|
||||
**Betriebliche Kosten, bewusst akzeptiert:**
|
||||
|
||||
- Jeder Event läuft durch Postgres — auf demselben Host im Sub-Millisekunden-Bereich.
|
||||
- `PgListener` belegt eine **dedizierte Verbindung außerhalb des Pools** (heute
|
||||
`max_connections = 10`) — beim Sizing einplanen.
|
||||
- Hängt ein Listener und die Notify-Queue läuft voll, **scheitern Transaktionen beim
|
||||
Commit**. Das braucht Überwachung und gehört in die Fog-Notiz zur Observability.
|
||||
|
||||
⚠️ **Lücke, die diese Entscheidung einführt:** `PgListener` reconnected laut sqlx-Doku selbst,
|
||||
aber Events während der Unterbrechung sind **weg** — `NOTIFY` hat keine Historie. Regel
|
||||
daher: **nach jedem Listener-Reconnect schickt der Server allen Verbindungen einen
|
||||
Resync-Hinweis**, woraufhin die Clients das Manifest neu ziehen. Das ist billig, selten, und
|
||||
schließt die Lücke vollständig.
|
||||
|
||||
### 6. Auf- und Abräumen
|
||||
|
||||
- **Verbindungsaufbau:** Mitgliedschaften laden, in beide Registries eintragen.
|
||||
- **Verbindungsabbau:** aus beiden entfernen. Abbrüche sind der Normalfall, nicht die
|
||||
Ausnahme — Leaks in der Registry sind hier der klassische Fehler.
|
||||
- **Mitgliedschaftswechsel während der Verbindung:** über die `user_id`-Registry, siehe §2.
|
||||
- Eine leere Conversation-Menge wird aus der Karte entfernt, damit sie nicht unbegrenzt wächst.
|
||||
|
||||
### 7. Reihenfolge
|
||||
|
||||
Das Fan-out garantiert **keine** Reihenfolge über Instanzen hinweg und braucht sie auch
|
||||
nicht: der Cursor ist monoton, der Client lädt ohnehin den aktuellen Zustand, und das
|
||||
Dirty-Set nimmt das Maximum. Ein zu früh oder zu spät zugestellter Event kann nichts
|
||||
beschädigen — er kann nur überflüssig sein.
|
||||
255
docs/realtime/issues/09-voice-signaling-protokoll.md
Normal file
255
docs/realtime/issues/09-voice-signaling-protokoll.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Voice-Signaling-Protokoll
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie sieht das flüchtige Signaling-Format aus, mit dem Clients einen Voice-Room betreten,
|
||||
sich gegenseitig finden und SDP-/ICE-Daten austauschen?
|
||||
|
||||
Das ist die eine Stelle, an der echte Nutzlast über den Socket geht — der Dumb Pipe
|
||||
trägt es nicht, weil ein SDP-Angebot flüchtig ist und in keiner Tabelle liegt.
|
||||
|
||||
**Entblockt am 14.08.2026.** Was aus [*Voice-Topologie*](04-voice-topologie.md) feststeht und
|
||||
hier nicht neu aufgemacht wird:
|
||||
|
||||
- **Mesh, harte Obergrenze sechs Teilnehmer**, serverseitig durchgesetzt — der Beitritt wird
|
||||
abgelehnt, nicht nur in der UI verhindert. Kein SFU-Vorbau; ein SFU wäre ein bewusster
|
||||
Protokollbruch in einem eigenen Effort.
|
||||
- **Eine Datenschutz-Politik je Instanz** (streng: `iceTransportPolicy: "relay"` + Opus
|
||||
`cbr=1`/`usedtx=0`; offen: Standard-ICE + Standard-Opus). Sie muss über das Signaling an den
|
||||
Client übermittelt werden, damit er sie umsetzen **und anzeigen** kann.
|
||||
- Im strengen Modus sind **TURN-Zugangsdaten zwingend** — ihre Übermittlung gehört damit in
|
||||
dieses Ticket.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- **Room-Modell.** Ist ein Voice-Room an eine Conversation gebunden („Sprachkanal dieses
|
||||
Channels"), oder eine eigenständige Entität mit eigener Mitgliedschaft? Das erste ist
|
||||
einfacher und passt zum bestehenden Datenmodell; das zweite erlaubt Ad-hoc-Anrufe
|
||||
zwischen Leuten, die keine gemeinsame Conversation haben.
|
||||
- **Persistenz.** Ist der Room-Zustand rein im Speicher (weg beim Neustart), oder liegt
|
||||
er in der DB? In-Memory ist einfacher und passt zur Flüchtigkeit — aber beim Neustart
|
||||
fallen alle Gespräche, und bei Mehr-Instanz-Betrieb später ist In-Memory ein Problem.
|
||||
- **Teilnehmerliste.** Das ist die **einzige** Presence-Form im Scope dieses Efforts
|
||||
(allgemeine Chat-Presence liegt in der Fog). Wie erfährt ein Beitretender, wer schon
|
||||
drin ist? Wie erfahren die anderen von ihm? Was passiert bei einem
|
||||
Verbindungsabbruch — ab wann gilt jemand als weg?
|
||||
- **Nachrichtentypen.** Minimal nötig: `join`, `leave`, `participants`, `offer`,
|
||||
`answer`, `ice-candidate`. Braucht es mehr — `mute`, `speaking`, `renegotiate`?
|
||||
- **Adressierung.** SDP und ICE-Kandidaten gehen an einen *bestimmten* Peer, nicht an
|
||||
den Raum. Wie werden Peers identifiziert — User-ID reicht nicht, weil Multi-Device
|
||||
gefordert ist. Also eine Verbindungs- oder Session-ID.
|
||||
- **Wer bietet an?** Bei n Teilnehmern muss geregelt sein, wer bei einem Beitritt das
|
||||
Angebot macht, sonst gibt es Glare (beide bieten gleichzeitig). Deterministische Regel
|
||||
festlegen.
|
||||
- **TURN-Credentials.** Wenn TURN konfiguriert ist: wie kommen zeitbeschränkte
|
||||
Credentials zum Client? Gehört das in dieses Protokoll oder in einen REST-Endpunkt?
|
||||
Und was ist das Verhalten, wenn **kein** TURN konfiguriert ist — der Nutzer muss früh
|
||||
und ehrlich erfahren, dass die Verbindung scheitern kann, statt in einen Timeout zu laufen.
|
||||
- **Transport.** Derselbe WebSocket wie der Dumb Pipe, oder ein zweiter Endpunkt? Ein
|
||||
Socket spart Auth- und Reconnect-Verwaltung; zwei trennen die Belange sauberer. Hängt
|
||||
auch an *Wo lebt die WS-Verbindung im Client* — falls Chat in Rust und Voice in JS
|
||||
liegt, ist die Frage bereits beantwortet.
|
||||
|
||||
### Harte Randbedingung: DTLS-Fingerprint-Pinning
|
||||
|
||||
Aus [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) §7.4,
|
||||
und für dieses Projekt der wichtigste Sicherheitsbefund überhaupt:
|
||||
|
||||
**RFC 8827 benennt den Angriff wörtlich** — ein On-Path-Angreifer kann die DTLS-SRTP-Fingerprints
|
||||
im Handshake austauschen und die Identität beider Endpunkte ersetzen. Der Signaling-Server
|
||||
relayed die SDP, kann also `a=fingerprint:` tauschen und zwei separate DTLS-Sitzungen
|
||||
terminieren: vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt.
|
||||
Bei einem Bedrohungsmodell, in dem der Betreiber der Angreifer ist, ist das kein Randfall,
|
||||
sondern der Hauptfall.
|
||||
|
||||
**Der spezifizierte Gegenmechanismus existiert nicht.** W3C *Identity for WebRTC 1.0* steht
|
||||
seit 2018 unverändert als Candidate Recommendation; `setIdentityProvider` / `peerIdentity`
|
||||
sind in Chrome, Safari und Android WebView **nicht implementiert** — also in keiner der vier
|
||||
Ziel-Engines.
|
||||
|
||||
Was das Protokoll deshalb leisten muss:
|
||||
|
||||
1. Fingerprint deterministisch vorab bestimmen (`RTCPeerConnection.generateCertificate()` +
|
||||
`getFingerprints()`), statt ihn aus `localDescription.sdp` zu lesen.
|
||||
2. Ihn **durch einen E2EE-authentifizierten Kanal** übertragen, signiert mit dem
|
||||
Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed.
|
||||
3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen
|
||||
und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen.
|
||||
4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei
|
||||
Änderung warnen.
|
||||
5. Optional eine **SAS** (Emoji-/Wortliste aus dem Hash beider Fingerprints, à la ZRTP)
|
||||
für den paranoiden Pfad — verteidigt zusätzlich gegen einen kompromittierten Identity-Key.
|
||||
|
||||
Genau so löst es Matrix in MSC3401/Element Call: Call-SDP läuft über verschlüsselte
|
||||
To-Device-Messages, der Homeserver sieht den Fingerprint nie im Klartext.
|
||||
|
||||
**Konsequenz für die Effort-Grenzen:** Punkt 2 braucht einen E2EE-Kanal, den es heute nicht
|
||||
gibt — das volle E2EE-Design ist ein Folge-Effort. Dieses Ticket muss die **Naht** festlegen
|
||||
(wo im Signaling-Fluss der signierte Fingerprint sitzt, wie ein Mismatch behandelt wird),
|
||||
kann sie aber nicht einlösen. Sicheres Voice ist damit von der Fertigstellung des
|
||||
E2EE-Efforts abhängig — das explizit festhalten, statt es implizit zu lassen.
|
||||
|
||||
### Randbedingung aus *Voice auf Linux*
|
||||
|
||||
Es gibt **zwei Medienpfade**: `RTCPeerConnection` im WebView auf Windows, Android und iOS,
|
||||
und `livekit/rust-sdks` im Rust-Layer auf Linux. Das Signaling-Protokoll muss beide bedienen.
|
||||
|
||||
Konkret für das Fingerprint-Pinning oben: der signierte DTLS-Fingerprint muss auf **beiden**
|
||||
Pfaden erzeugbar sein — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über
|
||||
die Zertifikats-API von libwebrtc. Die Naht darf also nicht an einer WebView-API hängen.
|
||||
|
||||
### Randbedingung
|
||||
|
||||
**Kein Audio.** Codecs, Echo-Cancellation, Audio-Pipeline und SFU-Betrieb sind out of
|
||||
scope. Dieses Ticket endet bei „zwei Clients haben genug Information, um eine
|
||||
`RTCPeerConnection` aufzubauen".
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### 1. Der Voice-Room ist eine eigenständige Entität mit zwei möglichen Eltern
|
||||
|
||||
**Discord-Modell:** ein Sprachkanal hängt am **Workspace** und ist per Default für alle
|
||||
Workspace-Mitglieder sichtbar und betretbar. Zusätzlich kann ein Raum an einer **Conversation**
|
||||
hängen — das ist der Direktanruf aus einer DM oder Gruppe heraus.
|
||||
|
||||
```sql
|
||||
CREATE TABLE voice_rooms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK ((workspace_id IS NULL) <> (conversation_id IS NULL))
|
||||
);
|
||||
```
|
||||
|
||||
Genau eines der beiden Elternfelder ist gesetzt — dasselbe Muster, das `conversations` heute
|
||||
schon benutzt (dort ist `workspace_id` für DMs null). **Eine Entität, ein Signaling-Protokoll,
|
||||
eine Prüfstelle für die Autorisierung**, die nur danach verzweigt, welcher Elternteil gesetzt
|
||||
ist.
|
||||
|
||||
**Lebenszyklus:** Workspace-Räume sind dauerhaft (ein Ort, den man betritt und verlässt).
|
||||
Conversation-Räume entstehen mit dem Anruf und werden beim Auflegen abgeräumt.
|
||||
|
||||
⚠️ **Die Autorisierung muss durch genau eine Funktion laufen.** Später sollen Rollen
|
||||
*Conversations und Voice-Rooms gleichermaßen* einschränken — es darf deshalb **kein
|
||||
raumspezifisches Berechtigungsmodell** entstehen, sonst gibt es zwei Systeme, die auseinander
|
||||
laufen. Heute prüft diese Funktion Workspace- bzw. Conversation-Mitgliedschaft; das
|
||||
Rollenmodell setzt später an derselben Stelle an.
|
||||
|
||||
### 2. Raumzustand in Postgres, aufgeräumt über den bestehenden Heartbeat
|
||||
|
||||
```sql
|
||||
CREATE TABLE voice_participants (
|
||||
room_id UUID NOT NULL REFERENCES voice_rooms(id) ON DELETE CASCADE,
|
||||
session_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (room_id, session_id)
|
||||
);
|
||||
```
|
||||
|
||||
Über Instanzen hinweg konsistent per Konstruktion — nötig, weil das Fan-out seit
|
||||
[*Fan-out-Architektur*](08-fan-out-architektur.md) immer über `NOTIFY` läuft und
|
||||
Mehr-Instanz-Betrieb damit ab Tag eins möglich ist. Die Teilnehmerliste, die ein Beitretender
|
||||
für seine Angebote braucht, ist eine simple Abfrage.
|
||||
|
||||
**Geisterzeilen** nach einem Instanz-Absturz fängt `last_seen` ab, das der bestehende
|
||||
15-Sekunden-Heartbeat aus [*Verbindungsaufbau & Auth*](07-verbindungsaufbau-und-auth.md)
|
||||
gratis mitführt: wer zwei Intervalle fehlt, wird entfernt. Bei sauberem Socket-Schluss geht
|
||||
der Abgang sofort.
|
||||
|
||||
Verworfen: In-Memory je Instanz (`NOTIFY` hat keine Historie — nach einem Listener-Reconnect
|
||||
fehlt einer Instanz die Sicht, und abgestürzte Instanzen hinterlassen unaufräumbare Geister)
|
||||
und Raum-zu-Instanz-Bindung (bräuchte selbst wieder geteilten Zustand plus eine Umleitung, und
|
||||
ein Instanz-Absturz nähme den ganzen Raum mit statt ihn nur auszudünnen).
|
||||
|
||||
### 3. Adressierung über `session_id`, nicht `user_id`
|
||||
|
||||
Multi-Device ist gefordert — derselbe Nutzer kann mit Desktop und Handy im selben Raum sein.
|
||||
SDP und ICE-Kandidaten gehen deshalb an eine **Verbindung**, nicht an ein Konto. Jede
|
||||
WebSocket-Verbindung bekommt beim Aufbau eine `session_id`; sie ist die Peer-Identität im
|
||||
Signaling.
|
||||
|
||||
### 4. Der Beitretende bietet an
|
||||
|
||||
Wer beitritt, erhält die Teilnehmerliste und sendet n−1 Angebote; die Anwesenden antworten
|
||||
nur. Eine Regel statt einer Aushandlung darüber, wer aushandelt — und der Beitretende ist der
|
||||
Einzige, der in diesem Moment etwas Neues weiß.
|
||||
|
||||
Kollisionen können praktisch nicht entstehen, weil es reines Audio ohne mitten im Gespräch
|
||||
wechselnde Spuren gibt. Der einzige verbleibende Fall ist ein ICE-Neustart: **wer neu startet,
|
||||
bietet an.**
|
||||
|
||||
⚠️ Das ist eine **Konvention, kein Mechanismus**. Kämen später Bildschirmfreigabe oder Video
|
||||
hinzu, muss sie durch Perfect Negotiation (höflich/unhöflich per `session_id`-Vergleich)
|
||||
ersetzt werden. Als Notiz festgehalten, nicht heute gebaut.
|
||||
|
||||
### 5. Fingerprint-Pinning: Naht jetzt, Füllung durch den E2EE-Effort
|
||||
|
||||
Das Protokoll sieht das Feld für den signierten DTLS-Fingerprint und den Abgleich gegen das
|
||||
relayte SDP **von Anfang an** vor. Solange kein Identitätsschlüssel existiert, bleibt es leer,
|
||||
und der Client zeigt **dauerhaft und nicht wegklickbar** an, dass das Gespräch nicht
|
||||
verifiziert ist.
|
||||
|
||||
Begründung gegen die verlockende Alternative (eine kleine Identitätsschicht nur für Voice):
|
||||
der Identitätsschlüssel ist das Kernstück des E2EE-Efforts. Ihn separat für Voice zu erzeugen
|
||||
hieße, dass Nutzer später zwei Identitäten und zwei Verifikationsrituale haben — oder dass
|
||||
eine davon migriert werden muss, und das ist bei Schlüsseln der unangenehmste aller
|
||||
Migrationsfälle.
|
||||
|
||||
**Bewusst in Kauf genommen und in der Map vermerkt:** bis der E2EE-Effort steht, ist Voice
|
||||
gegen den Betreiber ungeschützt. Sichtbar, nicht stillschweigend.
|
||||
|
||||
Beide Medienpfade aus [*Voice auf Linux*](11-voice-auf-linux.md) müssen den Fingerprint
|
||||
deterministisch vorab erzeugen können — im WebView über
|
||||
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
|
||||
Naht darf nicht an einer WebView-API hängen.
|
||||
|
||||
### 6. Nachrichtensatz (abgeleitet)
|
||||
|
||||
Client → Server: `room.join` · `room.leave` · `peer.signal` (trägt Offer, Answer oder
|
||||
ICE-Kandidat, adressiert an eine `session_id`)
|
||||
|
||||
Server → Client: `room.joined` (Antwort auf `join`: Teilnehmerliste, Politik,
|
||||
TURN-Zugangsdaten) · `room.participant_added` · `room.participant_removed` · `peer.signal`
|
||||
(weitergereicht) · `room.rejected` (Raum voll, nicht berechtigt)
|
||||
|
||||
`mute` und `speaking` sind **nicht** enthalten: Stummschaltung ist lokal, und
|
||||
Sprechaktivität würde die Information über den Server leiten, die
|
||||
[*Voice-Topologie*](04-voice-topologie.md) im strengen Modus gerade per Opus-CBR versteckt.
|
||||
Beides gehört, falls gewünscht, über die Peer-Verbindung statt über das Signaling.
|
||||
|
||||
### 7. Politik und TURN-Zugangsdaten in der Beitrittsantwort (abgeleitet)
|
||||
|
||||
`room.joined` trägt die Instanz-Politik aus [*Voice-Topologie*](04-voice-topologie.md)
|
||||
(*streng* → `iceTransportPolicy: "relay"` und Opus `cbr=1`/`usedtx=0`; *offen* → Standard) —
|
||||
der Client setzt sie um **und zeigt sie an**.
|
||||
|
||||
Im strengen Modus sind TURN-Zugangsdaten zwingend und reisen mit: **zeitbeschränkte
|
||||
HMAC-Credentials** nach dem üblichen coturn-Muster, damit nichts Dauerhaftes im Client liegt.
|
||||
Erneuerung erfolgt durch erneuten Beitritt, nicht durch einen eigenen Mechanismus.
|
||||
|
||||
### 8. Raumgröße serverseitig durchgesetzt
|
||||
|
||||
Die Obergrenze von sechs wird beim `room.join` geprüft und mit `room.rejected` abgelehnt —
|
||||
**nicht** nur in der Oberfläche verhindert.
|
||||
|
||||
### Offene Kopplung zum E2EE-Effort
|
||||
|
||||
`voice_rooms.name` ist nutzererzeugter Inhalt und fiele damit unter die Blob-Regel aus
|
||||
[*Dumb-Pipe-Event & Backfill*](06-dumb-pipe-event-und-backfill.md). Der Server braucht ihn
|
||||
nicht — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne vorher
|
||||
Schlüsselmaterial zu haben. Das ist ein Schlüsselverwaltungsproblem und gehört dem
|
||||
E2EE-Effort; hier nur festgehalten, damit es dort nicht übersehen wird.
|
||||
121
docs/realtime/issues/10-client-integration.md
Normal file
121
docs/realtime/issues/10-client-integration.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Client-Integration: `refreshKey` ersetzen
|
||||
|
||||
Type: prototype
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie sieht der Frontend-State aus, wenn Nachrichten gepusht statt nachgeladen werden —
|
||||
und wie fühlt sich das bei N gleichzeitig verbundenen Servern an?
|
||||
|
||||
Ein Prototyp, kein Produktionscode. Ziel ist, an etwas Konkretem zu sehen, ob das
|
||||
Zustandsmodell trägt, bevor die Spec es festschreibt.
|
||||
|
||||
### Ausgangslage
|
||||
|
||||
`ChatPage.tsx` hält heute `refreshKey` als Zähler und reicht ihn an `MessageList`
|
||||
durch; `MessageInput` zählt nach dem Senden hoch. Das ist der Hack, dessen Verschwinden
|
||||
den ganzen Effort motiviert.
|
||||
|
||||
**Zugeschnitten am 14.08.2026** durch [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md):
|
||||
die Verbindung und der Backfill liegen in Rust, das Frontend hat **keine Cursor-Logik** und
|
||||
kein Debouncing. Es bekommt aufgelöste Entitäten per `emit` plus ein separates
|
||||
`resync`-Signal. Das schrumpft dieses Ticket erheblich — und verschiebt eine Frage hierher:
|
||||
**soll Rust für Conversations nachladen, die gar nicht sichtbar sind?** Das weiß nur das
|
||||
Frontend, also muss es diese Information nach unten reichen.
|
||||
|
||||
### Zu klären
|
||||
|
||||
- **Zustandsform.** Wo leben Nachrichten — pro Conversation gecacht, oder nur die
|
||||
gerade offene? Was passiert beim Wechsel zwischen Conversations? Braucht es überhaupt
|
||||
eine State-Bibliothek, oder reicht React-State plus ein Cursor pro Conversation?
|
||||
- **Ungelesen-Anzeige.** Der Dumb-Pipe-Event trifft auch für Conversations ein, die
|
||||
nicht offen sind. Wird dann nachgeladen (kostet Requests) oder nur ein Punkt gesetzt
|
||||
und erst beim Öffnen geladen? Zweiteres wirkt richtig — am Prototyp prüfen.
|
||||
- **Optimistisches Senden.** Erscheint die eigene Nachricht sofort, und wie wird sie
|
||||
mit der später gepushten Version zusammengeführt, ohne zu doppeln oder zu springen?
|
||||
- **Multi-Server im UI.** N Server heißt N Conversation-Listen, N Verbindungszustände,
|
||||
N Fehlerfälle. Wie sieht das aus, wenn ein Server nicht erreichbar ist, während die
|
||||
anderen laufen? Das ist der Punkt, an dem das Multi-Server-Modell konkret wird.
|
||||
- **Verbindungszustand sichtbar machen.** „Verbunden", „reconnecting", „offline" — was
|
||||
sieht der Nutzer, und wo?
|
||||
- **Koaleszenz.** Zehn Events in zwei Sekunden sollen einen Fetch auslösen, nicht zehn.
|
||||
Wo sitzt das Debouncing, und mit welcher Verzögerung fühlt es sich noch sofort an?
|
||||
|
||||
### Erwartetes Ergebnis
|
||||
|
||||
Ein Wegwerf-Prototyp, der die Zustandsform greifbar macht, plus eine kurze Notiz, was
|
||||
er über das Zustandsmodell gezeigt hat. Der Prototyp wird als Asset verlinkt, nicht in
|
||||
die Antwort kopiert.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
**Asset:** `PROTOTYPE-frontend-zustandsmodell.html` im Repo `rustification-client`,
|
||||
auf dem Wegwerf-Branch `prototype/frontend-zustandsmodell` (bewusst nicht auf `master`) — eine
|
||||
eigenständige HTML-Datei, im Browser zu öffnen. Freies Ausprobieren plus sechs geführte
|
||||
Durchläufe. Der Reducer im ersten `<script>`-Block ist der übernehmbare Teil; die Seite
|
||||
drumherum ist Wegwerfcode.
|
||||
|
||||
Geprüft: die Datei läuft fehlerfrei (nur ein Favicon-404). Die beiden entscheidenden
|
||||
Durchläufe wurden durchgeklickt, siehe unten.
|
||||
|
||||
### Was der Prototyp aufgedeckt hat
|
||||
|
||||
**1. Der Client muss die Nachrichten-ID vergeben.** Das war vorher keine Frage — es wurde
|
||||
eine, sobald optimistisches Senden konkret wurde. Vergibt der Server die ID, hat der Client
|
||||
beim Echo nichts, woran er seine eigene Nachricht wiedererkennt: sie steht dann zweimal da,
|
||||
einmal mit `seq` und einmal ohne. Im Prototyp beide Fälle nachgestellt und bestätigt.
|
||||
|
||||
Folge für die Spec: `messages.id` wird vom Client geliefert, nicht per
|
||||
`DEFAULT gen_random_uuid()` erzeugt. **Nebengewinn:** damit wird das Senden idempotent —
|
||||
ein Wiederholungsversuch nach unklarem Netzfehler ist ein No-op statt eines Duplikats, was
|
||||
gerade auf Mobile den Unterschied macht.
|
||||
|
||||
⚠️ Client-gelieferte IDs sind eine Eingabe an einer Vertrauensgrenze. Der Primärschlüssel
|
||||
weist Kollisionen von selbst ab, aber der Handler muss das als **Konflikt** behandeln und
|
||||
nicht als Serverfehler — und die ID auf UUID-Form prüfen, statt sie ungeprüft zu übernehmen.
|
||||
Auf einem öffentlichen Server ist das keine Formalie.
|
||||
|
||||
**2. Eine Ungelesen-*Zahl* ist ohne Nachladen nicht ableitbar.** `seq` zählt **Events**,
|
||||
nicht Nachrichten — Reactions, Bearbeitungen und später Read-Receipts erhöhen ihn mit. Aus
|
||||
der Differenz zwischen altem Cursor und neuem `seq` lässt sich also kein „3 neue
|
||||
Nachrichten" berechnen, nur „hier hat sich etwas getan". Das war im Entwurf implizit falsch
|
||||
angenommen.
|
||||
|
||||
**Entscheidung daraus:** Rust lädt **für DMs** nach (Zahl möglich), für Channels und Gruppen
|
||||
nicht (nur ein Punkt). Das ist dasselbe Discord-Verhalten wie beim Raummodell, die Regel
|
||||
kostet nichts, weil `conversations.kind` sie schon hergibt, und sie begrenzt die Last dort,
|
||||
wo sie entsteht: ein aktiver Channel feuert ständig, eine DM selten. Sobald es Erwähnungen
|
||||
gibt, kommen die nach derselben Logik dazu.
|
||||
|
||||
### Was der Prototyp bestätigt hat
|
||||
|
||||
- **Bearbeitete Nachrichten springen nicht.** Die Sortierung hängt an `messages.seq`, nicht
|
||||
am Änderungszeitpunkt — eine Bearbeitung bewegt die Nachricht nur im Cursor-Raum, nicht im
|
||||
Verlauf.
|
||||
- **Löschen erzeugt einen Grabstein, keine Lücke.**
|
||||
- **Der Zustand ist pro Server, nicht global.** Ein ausgefallener Server lässt die anderen
|
||||
unberührt; „verdrängt" (4002) ist ein Endzustand, aus dem bewusst nicht neu verbunden wird.
|
||||
- **Das Frontend rechnet nichts aus.** Es setzt gelieferte Entitäten ein und ersetzt anhand
|
||||
der ID. Keine Cursor-Logik, kein Debouncing — beides liegt seit
|
||||
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) in Rust.
|
||||
|
||||
### Zustandsform
|
||||
|
||||
Pro Server ein Verbindungszustand; darunter je Conversation `{ geladen, ungelesen,
|
||||
nachrichten[] }`. `ungelesen` ist entweder eine Zahl (DMs) oder eine Markierung (Channels) —
|
||||
die Zweiwertigkeit ist keine Nachlässigkeit, sondern der Befund aus Punkt 2. Kein
|
||||
Zustandsbibliothek nötig: React-State reicht, weil es keinen abgeleiteten Zustand gibt.
|
||||
|
||||
### Offen geblieben
|
||||
|
||||
Der `resync`-Pfad verwirft im Prototyp den Zwischenspeicher der betroffenen Conversation und
|
||||
lädt beim nächsten Öffnen neu. Ob das für die gerade **sichtbare** Conversation sichtbar
|
||||
ruckelt (kurz leer, dann voll), lässt sich am Zustandsmodell nicht beantworten — das ist eine
|
||||
Frage der Oberfläche und gehört in die Umsetzung.
|
||||
165
docs/realtime/issues/11-voice-auf-linux.md
Normal file
165
docs/realtime/issues/11-voice-auf-linux.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Voice auf Linux
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Was ist die Zusage an Linux-Nutzer bezüglich Sprachanrufen — und wenn Voice dort laufen
|
||||
soll, über welchen Medien-Stack?
|
||||
|
||||
Aufgeworfen von [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md).
|
||||
Die erwartete schlechte Nachricht kam nicht von Mobile, sondern von Linux.
|
||||
|
||||
### Befundlage (belegt, siehe §5 der Recherche)
|
||||
|
||||
WebRTC ist in Tauris Linux-WebView (WebKitGTK 4.1) an **drei unabhängigen Gates** blockiert:
|
||||
|
||||
1. **Compile-Time.** `ENABLE_WEB_RTC` folgt `ENABLE_EXPERIMENTAL_FEATURES`, das auf `OFF`
|
||||
steht. Debian/Ubuntu und Alpine setzen es belegbar nicht → `RTCPeerConnection` existiert
|
||||
im JS-Global gar nicht. Arch/Fedora unverifiziert (Experiment E5 in der Recherche).
|
||||
2. **Runtime-Setting.** `WebKitSettings:enable-webrtc` ist Default `FALSE` und wird von wry
|
||||
nie gesetzt.
|
||||
3. **Permission.** `permission-request` ist Default Deny; wry 0.55.1 verbindet das Signal
|
||||
überhaupt nicht.
|
||||
|
||||
Gate 2 und 3 wären aus dem App-Code über `WebviewWindow::with_webview` lösbar (bzw. ab
|
||||
Tauri 2.12 sauber über `on_permission_request`). **Gate 1 hängt am Distro-Paket des
|
||||
Nutzers und ist von uns nicht lösbar.**
|
||||
|
||||
Das trifft ausgerechnet die Gruppe, die für dieses Projekt zentral ist: Selbsthoster sind
|
||||
linux-lastig.
|
||||
|
||||
### Optionen, alle unattraktiv
|
||||
|
||||
1. **Voice auf Linux als nicht unterstützt deklarieren.** Chat läuft voll, Voice nicht.
|
||||
Ehrlich und billig — und wahrscheinlich der Weg, der die Simplizitätsdoktrin am wenigsten
|
||||
verletzt. Aber schmerzhaft für die Kernzielgruppe.
|
||||
2. **Nativer Medien-Stack nur auf Linux.** `livekit/rust-sdks` (`libwebrtc`-Crate, echtes
|
||||
libwebrtc mit NetEQ/AEC/Opus, laut README inkl. Linux) im Tauri-Rust-Layer. Bricht die
|
||||
Architektur-Einheitlichkeit: Voice läge auf drei Plattformen im WebView und auf einer in
|
||||
Rust. Zusätzlich ein großer vorkompilierter nativer Blob pro Plattform.
|
||||
*(`webrtc-rs` ist hierfür **kein** Ersatz — es ist ein reiner Protokoll-Stack ohne
|
||||
Geräte-Capture, ohne Codecs, ohne Jitter-Buffer, ohne Echo-Cancellation.)*
|
||||
3. **Nativer Stack auf allen Plattformen.** Einheitlich, aber verwirft den kostenlosen
|
||||
WebView-Pfad auf drei von vier Zielen und zieht den nativen Blob überallhin.
|
||||
4. **Eigenes WebKitGTK bündeln.** Widerspricht der Simplizitätsdoktrin frontal.
|
||||
5. **Auf Upstream warten.** Löst Gate 2 und 3, **nie Gate 1**. Also keine Lösung.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- Welche Option — und wie wird die Einschränkung dem Nutzer gegenüber **sichtbar** gemacht,
|
||||
statt ihn in einen Timeout laufen zu lassen? (Feature-Detection `typeof RTCPeerConnection`
|
||||
ist trivial und sollte in jedem Fall passieren.)
|
||||
- Falls (2) oder (3): wo verläuft dann die Rust↔WebView-Naht? Bei nativem Audio ist der
|
||||
WebView **nicht mehr der Medien-Endpunkt** — Capture und Wiedergabe liegen in Rust, und
|
||||
die UI bekommt nur heruntergerechnete Metering-Werte über IPC, niemals rohes PCM.
|
||||
Zurück-Injizieren von PCM in den WebView geht in Chromium, aber **nicht** in WebKitGTK.
|
||||
Rückkopplung auf [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md).
|
||||
**Beantwortet (14.08.2026): Linux ist eine vollwertige Client-Plattform.** Damit ist der
|
||||
billige Ausweg verbaut — Option 1 bedeutet, auf einer erklärten Zielplattform ein
|
||||
Kernfeature nicht anzubieten. Sie bleibt formal wählbar, muss aber als bewusster
|
||||
Produktverzicht begründet werden, nicht als technische Notwendigkeit.
|
||||
|
||||
Realistisch bleiben damit **(2)** nativer Stack nur auf Linux und **(3)** nativer Stack
|
||||
überall. Die eigentliche Frage dieses Tickets ist die Abwägung zwischen diesen beiden:
|
||||
Einheitlichkeit gegen Kostenminimierung.
|
||||
|
||||
### Kopplung
|
||||
|
||||
Wirkt auf [*Voice-Topologie*](04-voice-topologie.md) zurück: fällt hier ein nativer Pfad,
|
||||
ändern sich dort die Optionen. Die beiden Tickets können in beliebiger Reihenfolge laufen,
|
||||
aber das zweite muss das Ergebnis des ersten kennen.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026.
|
||||
|
||||
### 0. Zusätzlich geprüft: Flatpak löst Gate 1 nicht
|
||||
|
||||
Die Recherche hatte Gate 1 als „hängt am Distro-Paket" eingestuft. Das gilt aber nur, solange
|
||||
der Nutzer das Distro-Paket benutzt — ein Flatpak bringt WebKitGTK aus seinem Runtime mit.
|
||||
Geprüft, Ergebnis negativ:
|
||||
|
||||
- Das **GNOME-Runtime baut WebKitGTK ohne den WebRTC-Flag**; die Antwort im Flathub-Forum auf
|
||||
die Frage nach dem Patchen lautete sinngemäß „macht ein Issue bei gnome-build-meta auf"
|
||||
([Flathub Discourse](https://discourse.flathub.org/t/patching-gnome-runtime/10682)).
|
||||
- Das einzige dokumentierte funktionierende Rezept verlangt, **WebKitGTK selbst zu
|
||||
kompilieren** (`-DENABLE_MEDIA_STREAM=ON -DENABLE_WEB_RTC=ON`) **und zusätzlich wry zu
|
||||
patchen** — erprobt auf NixOS unter X11, mit GBM-Fehlern unter Wayland und nicht
|
||||
aufgelisteten v4l2loopback-Geräten
|
||||
([Tauri Discussion #8426](https://github.com/tauri-apps/tauri/discussions/8426)).
|
||||
|
||||
Für ein Entwicklungssystem machbar, für Endnutzer nicht. **Gate 1 hat heute keine
|
||||
nutzerseitige Lösung.**
|
||||
|
||||
### 1. WebView auf drei Plattformen, nativer Stack nur auf Linux
|
||||
|
||||
Windows, Android und iOS nutzen den kostenlosen, funktionierenden WebView-Pfad. Linux bekommt
|
||||
`livekit/rust-sdks` (`libwebrtc`-Crate) im Rust-Layer — echtes libwebrtc mit NetEQ, AEC/AGC/NS
|
||||
und Opus, laut README inklusive Linux.
|
||||
|
||||
`webrtc-rs` ist **kein** Ersatz: reiner Protokoll-Stack ohne Geräte-Capture, ohne Codecs, ohne
|
||||
Jitter-Buffer, ohne Echo-Cancellation.
|
||||
|
||||
Die Alternative „nativ überall" war knapp und hat ein starkes Argument — ein Pfad statt zwei,
|
||||
eine Berechtigungslogik, eine Testmatrix, und die wry-Eigenheit (Mikrofonrechte werden auf iOS
|
||||
bedingungslos an jeden Origin vergeben) wäre komplett aus dem Spiel. Sie verliert, weil sie
|
||||
einen auf drei Plattformen funktionierenden Gratis-Pfad wegwirft. **Der akzeptierte Preis sind
|
||||
zwei Medienpfade auf Dauer**, mit zwei Berechtigungsmodellen und zwei Fehlerbildern.
|
||||
|
||||
**Nützliche Nebenwirkung:** weil Linux den nativen Stack *unbedingt* nutzt und nicht nur dort,
|
||||
wo die WebView-Variante versagt, spielt es keine Rolle mehr, ob eine Distribution WebKitGTK
|
||||
mit WebRTC gebaut hat. **Experiment E5 der Recherche (Arch/Fedora prüfen) entfällt damit.**
|
||||
Und weil es hier nur um Audio geht, ist der GBM-/Wayland-Fehler aus #8426 gegenstandslos — er
|
||||
betrifft die Videoausgabe des WebViews.
|
||||
|
||||
### 2. Die Naht ist eine explizite Verzweigung im Frontend, ganz oben
|
||||
|
||||
Rust meldet dem Frontend eine Fähigkeit — sinngemäß `mediaOwner: "webview" | "native" | "none"`
|
||||
— und das Voice-Modul verzweigt **einmal**, an seiner Spitze. Darunter zwei klar getrennte
|
||||
Implementierungen: `RTCPeerConnection` im WebView, oder Tauri-Commands in den nativen Stack.
|
||||
|
||||
Das ist ehrlich gegenüber der Realität, dass sich die Fälle nicht nur mechanisch
|
||||
unterscheiden, sondern auch in der Oberfläche: **auf Linux gibt es kein `<audio>`-Element**,
|
||||
weil PCM sich in WebKitGTK nicht zurückinjizieren lässt (in Chromium ginge es). Der native
|
||||
Pfad besitzt dort die Wiedergabe vollständig, und Pegelanzeigen kommen als
|
||||
heruntergerechnete Werte über IPC — **niemals rohes PCM**.
|
||||
|
||||
Verworfen: ein `MediaEngine`-Trait in Rust. Es wäre die sauberere Fassade, verlangte aber eine
|
||||
Steuerungsumkehr — die WebView-Implementierung müsste ihre eigentliche Arbeit per IPC nach
|
||||
oben ans Frontend delegieren. Ebenfalls verworfen: eine Naht nur auf Signaling-Ebene, weil die
|
||||
Duplizierung dann bis in die Oberfläche reicht und Verhaltensunterschiede versehentlich statt
|
||||
absichtlich entstehen.
|
||||
|
||||
### 3. Gestaffelt: Voice erscheint zuerst auf den WebView-Plattformen
|
||||
|
||||
Linux zeigt Voice solange sauber als „noch nicht verfügbar" an — über die gemeldete Fähigkeit
|
||||
`none`, **nicht** über eine WebView-Feature-Erkennung, weil die auf Linux dauerhaft negativ
|
||||
bliebe und nichts über den nativen Pfad aussagt. Chat läuft dort voll.
|
||||
|
||||
Das Argument ist Risikoreihenfolge: der WebView-Pfad validiert das Signaling-Protokoll billig
|
||||
auf drei Plattformen, und der native Stack steckt danach in ein bereits bewährtes Protokoll.
|
||||
Den schweren, unerprobten Medien-Stack gegen ein gleichzeitig unerprobtes Protokoll zu bauen,
|
||||
verdoppelt die Unbekannten in einem Schritt — und bei einem Fehler wüsste niemand, welche
|
||||
Seite schuld ist.
|
||||
|
||||
**Bewusst in Kauf genommen:** die Zielgruppe ist linuxlastig, und „Voice, aber nicht bei dir"
|
||||
ist keine gute erste Begegnung. Wer es einmal als „geht auf Linux nicht" abgespeichert hat,
|
||||
prüft später nicht nach. Das ist der Preis dafür, das Feature überhaupt in Nutzerhände zu
|
||||
bringen, bevor Monate in den nativen Stack fließen — und er ist erträglich, weil die Ansage
|
||||
ehrlich und sichtbar ist statt stillschweigend.
|
||||
|
||||
### Folgen für andere Tickets
|
||||
|
||||
- [*Voice-Topologie*](04-voice-topologie.md): der native Stack liefert auf Linux echtes
|
||||
libwebrtc, Mesh funktioniert dort also genauso. Die Topologie-Entscheidung wird durch dieses
|
||||
Ticket **nicht** eingeschränkt.
|
||||
- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): der signierte
|
||||
DTLS-Fingerprint muss auf **beiden** Pfaden erzeugbar sein — im WebView über
|
||||
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
|
||||
Naht für das Pinning darf also nicht an einer WebView-API hängen.
|
||||
Reference in New Issue
Block a user