feat(quic): client-initiated 1-RTT key update + dispatch ecn/blackhole/amplificationlimit

The runner's keyupdate testcase has TESTCASE_CLIENT=keyupdate (server
runs plain transfer). The runner verifies the pcap shows BOTH sides
emit packets in phase 1 — pre-fix our receive-only key-update path
satisfied a server-initiated rotation but not this test, because
aioquic's transfer-server doesn't rotate spontaneously. Result: 0
phase-1 packets either direction, "Expected to see packets sent with
key phase 1 from both client and server".

QuicConnection.initiateKeyUpdate() (now public) is the send-side
analogue of commitKeyUpdate: derives next-phase secrets for both
directions via HKDF-Expand-Label "quic ku", installs as live
(reusing old HP keys per RFC §6.1), flips currentSendKeyPhase +
currentReceiveKeyPhase together. The receive side has to roll too
because the peer responds in the new phase — leaving currentReceive
at 0 would force feedShortHeaderPacket to take the
deriveNextPhase-then-commit path on the response and orphan the
keys we just installed in previousReceiveProtection.

InteropClient adds an `initiateKeyUpdate` flag to runTransferTest;
the keyupdate dispatch sets it true. After awaitHandshake (TLS done,
1-RTT keys derived) the flag-flow polls briefly for status=CONNECTED
(HANDSHAKE_DONE arrived → handshake confirmed per RFC 9001 §6.5
prerequisite) before calling initiateKeyUpdate, then sends the GET.
The GET goes out in phase 1, the server mirrors phase 1 in its
response, runner is satisfied.

Also added ecn, amplificationlimit, blackhole to the runTransferTest
dispatch (all reuse the plain-transfer flow; the runner verifies
behaviour via pcap independent of any client-side dance). aioquic
phase 3 result: ✓(retry, keyupdate, blackhole),
?(resumption, zerortt, ecn — feature gaps requiring session tickets,
0-RTT, and IP-layer ECT codepoints respectively),
amplificationlimit blocked by a runner-side cert-gen bug on macOS
(tr LC_CTYPE=C doesn't suppress UTF-8 errors, the chainlen=9 cert
inflation step fails).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 17:34:13 -04:00
15 changed files with 1026 additions and 336 deletions
+59 -59
View File
@@ -396,6 +396,65 @@
<string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string> <string name="migrate_bookmarks_button">Přesunout vše do nových záložek</string>
<string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string> <string name="migrate_bookmarks_success">Záložky úspěšně přesunuty</string>
<string name="drafts">Koncepty</string> <string name="drafts">Koncepty</string>
<string name="scheduled_posts">Naplánované příspěvky</string>
<string name="schedule_post">Naplánovat</string>
<string name="schedule_post_time_label">Naplánovaný čas</string>
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
<string name="schedule_post_pick_label">Naplánovat na…</string>
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
<string name="schedule_post_was_due">Mělo být před %1$s</string>
<string name="schedule_post_picker_time_title">Čas</string>
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
<string name="schedule_post_button_remove">Zrušit plánování</string>
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
<string name="scheduled_posts_day_tomorrow">Zítra</string>
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
<string name="scheduled_posts_logout_toast">Odhlášeno · smazáno %1$d naplánovaných příspěvků</string>
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
<string name="scheduled_posts_status_pending">Naplánováno</string>
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
<string name="scheduled_posts_status_failed">Selhalo</string>
<string name="scheduled_posts_status_sent">Odesláno</string>
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
<string name="scheduled_posts_logout_warning">Máte %1$d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d ve frontě</item>
<item quantity="few">%d ve frontě</item>
<item quantity="many">%d ve frontě</item>
<item quantity="other">%d ve frontě</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 do 1 h</item>
<item quantity="few"> · %d do 1 h</item>
<item quantity="many"> · %d do 1 h</item>
<item quantity="other"> · %d do 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">na 1 relay</item>
<item quantity="few">na %d relaye</item>
<item quantity="many">na %d relayů</item>
<item quantity="other">na %d relayů</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
<string name="polls">Ankety</string> <string name="polls">Ankety</string>
<string name="open_polls">Otevřené</string> <string name="open_polls">Otevřené</string>
<string name="closed_polls">Uzavřené</string> <string name="closed_polls">Uzavřené</string>
@@ -2440,63 +2499,4 @@
<string name="emoji_public_explainer">Veřejná emoji jsou viditelná pro všechny a objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\", když je tento balíček ve vašem seznamu emoji.</string> <string name="emoji_public_explainer">Veřejná emoji jsou viditelná pro všechny a objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\", když je tento balíček ve vašem seznamu emoji.</string>
<string name="emoji_private_explainer">Soukromá emoji jsou šifrovaně uložena na relayích a viditelná pouze pro vás. Objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\" stejně jako veřejná.</string> <string name="emoji_private_explainer">Soukromá emoji jsou šifrovaně uložena na relayích a viditelná pouze pro vás. Objeví se ve vaší nabídce reakcí a v automatickém doplňování \":\" stejně jako veřejná.</string>
<string name="gif">Gif</string> <string name="gif">Gif</string>
<string name="scheduled_posts">Naplánované příspěvky</string>
<string name="schedule_post">Naplánovat</string>
<string name="schedule_post_time_label">Naplánovaný čas</string>
<string name="schedule_post_helper">Příspěvky se publikují přibližně do 15 minut od naplánovaného času.</string>
<string name="schedule_post_pick_time">Vyberte naplánovaný čas</string>
<string name="schedule_post_pick_label">Naplánovat na…</string>
<string name="schedule_post_publishes_in">Publikováno za %1$s</string>
<string name="schedule_post_was_due">Mělo být před %1$s</string>
<string name="schedule_post_picker_time_title">Čas</string>
<string name="schedule_post_button_add">Naplánovat příspěvek</string>
<string name="schedule_post_button_remove">Zrušit plánování</string>
<string name="schedule_post_warning_title">Trvalá oznámení vypnuta</string>
<string name="schedule_post_warning_single">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_warning_multi">Naplánované příspěvky se nemusí publikovat, dokud aplikaci znovu neotevřete. Naplánované příspěvky jiných účtů se nespustí, dokud je aktivní tento účet. Pro spolehlivé plánování na pozadí povolte trvalá oznámení v Nastavení → Předvolby UI.</string>
<string name="schedule_post_preset_in_one_hour">Za 1 hodinu</string>
<string name="schedule_post_preset_tomorrow_morning">Zítra v 9:00</string>
<string name="schedule_post_preset_next_monday_morning">Příští pondělí v 9:00</string>
<string name="schedule_post_always_on_prompt_title">Povolit trvalá oznámení?</string>
<string name="schedule_post_always_on_prompt_message">Naplánované příspěvky se spolehlivě publikují pouze tehdy, když jsou povolena trvalá oznámení. Jinak se nemusí spustit, dokud aplikaci znovu neotevřete.</string>
<string name="schedule_post_always_on_prompt_open_settings">Otevřít nastavení</string>
<string name="schedule_post_always_on_prompt_continue">Přesto pokračovat</string>
<string name="scheduled_posts_at_time">%1$s · za %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · před %2$s</string>
<string name="scheduled_posts_day_tomorrow">Zítra</string>
<string name="scheduled_posts_logout_toast_zero">Odhlášeno</string>
<string name="scheduled_posts_logout_toast">Odhlášeno · smazáno %1$d naplánovaných příspěvků</string>
<string name="scheduled_posts_action_send_now">Odeslat hned</string>
<string name="scheduled_posts_empty_title">Žádné naplánované příspěvky</string>
<string name="scheduled_posts_empty_hint">Napište poznámku a klepněte na ikonu hodin pro naplánování na později.</string>
<string name="scheduled_posts_error_prefix">Chyba: %1$s</string>
<string name="scheduled_posts_status_pending">Naplánováno</string>
<string name="scheduled_posts_status_publishing">Odesílá se…</string>
<string name="scheduled_posts_status_failed">Selhalo</string>
<string name="scheduled_posts_status_sent">Odesláno</string>
<string name="scheduled_posts_status_cancelled">Zrušeno</string>
<string name="scheduled_posts_logout_warning">Máte %1$d naplánovaných příspěvků, které ještě nebyly publikovány. Odhlášením budou trvale smazány.</string>
<string name="scheduled_posts_notification_sent_title">Naplánovaný příspěvek publikován</string>
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d ve frontě</item>
<item quantity="few">%d ve frontě</item>
<item quantity="many">%d ve frontě</item>
<item quantity="other">%d ve frontě</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 do 1 h</item>
<item quantity="few"> · %d do 1 h</item>
<item quantity="many"> · %d do 1 h</item>
<item quantity="other"> · %d do 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">na 1 relay</item>
<item quantity="few">na %d relaye</item>
<item quantity="many">na %d relayů</item>
<item quantity="other">na %d relayů</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
</resources> </resources>
+52 -53
View File
@@ -402,6 +402,58 @@ anz der Bedingungen ist erforderlich</string>
<string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string> <string name="migrate_bookmarks_button">Alle in neue Lesezeichen verschieben</string>
<string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string> <string name="migrate_bookmarks_success">Lesezeichen erfolgreich migriert</string>
<string name="drafts">Entwürfe</string> <string name="drafts">Entwürfe</string>
<string name="scheduled_posts">Geplante Beiträge</string>
<string name="schedule_post">Planen</string>
<string name="schedule_post_time_label">Geplante Zeit</string>
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
<string name="schedule_post_pick_label">Planen für…</string>
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
<string name="schedule_post_was_due">Fällig vor %1$s</string>
<string name="schedule_post_picker_time_title">Zeit</string>
<string name="schedule_post_button_add">Beitrag planen</string>
<string name="schedule_post_button_remove">Planung abbrechen</string>
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
<string name="scheduled_posts_day_tomorrow">Morgen</string>
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
<string name="scheduled_posts_logout_toast">Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht</string>
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
<string name="scheduled_posts_status_pending">Geplant</string>
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
<string name="scheduled_posts_status_sent">Gesendet</string>
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
<string name="scheduled_posts_logout_warning">Du hast %1$d geplante(n) Beitrag/Beiträge, der/die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d in Warteschlange</item>
<item quantity="other">%d in Warteschlange</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 fällig in 1 Std.</item>
<item quantity="other"> · %d fällig in 1 Std.</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">an 1 Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
<string name="polls">Umfragen</string> <string name="polls">Umfragen</string>
<string name="open_polls">Offen</string> <string name="open_polls">Offen</string>
<string name="closed_polls">Geschlossen</string> <string name="closed_polls">Geschlossen</string>
@@ -2431,57 +2483,4 @@ anz der Bedingungen ist erforderlich</string>
<string name="emoji_public_explainer">Öffentliche Emojis sind für alle sichtbar und erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigungsauswahl, wenn dieses Paket in deiner Emoji-Liste ist.</string> <string name="emoji_public_explainer">Öffentliche Emojis sind für alle sichtbar und erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigungsauswahl, wenn dieses Paket in deiner Emoji-Liste ist.</string>
<string name="emoji_private_explainer">Private Emojis werden verschlüsselt auf Relays gespeichert und sind nur für dich sichtbar. Sie erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigung wie öffentliche.</string> <string name="emoji_private_explainer">Private Emojis werden verschlüsselt auf Relays gespeichert und sind nur für dich sichtbar. Sie erscheinen in deinem Reaktionsmenü und in der \":\"-Autovervollständigung wie öffentliche.</string>
<string name="gif">Gif</string> <string name="gif">Gif</string>
<string name="scheduled_posts">Geplante Beiträge</string>
<string name="schedule_post">Planen</string>
<string name="schedule_post_time_label">Geplante Zeit</string>
<string name="schedule_post_helper">Beiträge werden innerhalb von ~15 Minuten nach der geplanten Zeit veröffentlicht.</string>
<string name="schedule_post_pick_time">Geplante Zeit auswählen</string>
<string name="schedule_post_pick_label">Planen für…</string>
<string name="schedule_post_publishes_in">Veröffentlicht in %1$s</string>
<string name="schedule_post_was_due">Fällig vor %1$s</string>
<string name="schedule_post_picker_time_title">Zeit</string>
<string name="schedule_post_button_add">Beitrag planen</string>
<string name="schedule_post_button_remove">Planung abbrechen</string>
<string name="schedule_post_warning_title">Dauerbenachrichtigungen deaktiviert</string>
<string name="schedule_post_warning_single">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App das nächste Mal öffnest. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_warning_multi">Geplante Beiträge werden möglicherweise erst veröffentlicht, wenn du die App wieder öffnest. Geplante Beiträge anderer Konten werden nicht ausgelöst, solange dieses Konto aktiv ist. Aktiviere Dauerbenachrichtigungen in Einstellungen → UI-Einstellungen für zuverlässige Hintergrundplanung.</string>
<string name="schedule_post_preset_in_one_hour">In 1 Stunde</string>
<string name="schedule_post_preset_tomorrow_morning">Morgen 9 Uhr</string>
<string name="schedule_post_preset_next_monday_morning">Nächsten Montag 9 Uhr</string>
<string name="schedule_post_always_on_prompt_title">Dauerbenachrichtigungen aktivieren?</string>
<string name="schedule_post_always_on_prompt_message">Geplante Beiträge werden zuverlässig nur veröffentlicht, wenn Dauerbenachrichtigungen aktiviert sind. Andernfalls werden sie möglicherweise erst beim nächsten Öffnen der App ausgelöst.</string>
<string name="schedule_post_always_on_prompt_open_settings">Einstellungen öffnen</string>
<string name="schedule_post_always_on_prompt_continue">Trotzdem fortfahren</string>
<string name="scheduled_posts_at_time">%1$s · in %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · vor %2$s</string>
<string name="scheduled_posts_day_tomorrow">Morgen</string>
<string name="scheduled_posts_logout_toast_zero">Abgemeldet</string>
<string name="scheduled_posts_logout_toast">Abgemeldet · %1$d geplante(n) Beitrag/Beiträge gelöscht</string>
<string name="scheduled_posts_action_send_now">Jetzt senden</string>
<string name="scheduled_posts_empty_title">Keine geplanten Beiträge</string>
<string name="scheduled_posts_empty_hint">Verfasse eine Notiz und tippe auf das Uhr-Symbol, um sie für später zu planen.</string>
<string name="scheduled_posts_error_prefix">Fehler: %1$s</string>
<string name="scheduled_posts_status_pending">Geplant</string>
<string name="scheduled_posts_status_publishing">Wird gesendet…</string>
<string name="scheduled_posts_status_failed">Fehlgeschlagen</string>
<string name="scheduled_posts_status_sent">Gesendet</string>
<string name="scheduled_posts_status_cancelled">Abgebrochen</string>
<string name="scheduled_posts_logout_warning">Du hast %1$d geplante(n) Beitrag/Beiträge, der/die noch nicht veröffentlicht wurden. Beim Abmelden werden sie dauerhaft gelöscht.</string>
<string name="scheduled_posts_notification_sent_title">Geplanter Beitrag veröffentlicht</string>
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d in Warteschlange</item>
<item quantity="other">%d in Warteschlange</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 fällig in 1 Std.</item>
<item quantity="other"> · %d fällig in 1 Std.</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">an 1 Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
</resources> </resources>
+53 -53
View File
@@ -396,6 +396,59 @@
<string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string> <string name="migrate_bookmarks_button">Mover Tudo para Novos Favoritos</string>
<string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string> <string name="migrate_bookmarks_success">Favoritos migrados com sucesso</string>
<string name="drafts">Rascunhos</string> <string name="drafts">Rascunhos</string>
<string name="scheduled_posts">Posts agendados</string>
<string name="schedule_post">Agendar</string>
<string name="schedule_post_time_label">Hora agendada</string>
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
<string name="schedule_post_pick_time">Escolher horário agendado</string>
<string name="schedule_post_pick_label">Agendar para…</string>
<string name="schedule_post_publishes_in">Publica em %1$s</string>
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
<string name="schedule_post_picker_time_title">Hora</string>
<string name="schedule_post_button_add">Agendar post</string>
<string name="schedule_post_button_remove">Cancelar agendamento</string>
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
<string name="scheduled_posts_logout_toast">Desconectado · %1$d post(s) agendado(s) excluído(s)</string>
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
<string name="scheduled_posts_action_send_now">Enviar agora</string>
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
<string name="scheduled_posts_status_pending">Agendado</string>
<string name="scheduled_posts_status_publishing">Enviando…</string>
<string name="scheduled_posts_status_failed">Falhou</string>
<string name="scheduled_posts_status_sent">Enviado</string>
<string name="scheduled_posts_status_cancelled">Cancelado</string>
<string name="scheduled_posts_logout_warning">Você tem %1$d post(s) agendado(s) que ainda não foram publicados. Sair excluirá esses posts permanentemente.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d na fila</item>
<item quantity="other">%d na fila</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para 1 relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
<string name="polls">Enquetes</string> <string name="polls">Enquetes</string>
<string name="open_polls">Abertas</string> <string name="open_polls">Abertas</string>
<string name="closed_polls">Encerradas</string> <string name="closed_polls">Encerradas</string>
@@ -2426,57 +2479,4 @@
<string name="emoji_public_explainer">Emojis públicos são visíveis para todos e aparecem no seu menu de reações e no seletor de autocompletar \":\" quando este pacote está na sua lista de emojis.</string> <string name="emoji_public_explainer">Emojis públicos são visíveis para todos e aparecem no seu menu de reações e no seletor de autocompletar \":\" quando este pacote está na sua lista de emojis.</string>
<string name="emoji_private_explainer">Emojis privados são armazenados criptografados em relays e visíveis apenas para você. Eles aparecem no seu menu de reações e no autocompletar \":\" assim como os públicos.</string> <string name="emoji_private_explainer">Emojis privados são armazenados criptografados em relays e visíveis apenas para você. Eles aparecem no seu menu de reações e no autocompletar \":\" assim como os públicos.</string>
<string name="gif">Gif</string> <string name="gif">Gif</string>
<string name="scheduled_posts">Posts agendados</string>
<string name="schedule_post">Agendar</string>
<string name="schedule_post_time_label">Hora agendada</string>
<string name="schedule_post_helper">Posts são publicados em até ~15 minutos após o horário agendado.</string>
<string name="schedule_post_pick_time">Escolher horário agendado</string>
<string name="schedule_post_pick_label">Agendar para…</string>
<string name="schedule_post_publishes_in">Publica em %1$s</string>
<string name="schedule_post_was_due">Devia ter sido publicado há %1$s</string>
<string name="schedule_post_picker_time_title">Hora</string>
<string name="schedule_post_button_add">Agendar post</string>
<string name="schedule_post_button_remove">Cancelar agendamento</string>
<string name="schedule_post_warning_title">Notificações sempre ativas desativadas</string>
<string name="schedule_post_warning_single">Posts agendados podem não ser publicados até você reabrir o app. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_warning_multi">Posts agendados podem não ser publicados até você reabrir o app. Posts agendados de outras contas não serão disparados enquanto esta conta estiver ativa. Ative notificações sempre ativas em Configurações → Preferências de UI para agendamento confiável em segundo plano.</string>
<string name="schedule_post_preset_in_one_hour">Em 1 hora</string>
<string name="schedule_post_preset_tomorrow_morning">Amanhã às 9h</string>
<string name="schedule_post_preset_next_monday_morning">Próxima segunda às 9h</string>
<string name="schedule_post_always_on_prompt_title">Ativar notificações sempre ativas?</string>
<string name="schedule_post_always_on_prompt_message">Posts agendados publicam de forma confiável apenas quando notificações sempre ativas estão ativadas. Caso contrário, podem não disparar até você reabrir o app.</string>
<string name="schedule_post_always_on_prompt_open_settings">Abrir configurações</string>
<string name="schedule_post_always_on_prompt_continue">Continuar mesmo assim</string>
<string name="scheduled_posts_at_time">%1$s · em %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · há %2$s</string>
<string name="scheduled_posts_day_tomorrow">Amanhã</string>
<string name="scheduled_posts_logout_toast_zero">Desconectado</string>
<string name="scheduled_posts_logout_toast">Desconectado · %1$d post(s) agendado(s) excluído(s)</string>
<string name="scheduled_posts_action_send_now">Enviar agora</string>
<string name="scheduled_posts_empty_title">Sem posts agendados</string>
<string name="scheduled_posts_empty_hint">Componha uma nota e toque no ícone do relógio para agendá-la.</string>
<string name="scheduled_posts_error_prefix">Erro: %1$s</string>
<string name="scheduled_posts_status_pending">Agendado</string>
<string name="scheduled_posts_status_publishing">Enviando…</string>
<string name="scheduled_posts_status_failed">Falhou</string>
<string name="scheduled_posts_status_sent">Enviado</string>
<string name="scheduled_posts_status_cancelled">Cancelado</string>
<string name="scheduled_posts_logout_warning">Você tem %1$d post(s) agendado(s) que ainda não foram publicados. Sair excluirá esses posts permanentemente.</string>
<string name="scheduled_posts_notification_sent_title">Post agendado publicado</string>
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d na fila</item>
<item quantity="other">%d na fila</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para 1 relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
</resources> </resources>
+53 -53
View File
@@ -396,6 +396,59 @@
<string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string> <string name="migrate_bookmarks_button">Flytta allt till nya bokmärken</string>
<string name="migrate_bookmarks_success">Bokmärken migrerade</string> <string name="migrate_bookmarks_success">Bokmärken migrerade</string>
<string name="drafts">Utkast</string> <string name="drafts">Utkast</string>
<string name="scheduled_posts">Schemalagda inlägg</string>
<string name="schedule_post">Schemalägg</string>
<string name="schedule_post_time_label">Schemalagd tid</string>
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
<string name="schedule_post_pick_label">Schemalägg för…</string>
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
<string name="schedule_post_picker_time_title">Tid</string>
<string name="schedule_post_button_add">Schemalägg inlägg</string>
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
<string name="scheduled_posts_logout_toast">Utloggad · %1$d schemalagda inlägg raderade</string>
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
<string name="scheduled_posts_action_send_now">Skicka nu</string>
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
<string name="scheduled_posts_status_pending">Schemalagd</string>
<string name="scheduled_posts_status_publishing">Skickar…</string>
<string name="scheduled_posts_status_failed">Misslyckades</string>
<string name="scheduled_posts_status_sent">Skickat</string>
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
<string name="scheduled_posts_logout_warning">Du har %1$d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d i kö</item>
<item quantity="other">%d i kö</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till 1 relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
<string name="polls">Omröstningar</string> <string name="polls">Omröstningar</string>
<string name="open_polls">Öppna</string> <string name="open_polls">Öppna</string>
<string name="closed_polls">Stängda</string> <string name="closed_polls">Stängda</string>
@@ -2425,57 +2478,4 @@
<string name="emoji_public_explainer">Offentliga emojis är synliga för alla och visas i din reaktionsmeny och i \":\"-autokompletteringen när detta paket finns i din emoji-lista.</string> <string name="emoji_public_explainer">Offentliga emojis är synliga för alla och visas i din reaktionsmeny och i \":\"-autokompletteringen när detta paket finns i din emoji-lista.</string>
<string name="emoji_private_explainer">Privata emojis lagras krypterade på relän och är endast synliga för dig. De visas i din reaktionsmeny och i autokomplettering med \":\" precis som offentliga.</string> <string name="emoji_private_explainer">Privata emojis lagras krypterade på relän och är endast synliga för dig. De visas i din reaktionsmeny och i autokomplettering med \":\" precis som offentliga.</string>
<string name="gif">Gif</string> <string name="gif">Gif</string>
<string name="scheduled_posts">Schemalagda inlägg</string>
<string name="schedule_post">Schemalägg</string>
<string name="schedule_post_time_label">Schemalagd tid</string>
<string name="schedule_post_helper">Inlägg publiceras inom ~15 minuter från den schemalagda tiden.</string>
<string name="schedule_post_pick_time">Välj schemalagd tid</string>
<string name="schedule_post_pick_label">Schemalägg för…</string>
<string name="schedule_post_publishes_in">Publiceras om %1$s</string>
<string name="schedule_post_was_due">Skulle ha publicerats för %1$s sedan</string>
<string name="schedule_post_picker_time_title">Tid</string>
<string name="schedule_post_button_add">Schemalägg inlägg</string>
<string name="schedule_post_button_remove">Avbryt schemaläggning</string>
<string name="schedule_post_warning_title">Alltid-på-aviseringar avstängda</string>
<string name="schedule_post_warning_single">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_warning_multi">Schemalagda inlägg kanske inte publiceras förrän du öppnar appen igen. Andra kontons schemalagda inlägg utlöses inte medan detta konto är aktivt. Aktivera alltid-på i Inställningar → UI-inställningar för pålitlig schemaläggning i bakgrunden.</string>
<string name="schedule_post_preset_in_one_hour">Om 1 timme</string>
<string name="schedule_post_preset_tomorrow_morning">Imorgon kl. 09:00</string>
<string name="schedule_post_preset_next_monday_morning">Nästa måndag kl. 09:00</string>
<string name="schedule_post_always_on_prompt_title">Aktivera alltid-på-aviseringar?</string>
<string name="schedule_post_always_on_prompt_message">Schemalagda inlägg publiceras pålitligt endast när alltid-på-aviseringar är aktiverade. Annars kanske de inte utlöses förrän du öppnar appen igen.</string>
<string name="schedule_post_always_on_prompt_open_settings">Öppna inställningar</string>
<string name="schedule_post_always_on_prompt_continue">Fortsätt ändå</string>
<string name="scheduled_posts_at_time">%1$s · om %2$s</string>
<string name="scheduled_posts_at_time_past">%1$s · för %2$s sedan</string>
<string name="scheduled_posts_day_tomorrow">Imorgon</string>
<string name="scheduled_posts_logout_toast_zero">Utloggad</string>
<string name="scheduled_posts_logout_toast">Utloggad · %1$d schemalagda inlägg raderade</string>
<string name="scheduled_posts_action_send_now">Skicka nu</string>
<string name="scheduled_posts_empty_title">Inga schemalagda inlägg</string>
<string name="scheduled_posts_empty_hint">Skriv en anteckning och tryck på klockikonen för att schemalägga den.</string>
<string name="scheduled_posts_error_prefix">Fel: %1$s</string>
<string name="scheduled_posts_status_pending">Schemalagd</string>
<string name="scheduled_posts_status_publishing">Skickar…</string>
<string name="scheduled_posts_status_failed">Misslyckades</string>
<string name="scheduled_posts_status_sent">Skickat</string>
<string name="scheduled_posts_status_cancelled">Avbrutet</string>
<string name="scheduled_posts_logout_warning">Du har %1$d schemalagda inlägg som inte har publicerats än. Att logga ut raderar dem permanent.</string>
<string name="scheduled_posts_notification_sent_title">Schemalagt inlägg publicerat</string>
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d i kö</item>
<item quantity="other">%d i kö</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till 1 relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
</resources> </resources>
+14
View File
@@ -25,6 +25,20 @@ contact = "admin@example.com"
host = "0.0.0.0" host = "0.0.0.0"
port = 7447 port = 7447
path = "/" path = "/"
# Ktor CIO event-loop pool sizing. Leave commented-out for sensible
# per-CPU defaults (typical for <2k concurrent connections). Lift on
# big-VM deployments targeting 10k+ connections — over-threading at
# low connection counts hurts L1/L2 cache locality, so always
# benchmark before/after when tuning these.
#
# Operators targeting >1k concurrent WebSockets should also raise the
# OS file-descriptor limit: `ulimit -n 65536` (or higher) before
# launching, plus a matching `LimitNOFILE=` in any systemd unit. The
# default of 1024 on most distros caps the relay well below 1k FDs
# (one per WS plus DB and listening sockets).
# connection_group_size = 4
# worker_group_size = 16
# call_group_size = 64
[database] [database]
# True keeps an in-memory SQLite db (events vanish on restart). Useful # True keeps an in-memory SQLite db (events vanish on restart). Useful
+159 -50
View File
@@ -1,5 +1,14 @@
# Connection scaling: pushing past 2 000 # Connection scaling: pushing past 2 000
> **Status (2026-05-07):** Sketches A and B shipped on
> `claude/connection-scaling-plan-YVjc8`. Sketch C landed as a smaller
> slice in Quartz — the streaming-filter cut — once the audit showed
> the rest of the plan's premise was overstated. Verification
> benchmarks (`connectionsHeldOpen10k`, `connectionsHeldOpenWithFanout`)
> are wired up but only run under `-DrunLoadBenchmark=true`. Remaining
> open work, including fan-out de-duplication, is now tracked in
> [`live-broadcast-fanout-index.md`](./2026-05-07-live-broadcast-fanout-index.md).
## Problem ## Problem
Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000 Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000
@@ -25,69 +34,169 @@ the channel array, even though most connections never fan out.
## Sketch ## Sketch
### A — adaptive outQueue capacity ### A — adaptive outQueue capacity ✅ shipped
Start every connection with `INITIAL_OUTGOING_BUFFER = 64`. When the > Original plan: start at `INITIAL_OUTGOING_BUFFER = 64` and swap to a
producer side trySends and we observe queue depth crossing a high-water > wider channel under a per-session lock when a high-water mark
mark (e.g. 75% full), grow the channel up to `MAX_OUTGOING_BUFFER = > trips. **Not how it shipped.**
8192`. This is not how `kotlinx.coroutines.channels.Channel` is
structured (capacity is fixed at construction), so the implementation
is "swap in a wider channel under a per-session lock when watermark
trips" — drains the old, then routes new sends through the new.
Expected: 90% of connections never fan out, so they stay at 64 slots What actually shipped is the simpler alternative the original Risks
× ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB. section called out: `Channel.UNLIMITED` plus an `AtomicInteger`
Hot-fanout connections still get the 2 MB cap. backlog cap. kotlinx.coroutines' `BufferedChannel` allocates segments
lazily, so an unlimited channel pays only the small head-segment cost
### B — per-relay event-loop pool sizing on idle connections — there is no preallocated buffer to scale.
Ktor CIO defaults to one event-loop thread per available CPU.
Beyond a few thousand connections, this becomes the bottleneck — and
none of geode's per-connection work is CPU-bound (it's mostly waiting
on incoming frames). Tune CIO via:
```kotlin ```kotlin
embeddedServer(CIO, ...) { private val outQueue = Channel<String>(capacity = Channel.UNLIMITED)
connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2) private val outstanding = AtomicInteger(0)
workerGroupSize = max(4, Runtime.getRuntime().availableProcessors())
callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4) // producer side
val depth = outstanding.incrementAndGet()
if (depth > MAX_OUTGOING_BUFFER) { // 8192
outstanding.decrementAndGet()
droppedForBackpressure = true
outQueue.close() // NIP-01: drop the conn
return@connect
}
val res = outQueue.trySend(json)
if (!res.isSuccess) outstanding.decrementAndGet() // closed concurrently
// writer side
for (json in outQueue) {
ws.outgoing.send(Frame.Text(json))
outstanding.decrementAndGet()
} }
``` ```
Expose these through `RelayConfig.NetworkSection` so an operator on a Memory characteristic the plan asked for is intact: idle connections
big VM can lift them. no longer reserve an 8 192-slot fixed buffer; hot fan-out connections
still get bounded at the same 2 MiB cap before the slow-client cutoff
fires. NIP-01 ordering is preserved (no silent drop — connection is
killed at the cap).
### C — reduce per-message JSON allocations Implementation: `geode/.../server/WebSocketSessionPump.kt`. The
channel-swap approach was rejected because
`Channel.UNLIMITED` already gives the lazy-allocation behavior the
swap was simulating, with none of the swap's race surface.
`OptimizedJsonMapper.fromJsonToCommand` allocates a `JsonNode` tree per ### B — per-relay event-loop pool sizing ✅ shipped
incoming frame. At 10k connections with 1 msg/s each that's 10k tree
allocations/sec. Investigate streaming Jackson + reusing `ObjectMapper`
per session, or using kotlinx-serialization's lower-overhead path.
This is more of a quartz-level change than geode-specific, but Three optional knobs added to `[network]` in `RelayConfig`:
geode's load benchmark is the right place to measure it.
## How to verify ```toml
[network]
host = "0.0.0.0"
port = 7447
path = "/"
# connection_group_size = 4
# worker_group_size = 16
# call_group_size = 64
```
Add to `geode.perf.LoadBenchmark`: Default is **`null` (Ktor default)** — no behavior change unless an
operator explicitly tunes them. The values are wired through
`LocalRelayServer` into the new `embeddedServer(factory = CIO,
rootConfig = serverConfig {…}, configure = {…})` overload (the
short-form `embeddedServer(factory, host, port) {…}` overload doesn't
expose CIO config). The auto-connector that the short form created
now has to be added explicitly via `connector { host = …; port = …}`.
- `connectionsHeldOpen10k` — opens 10 000 idle WebSocket connections; `config.example.toml` documents the knobs and includes the operator
asserts no FD exhaustion + RSS stays under 1 GB. note that targeting >1k connections needs `ulimit -n 65536` (or
- `connectionsHeldOpenWithFanout` — 5 000 idle subscribers, matching `LimitNOFILE=` in a systemd unit).
10 EPS published; measures p99 fanout latency at scale.
The current `connectionsHeldOpen` benchmark stays as the baseline ### C — reduce per-message JSON allocations ✅ partially shipped (in Quartz)
floor (~2 000 conns).
## Risks > Original plan claim: "`OptimizedJsonMapper.fromJsonToCommand`
> allocates a `JsonNode` tree per incoming frame." **Overstated.**
- **Adaptive channel swap is fiddly**: drains under the producer's nose Audit of `quartz/.../jackson` showed the Command/Message envelope is
must preserve OK ordering. A simpler alternative: keep capacity fixed, already streaming:
but lazily allocate a small `ArrayDeque<String>` only when the first
message is sent. Channels in kotlinx.coroutines do allocate up-front. | Path | Already streaming? | Tree alloc? |
- **Bumping CIO group sizes can hurt**: more threads can mean worse | ----------------------- | ------------------ | -------------------------------------------------- |
L1/L2 locality. Always benchmark before/after, don't trust | `MessageDeserializer` | yes | only for `COUNT` result (rare) |
intuitive sizing. | `CommandDeserializer` | yes | only for **filter sub-objects** in REQ/COUNT/NEG-OPEN |
- **OS-level FD limit**: per-process FD limit on Linux defaults to | `EventDeserializer` | yes | none — `currentName().hashCode()` dispatch |
1024 in many environments. Document the `ulimit -n` requirement | `ManualFilterDeserializer` | **no** | `jp.codec.readTree(jp)` per filter |
for operators targeting >1k connections.
So the only relay-inbound tree allocation worth chasing was filter
parsing — the bulk of the per-frame allocations on a REQ-heavy
relay.
What shipped: a streaming `ManualFilterDeserializer.fromJson(jp:
JsonParser)` modeled exactly on `EventDeserializer`. Token-loop with
field-name dispatch (`ids` / `authors` / `kinds` / `since` / `until` /
`limit` / `search`, plus dynamic `#x` / `&x` tag keys), and
`readStringArray` / `readIntArray` helpers that drop invalid entries
silently to match the tree path's `mapNotNull { asTextOrNull() }`
tolerance. Wired into all four internal call sites:
`FilterDeserializer.deserialize` and the three `CommandDeserializer`
paths (REQ, COUNT, NEG-OPEN).
The tree-based `fromJson(ObjectNode)` overload is retained for
external/cross-format adapters (Quartz is a published library).
What was NOT done — and why:
- **Streaming Jackson for the Command envelope**: already streaming.
No allocation to remove.
- **kotlinx-serialization for the inbound path**: not pursued. The
cross-mapper round-trip tests in `KotlinSerializationMapperTest`
show the two formats are interchangeable, but the engine swap is a
much larger lift than the filter cut and there's no evidence the
KS path is faster on this code shape.
- **Per-session `ObjectMapper`**: Jackson's `ObjectMapper` is
thread-safe and stateless — sharing one is the recommended pattern.
Per-session would *increase* allocation, not decrease it.
## How to verify ✅ shipped
Two new benchmarks in `geode.perf.LoadBenchmark`, gated behind
`-DrunLoadBenchmark=true`:
- **`connectionsHeldOpen10k`** — opens 10 000 idle WebSocket
connections, asserts every one settles to EOSE inside 120 s, and
measures retained JVM heap (after `System.gc()` + 200 ms settle)
with a 1 GiB ceiling assertion. Requires `ulimit -n 32768` on
Linux.
- **`connectionsHeldOpenWithFanout`** — 5 000 subscribers all
matching `kinds:[1]`, one publisher emitting `targetEps × duration`
events, prints p50 / p99 last-fanout latency. No assertion on
latency — just regression-detection via stdout logging.
The original `connectionsHeldOpen` benchmark stays as the **baseline
floor (~2 000 conns)** for before/after comparisons.
Note on heap-vs-RSS: the original plan said "RSS stays under 1 GB"
but the JVM can only measure heap from inside; `Runtime.totalMemory
- freeMemory` is what the benchmark asserts on. RSS will be higher
because of code, native buffers, off-heap (Ktor CIO), etc.
## Risks (post-implementation)
- ~~**Adaptive channel swap is fiddly**~~ — sidestepped by using
`Channel.UNLIMITED` instead of swapping bounded channels.
- **Bumping CIO group sizes can hurt** — kept the defaults `null`.
Operators must opt in, and the docstrings explicitly say to
benchmark before/after.
- **OS-level FD limit** — documented in `config.example.toml` next to
the CIO knobs. Test prereq is also documented in the benchmark
KDoc.
## Open work
- **Fan-out de-duplication** — when one EVENT matches N subscribers,
we currently re-serialize and copy the JSON N times into N
channels. Caching one pre-serialized payload per event and
broadcasting a shared reference is a much bigger win than anything
in this plan; tracked in
[`live-broadcast-fanout-index.md`](./2026-05-07-live-broadcast-fanout-index.md).
- **Filter-matching index** — same plan. At 10k conns × ~5 filters
that's 50k evaluations per published EVENT, almost all of which
could be culled by indexing subscriptions on `kinds` / `authors` /
`#e` / `#p`.
- **Netty engine evaluation** — Ktor's Netty engine handles many idle
connections with measurably lower per-connection overhead than
CIO. Not pursued here because it changes the transport layer
wholesale; revisit only if the CIO knobs in (B) prove insufficient
for an operator at 20k+ connections.
@@ -32,8 +32,10 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.application.serverConfig
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header import io.ktor.server.request.header
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
@@ -109,6 +111,16 @@ class LocalRelayServer(
* RPC payload. * RPC payload.
*/ */
val maxAdminBodyBytes: Int = 1 shl 20, val maxAdminBodyBytes: Int = 1 shl 20,
/**
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
* Lift on machines with many cores when targeting 10k+
* concurrent connections see `[network]` config docs.
*/
val connectionGroupSize: Int? = null,
/** Ktor CIO worker-thread count. `null` keeps Ktor's default. */
val workerGroupSize: Int? = null,
/** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */
val callGroupSize: Int? = null,
) { ) {
private val infoHolder = private val infoHolder =
object : Nip86Server.InfoHolder { object : Nip86Server.InfoHolder {
@@ -167,51 +179,78 @@ class LocalRelayServer(
* [url] is safe to read on the very next line. * [url] is safe to read on the very next line.
*/ */
fun start(): LocalRelayServer { fun start(): LocalRelayServer {
// Snapshot the constructor-supplied overrides into locals so
// the `configure` lambda below can assign to its receiver
// without the names colliding with outer properties.
val connGrp = connectionGroupSize
val workGrp = workerGroupSize
val callGrp = callGroupSize
val bindHost = host
val bindPort = port
val server = val server =
embeddedServer(CIO, host = host, port = port) { embeddedServer(
install(WebSockets) { factory = CIO,
maxFrameBytes?.let { maxFrameSize = it } rootConfig =
} serverConfig {
routing { module {
// NIP-11: GET on the relay URL with Accept: install(WebSockets) {
// application/nostr+json returns the relay info doc. maxFrameBytes?.let { maxFrameSize = it }
// We mount this *before* the webSocket route so Ktor }
// serves NIP-11 for plain HTTP GETs and only upgrades routing {
// to a WebSocket when the request is a WS upgrade. // NIP-11: GET on the relay URL with Accept:
get(path) { // application/nostr+json returns the relay info doc.
val accept = call.request.header(HttpHeaders.Accept).orEmpty() // We mount this *before* the webSocket route so Ktor
if (accept.contains("application/nostr+json")) { // serves NIP-11 for plain HTTP GETs and only upgrades
call.response.headers.append("Access-Control-Allow-Origin", "*") // to a WebSocket when the request is a WS upgrade.
call.respondText( get(path) {
relay.info.json, val accept = call.request.header(HttpHeaders.Accept).orEmpty()
ContentType.parse("application/nostr+json"), if (accept.contains("application/nostr+json")) {
) call.response.headers.append("Access-Control-Allow-Origin", "*")
} else { call.respondText(
call.respondText( relay.info.json,
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).", ContentType.parse("application/nostr+json"),
ContentType.Text.Plain, )
HttpStatusCode.UpgradeRequired, } else {
) call.respondText(
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).",
ContentType.Text.Plain,
HttpStatusCode.UpgradeRequired,
)
}
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
post(path) {
nip86Route.handle(call)
}
webSocket(path) {
if (shuttingDown) {
// Just return — Ktor closes the WS for us.
return@webSocket
}
WebSocketSessionPump(this).pump(
server = relay.server,
registerSession = activeSessions::add,
unregisterSession = activeSessions::remove,
)
}
}
} }
},
configure = {
connector {
host = bindHost
port = bindPort
} }
// NIP-86: POST application/nostr+json+rpc with a NIP-98 // Keep Ktor defaults unless the operator overrode
// signed Authorization header → JSON-RPC dispatch. // them — Ktor's per-CPU sizing is sensible for
post(path) { // most deployments, and over-threading hurts L1/L2
nip86Route.handle(call) // locality at low connection counts.
} connGrp?.let { connectionGroupSize = it }
webSocket(path) { workGrp?.let { workerGroupSize = it }
if (shuttingDown) { callGrp?.let { callGroupSize = it }
// Just return — Ktor closes the WS for us. },
return@webSocket )
}
WebSocketSessionPump(this).pump(
server = relay.server,
registerSession = activeSessions::add,
unregisterSession = activeSessions::remove,
)
}
}
}
server.start(wait = false) server.start(wait = false)
engine = server.engine engine = server.engine
// Ktor 3.x made resolvedConnectors() suspend. We block here so // Ktor 3.x made resolvedConnectors() suspend. We block here so
@@ -117,6 +117,9 @@ fun main(args: Array<String>) {
maxFrameBytes = frameLimit, maxFrameBytes = frameLimit,
adminPubkeys = config.admin.pubkeys.toSet(), adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url, publicUrl = config.admin.public_url,
connectionGroupSize = config.network.connection_group_size,
workerGroupSize = config.network.worker_group_size,
callGroupSize = config.network.call_group_size,
).start() ).start()
Runtime.getRuntime().addShutdownHook( Runtime.getRuntime().addShutdownHook(
@@ -96,6 +96,29 @@ data class RelayConfig(
val host: String = "0.0.0.0", val host: String = "0.0.0.0",
val port: Int = 7447, val port: Int = 7447,
val path: String = "/", val path: String = "/",
/**
* Ktor CIO acceptor-thread count. `null` (default) keeps Ktor's
* default sizing fine up to a few thousand concurrent
* connections. On big-VM deployments targeting 10k+
* connections, lift this to roughly half the available cores
* so the acceptor doesn't starve workers.
*/
val connection_group_size: Int? = null,
/**
* Ktor CIO worker-thread count (handles socket I/O). `null`
* keeps Ktor's default. Each connection's WebSocket read/write
* is dispatched onto this pool; for many idle long-lived
* connections the pool can stay small, but 10k+ connections
* benefit from sizing this to the full CPU count.
*/
val worker_group_size: Int? = null,
/**
* Ktor CIO call-handling thread count. `null` keeps Ktor's
* default. Sized higher than [worker_group_size] because each
* call (incl. WebSocket upgrade) may suspend on I/O at
* 10k+ connections, ~4× cores is a reasonable starting point.
*/
val call_group_size: Int? = null,
) )
data class DatabaseSection( data class DatabaseSection(
@@ -29,6 +29,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
/** /**
* Per-WebSocket pump that owns the bounded outbound queue and the * Per-WebSocket pump that owns the bounded outbound queue and the
@@ -44,15 +45,39 @@ import kotlinx.coroutines.launch
* 3. `finally`-style teardown closes the queue, cancels the * 3. `finally`-style teardown closes the queue, cancels the
* writer, unregisters the session, and closes it. * writer, unregisters the session, and closes it.
* *
* Slow-client policy: when [outQueue] fills, [SESSION_OUTGOING_BUFFER] * Slow-client policy: once the outbound backlog reaches
* frames behind, the connection is dropped rather than silently * [MAX_OUTGOING_BUFFER] frames, the connection is dropped rather
* losing EVENT/EOSE silent drop would corrupt NIP-01. * than silently losing EVENT/EOSE silent drop would corrupt
* NIP-01.
*
* Memory model: the outbound queue is `Channel.UNLIMITED`, which in
* kotlinx.coroutines allocates segments lazily an idle connection
* pays only a small head-segment cost. The cap is enforced via
* [outstanding] rather than the channel's own capacity so we don't
* reserve a fixed-size buffer up-front for every connection. At
* 5 000+ idle connections this matters: an 8 192-slot fixed buffer
* per connection would otherwise dominate JVM heap usage even
* though the vast majority of connections never fan out.
*/ */
internal class WebSocketSessionPump( internal class WebSocketSessionPump(
private val ws: DefaultWebSocketServerSession, private val ws: DefaultWebSocketServerSession,
) { ) {
private val outQueue = Channel<String>(capacity = SESSION_OUTGOING_BUFFER) /**
private var droppedForBackpressure = false * Unbounded channel bounded by [outstanding] above, not by the
* channel's own capacity. See class kdoc for memory rationale.
*/
private val outQueue = Channel<String>(capacity = Channel.UNLIMITED)
/**
* Number of frames queued but not yet written to the socket.
* Producer increments before [Channel.trySend]; writer decrements
* after the frame is handed to Ktor. When this would cross
* [MAX_OUTGOING_BUFFER] we treat the client as slow and close
* the queue.
*/
private val outstanding = AtomicInteger(0)
@Volatile private var droppedForBackpressure = false
suspend fun pump( suspend fun pump(
server: NostrServer, server: NostrServer,
@@ -64,6 +89,7 @@ internal class WebSocketSessionPump(
try { try {
for (json in outQueue) { for (json in outQueue) {
ws.outgoing.send(Frame.Text(json)) ws.outgoing.send(Frame.Text(json))
outstanding.decrementAndGet()
} }
} catch (_: ClosedSendChannelException) { } catch (_: ClosedSendChannelException) {
// socket closed — outer handler runs normal teardown. // socket closed — outer handler runs normal teardown.
@@ -71,13 +97,22 @@ internal class WebSocketSessionPump(
} }
val session = val session =
server.connect { json -> server.connect { json ->
val res = outQueue.trySend(json) // The channel itself is UNLIMITED, so trySend can't
if (!res.isSuccess && !res.isClosed) { // report "full". Enforce the cap explicitly: increment
// Buffer is full → slow client. Mark + close the // first, refuse if we'd cross the bound, otherwise
// queue; the writer drains, then the outer handler // enqueue.
// closes the WS session. val depth = outstanding.incrementAndGet()
if (depth > MAX_OUTGOING_BUFFER) {
outstanding.decrementAndGet()
droppedForBackpressure = true droppedForBackpressure = true
outQueue.close() outQueue.close()
return@connect
}
val res = outQueue.trySend(json)
if (!res.isSuccess) {
// Channel was closed concurrently (e.g. teardown).
// Roll back the counter; nothing more to do.
outstanding.decrementAndGet()
} }
} }
registerSession(session) registerSession(session)
@@ -98,17 +133,19 @@ internal class WebSocketSessionPump(
companion object { companion object {
/** /**
* Per-session outbound buffer size. When a slow client falls * Per-session outbound backlog cap. When a slow client falls
* this many frames behind, we close their connection rather * this many frames behind, we close their connection rather
* than silently dropping further frames (which would corrupt * than silently dropping further frames (which would corrupt
* NIP-01 by missing EVENT/EOSE messages). * NIP-01 by missing EVENT/EOSE messages).
* *
* Sized to hold fan-out for a connection holding several * Sized to hold fan-out for a connection holding several
* thousand subscriptions when one event matches all of them * thousand subscriptions when one event matches all of them
* the realistic upper bound for a relay client. At ~250B * the realistic upper bound for a relay client. At ~250 B
* per frame this caps per-session memory at ~2 MiB before * per frame this caps per-session worst-case memory at
* we drop the connection. * ~2 MiB before we drop the connection. Idle connections
* pay only the small head-segment cost of an unlimited
* channel ( a few hundred bytes), not the full cap.
*/ */
const val SESSION_OUTGOING_BUFFER: Int = 8192 const val MAX_OUTGOING_BUFFER: Int = 8192
} }
} }
@@ -139,6 +139,195 @@ class LoadBenchmark {
} }
} }
/**
* Holds 10 000 idle WebSocket connections open against a single
* relay. Verifies that the adaptive outQueue (sketch A in
* [connection-scaling plan][1]) lets us cross the ~2 000-connection
* floor measured by [connectionsHeldOpen] without FD exhaustion or
* runaway RSS.
*
* RUN PREREQ: requires a process FD limit ~12 000 (each WS uses
* one FD on each side plus margin). On Linux: `ulimit -n 32768`
* before launching the test JVM.
*
* [1]: geode/plans/2026-05-07-connection-scaling.md
*/
@Test
fun connectionsHeldOpen10k() =
benchmark("connections held open 10k") {
val target = 10_000
runBenchmarkServer { server, http ->
val httpUrl =
okhttp3.Request
.Builder()
.url(server.url.replace("ws://", "http://"))
.build()
val sockets = java.util.concurrent.CopyOnWriteArrayList<okhttp3.WebSocket>()
val opened = AtomicLong()
val gotEose = AtomicLong()
val opens =
measureTime {
repeat(target) {
val ws =
http.newWebSocket(
httpUrl,
object : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: okhttp3.Response,
) {
opened.incrementAndGet()
webSocket.send(
"""["REQ","s",{"kinds":[1],"limit":1}]""",
)
}
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) {
if (text.startsWith("[\"EOSE\"")) {
gotEose.incrementAndGet()
}
}
},
)
sockets += ws
}
val deadline = System.currentTimeMillis() + 120_000
while (gotEose.get() < target && System.currentTimeMillis() < deadline) {
Thread.sleep(50)
}
}
// JVM heap usage, not OS RSS — we can only measure
// what the JVM itself has allocated. Force a GC first
// so the reading reflects retained bytes, not in-flight
// allocation churn from the connect ramp-up.
val rt = Runtime.getRuntime()
System.gc()
Thread.sleep(200)
val heapMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024)
println(
"target=$target opened=${opened.get()} eosed=${gotEose.get()} " +
"active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds} " +
"heapMb=$heapMb",
)
sockets.forEach { runCatching { it.cancel() } }
check(gotEose.get() == target.toLong()) {
"expected $target EOSE but got ${gotEose.get()} — connection scaling regression"
}
check(heapMb < 1024) {
"JVM heap $heapMb MiB exceeded 1 GiB ceiling for $target idle connections"
}
}
}
/**
* 5 000 idle subscribers, one publisher emitting 10 EPS for 10 s.
* Measures fan-out latency at scale exercises the queue path
* for a connection that *does* fan out, not just an idle one.
*
* Each subscriber matches every published event (`kinds:[1]`),
* so a single EVENT generates 5 000 outbound frames per tick.
*/
@Test
fun connectionsHeldOpenWithFanout() =
benchmark("connections held open with fanout") {
val subs = 5_000
val durationSeconds = 10
val targetEps = 10
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val relayUrl = server.url.normalizeRelayUrl()
val received = AtomicLong()
val eosed = AtomicLong()
// Last-receive timestamp per event id. The N-th
// subscriber to deliver wins; combined with the
// publish timestamp this gives us the full fan-out
// duration to the slowest subscriber.
val lastReceiveNs =
java.util.concurrent.ConcurrentHashMap<String, AtomicLong>()
repeat(subs) { i ->
subClient.subscribe(
"fanout-$i",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
lastReceiveNs
.computeIfAbsent(event.id) { AtomicLong() }
.set(System.nanoTime())
received.incrementAndGet()
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eosed.incrementAndGet()
}
},
)
}
runBlocking {
withTimeout(120_000) {
while (eosed.get() < subs) kotlinx.coroutines.delay(100)
}
}
println("$subs subs ready; publishing ${targetEps * durationSeconds} events at $targetEps EPS...")
val signer = NostrSignerSync(KeyPair())
val publishedAt = java.util.concurrent.ConcurrentHashMap<String, Long>()
val totalEvents = targetEps * durationSeconds
val tickIntervalMs = 1000L / targetEps
runBlocking {
repeat(totalEvents) { i ->
val event = signer.sign(TextNoteEvent.build("fanout-$i"))
publishedAt[event.id] = System.nanoTime()
pubClient.publishAndConfirm(event, setOf(relayUrl))
kotlinx.coroutines.delay(tickIntervalMs)
}
}
// Wait for fan-out completion (or 30s, whichever first).
runBlocking {
withTimeout(30_000) {
while (received.get() < subs.toLong() * totalEvents) {
kotlinx.coroutines.delay(100)
}
}
}
val perEventLastMs =
lastReceiveNs.entries
.mapNotNull { (id, last) ->
publishedAt[id]?.let { (last.get() - it) / 1_000_000.0 }
}.sorted()
val p50 = perEventLastMs.getOrNull(perEventLastMs.size / 2) ?: -1.0
val p99 = perEventLastMs.getOrNull((perEventLastMs.size * 99) / 100) ?: -1.0
println(
"subs=$subs events=$totalEvents received=${received.get()}/${subs.toLong() * totalEvents} " +
"p50LastFanoutMs=${"%.1f".format(p50)} " +
"p99LastFanoutMs=${"%.1f".format(p99)}",
)
} finally {
subClient.disconnect()
pubClient.disconnect()
scope.cancel()
}
}
}
/** /**
* One publisher sends 10k events serially. Measures the round-trip * One publisher sends 10k events serially. Measures the round-trip
* `EVENT` `OK true` time, which is dominated by SQLite write * `EVENT` `OK true` time, which is dominated by SQLite write
@@ -24,7 +24,6 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer
@@ -52,9 +51,9 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
val filters = mutableListOf<Filter>() val filters = mutableListOf<Filter>()
while (jp.nextToken() != JsonToken.END_ARRAY) { while (jp.nextToken() != JsonToken.END_ARRAY) {
val filterObj: ObjectNode = jp.codec.readTree(jp) // currentToken is now START_OBJECT for each filter;
val filter = ManualFilterDeserializer.fromJson(filterObj) // the streaming parser consumes through END_OBJECT.
filters.add(filter) filters.add(ManualFilterDeserializer.fromJson(jp))
} }
ReqCmd( ReqCmd(
@@ -68,9 +67,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
val filters = mutableListOf<Filter>() val filters = mutableListOf<Filter>()
while (jp.nextToken() != JsonToken.END_ARRAY) { while (jp.nextToken() != JsonToken.END_ARRAY) {
val filterObj: ObjectNode = jp.codec.readTree(jp) filters.add(ManualFilterDeserializer.fromJson(jp))
val filter = ManualFilterDeserializer.fromJson(filterObj)
filters.add(filter)
} }
CountCmd( CountCmd(
@@ -102,9 +99,8 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
NegOpenCmd.LABEL -> { NegOpenCmd.LABEL -> {
val subId = jp.nextTextValue() val subId = jp.nextTextValue()
jp.nextToken() jp.nextToken() // advance to filter's START_OBJECT
val filterObj: ObjectNode = jp.codec.readTree(jp) val filter = ManualFilterDeserializer.fromJson(jp)
val filter = ManualFilterDeserializer.fromJson(filterObj)
val initialMessage = jp.nextTextValue() val initialMessage = jp.nextTextValue()
NegOpenCmd( NegOpenCmd(
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip01Core.relay.filters package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.databind.node.ObjectNode
@@ -32,11 +33,149 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
override fun deserialize( override fun deserialize(
jp: JsonParser, jp: JsonParser,
ctxt: DeserializationContext, ctxt: DeserializationContext,
): Filter = ManualFilterDeserializer.fromJson(jp.codec.readTree(jp)) ): Filter = ManualFilterDeserializer.fromJson(jp)
} }
class ManualFilterDeserializer { class ManualFilterDeserializer {
companion object { companion object {
/**
* Streaming filter parser. Reads field-by-field off [jp] without
* materializing an intermediate `JsonNode` tree same shape as
* [com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer],
* which is what makes high-fan-out REQ traffic cheap on the
* relay-inbound path.
*
* Caller must position the parser so [jp.currentToken] is the
* `START_OBJECT` opening the filter. On return, [jp.currentToken]
* is the matching `END_OBJECT`.
*
* Tolerant by design invalid array entries (wrong type, JSON
* null) are silently dropped, mirroring the
* `mapNotNull { it.asTextOrNull() }` behavior of the tree-based
* overload below. Unknown top-level fields are skipped via
* [JsonParser.skipChildren].
*/
fun fromJson(jp: JsonParser): Filter {
var ids: MutableList<String>? = null
var authors: MutableList<String>? = null
var kinds: MutableList<Int>? = null
var tags: MutableMap<String, List<String>>? = null
var tagsAll: MutableMap<String, List<String>>? = null
var since: Long? = null
var until: Long? = null
var limit: Int? = null
var search: String? = null
while (jp.nextToken() != JsonToken.END_OBJECT) {
val name = jp.currentName()
jp.nextToken() // advance to value
when {
name == "ids" -> {
ids = readStringArray(jp)
}
name == "authors" -> {
authors = readStringArray(jp)
}
name == "kinds" -> {
kinds = readIntArray(jp)
}
name == "since" -> {
if (jp.currentToken != JsonToken.VALUE_NULL) since = jp.longValue
}
name == "until" -> {
if (jp.currentToken != JsonToken.VALUE_NULL) until = jp.longValue
}
name == "limit" -> {
if (jp.currentToken != JsonToken.VALUE_NULL) limit = jp.intValue
}
name == "search" -> {
if (jp.currentToken != JsonToken.VALUE_NULL) search = jp.text
}
name.length > 1 && name[0] == '#' -> {
val map = tags ?: mutableMapOf<String, List<String>>().also { tags = it }
map[name.substring(1)] = readStringArray(jp)
}
name.length > 1 && name[0] == '&' -> {
val map = tagsAll ?: mutableMapOf<String, List<String>>().also { tagsAll = it }
map[name.substring(1)] = readStringArray(jp)
}
else -> {
jp.skipChildren()
}
}
}
return Filter(
ids = ids,
authors = authors,
kinds = kinds,
tags = tags,
tagsAll = tagsAll,
since = since,
until = until,
limit = limit,
search = search,
)
}
/**
* Reads a string array off [jp]. Drops non-string entries and
* JSON nulls matches the `mapNotNull { it.asTextOrNull() }`
* tolerance of the tree-based path. Returns an empty list when
* the value is anything other than a `START_ARRAY` (incl.
* `null`), so callers don't have to special-case that.
*/
private fun readStringArray(jp: JsonParser): MutableList<String> {
val out = mutableListOf<String>()
if (jp.currentToken == JsonToken.START_ARRAY) {
while (jp.nextToken() != JsonToken.END_ARRAY) {
if (jp.currentToken == JsonToken.VALUE_STRING) {
out.add(jp.text)
} else if (jp.currentToken == JsonToken.START_OBJECT || jp.currentToken == JsonToken.START_ARRAY) {
jp.skipChildren()
}
}
} else if (jp.currentToken == JsonToken.START_OBJECT) {
jp.skipChildren()
}
return out
}
/**
* Reads an int array off [jp]. Same tolerance rules as
* [readStringArray] non-numeric entries are dropped.
*/
private fun readIntArray(jp: JsonParser): MutableList<Int> {
val out = mutableListOf<Int>()
if (jp.currentToken == JsonToken.START_ARRAY) {
while (jp.nextToken() != JsonToken.END_ARRAY) {
when (jp.currentToken) {
JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT -> out.add(jp.intValue)
JsonToken.START_OBJECT, JsonToken.START_ARRAY -> jp.skipChildren()
else -> Unit
}
}
} else if (jp.currentToken == JsonToken.START_OBJECT) {
jp.skipChildren()
}
return out
}
/**
* Tree-based overload kept for callers that already have an
* `ObjectNode` in hand (e.g. cross-format adapters). New code on
* the relay-inbound path should use the streaming overload
* this one materializes the full filter tree first.
*/
fun fromJson(jsonObject: ObjectNode): Filter { fun fromJson(jsonObject: ObjectNode): Filter {
val tagsIn = mutableListOf<String>() val tagsIn = mutableListOf<String>()
jsonObject.fieldNames().forEach { jsonObject.fieldNames().forEach {
@@ -209,10 +209,33 @@ fun main() {
// ipv6 — same flow over an IPv6 socket; // ipv6 — same flow over an IPv6 socket;
// JDK DatagramChannel.connect handles // JDK DatagramChannel.connect handles
// the v6 address resolution natively. // the v6 address resolution natively.
// ecn — runner verifies ECN-CE counts in
// the pcap. Client just does a 100KB
// transfer; the IP-layer ECT codepoint
// is set by the sim and we don't
// need to do anything special.
// amplificationlimit — runner verifies server obeys 3x
// amplification limit. Pure server
// check — client does a normal
// transfer (the runner sets
// TESTCASE_CLIENT=transfer).
// blackhole — sim drops ALL packets for several
// seconds mid-transfer; client must
// resume after blackhole ends. Our
// PTO + retransmit handles this; the
// runner sets TESTCASE_CLIENT=transfer.
// keyupdate — server initiates a 1-RTT key update
// mid-transfer (KEY_PHASE bit flips).
// Our RFC 9001 §6 receive-side key
// update lands the rotation; runner
// verifies the pcap shows packets
// in both phases. Server-side test
// from our perspective.
"handshake", "chacha20", "handshake", "chacha20",
"transfer", "http3", "multiplexing", "transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic", "transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
"retry", "ipv6", "retry", "ipv6",
"ecn", "amplificationlimit", "blackhole",
// NOTE: the runner does NOT have a `versionnegotiation` testcase // NOTE: the runner does NOT have a `versionnegotiation` testcase
// (its Available list excludes it). The :quic VN-handling code // (its Available list excludes it). The :quic VN-handling code
// (applyVersionNegotiation, FORCE_VERSION_NEGOTIATION constant) // (applyVersionNegotiation, FORCE_VERSION_NEGOTIATION constant)
@@ -245,6 +268,28 @@ fun main() {
) )
} }
// keyupdate: same transfer flow but the client initiates a
// RFC 9001 §6 1-RTT key update once the handshake is
// confirmed. Runner verifies pcap shows BOTH client and
// server emitting packets in phase 1 — without our side
// initiating, aioquic's plain-transfer server doesn't
// rotate spontaneously and the test fails with "Expected
// to see packets sent with key phase 1 from both client
// and server".
"keyupdate" -> {
runTransferTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
offeredAlpns = offeredAlpns,
initialVersion = initialVersion,
keyLogPath = keyLogPath,
qlogDir = qlogDir,
parallel = requests.split(Regex("\\s+")).count { it.isNotBlank() } > 1,
initiateKeyUpdate = true,
)
}
// The runner reuses TESTCASE_CLIENT=multiconnect for the // The runner reuses TESTCASE_CLIENT=multiconnect for the
// handshakeloss + handshakecorruption tests (see // handshakeloss + handshakecorruption tests (see
// testcases_quic.py:746). Each URL must be fetched on a fresh // testcases_quic.py:746). Each URL must be fetched on a fresh
@@ -294,6 +339,7 @@ private fun runTransferTest(
keyLogPath: String?, keyLogPath: String?,
qlogDir: File?, qlogDir: File?,
parallel: Boolean, parallel: Boolean,
initiateKeyUpdate: Boolean = false,
): Int { ): Int {
val urls = val urls =
requests requests
@@ -407,6 +453,27 @@ private fun runTransferTest(
// runs even when DEBUG=0 — this is a control-flow boundary, // runs even when DEBUG=0 — this is a control-flow boundary,
// not a hot-path trace. // not a hot-path trace.
System.err.println("[boot] transfer mode: parallel=$parallel urls=${urls.size}") System.err.println("[boot] transfer mode: parallel=$parallel urls=${urls.size}")
// RFC 9001 §6 keyupdate testcase: the runner verifies the pcap
// shows packets in BOTH key phases from BOTH sides. Without
// initiating from our side, only the server's natural rotation
// (if any) would show — aioquic's transfer-server doesn't
// initiate, so we'd see only phase 0. Initiate after the
// handshake is confirmed (HANDSHAKE_DONE → status=CONNECTED,
// RFC 9001 §6.5 prerequisite) but BEFORE we send the GET so the
// request itself is in phase 1 — the server's response then
// mirrors phase 1, satisfying the runner's check. Brief poll
// for status because awaitHandshake returns on TLS-done
// (1-RTT keys derived) which is one ack ahead of HANDSHAKE_DONE
// arriving.
if (initiateKeyUpdate) {
withTimeoutOrNull(2_000L) {
while (conn.status != QuicConnection.Status.CONNECTED) delay(10)
}
conn.initiateKeyUpdate()
System.err.println("[boot] keyupdate: client initiated rotation to phase 1")
}
val outcome = val outcome =
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) { withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
val responses = val responses =
@@ -1481,6 +1481,81 @@ class QuicConnection(
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
} }
/**
* RFC 9001 §6.1 initiate a 1-RTT key update from our side. Derive
* next-phase send keys (and pre-derive next-phase receive keys, for
* the inevitable response from the peer) using HKDF-Expand-Label
* "quic ku", install both as live, flip the phase fields. The next
* outbound packet carries `KEY_PHASE = 1` and the peer is expected
* to mirror back in the same phase.
*
* RFC 9001 §6.5 says an endpoint MUST NOT initiate a key update
* before the handshake is confirmed (HANDSHAKE_DONE received). The
* caller is responsible for that check; this method just performs
* the rotation. §6.4 also forbids initiating a second update before
* the current one has been confirmed (peer responds in matching
* phase) same caller contract.
*
* Header-protection key is unchanged (RFC 9001 §6.1: HP key is NOT
* rotated when keys are updated).
*
* Returns true if rotation succeeded; false if app keys aren't yet
* installed (handshake hasn't completed) or the cipher suite isn't
* cached. The interop runner's keyupdate testcase requires the
* client to send packets in phase 1 without this method we'd
* only echo peer-initiated rotations and the test fails with
* "Expected to see packets sent with key phase 1 from both client
* and server".
*/
fun initiateKeyUpdate(): Boolean {
val cs = appCipherSuite.takeIf { it != 0 } ?: return false
val curRx = appReceiveSecret ?: return false
val curTx = appSendSecret ?: return false
val liveRx = application.receiveProtection ?: return false
val liveSend = application.sendProtection ?: return false
// Derive next-phase secrets and protections for both directions
// up front. We MUST roll both sides because the peer responds in
// the new phase — if our receive state is still at the old phase
// when their response lands, the receive-side commit path will
// re-derive the SAME keys we just installed (idempotent but
// wasteful) and then promote them, ending up with our previous
// receive keys orphaned in [previousReceiveProtection].
val nextRxSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curRx, "quic ku", ByteArray(0), curRx.size)
val nextTxSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curTx, "quic ku", ByteArray(0), curTx.size)
val freshRx = packetProtectionFromSecret(cs, nextRxSecret)
val freshTx = packetProtectionFromSecret(cs, nextTxSecret)
previousReceiveProtection = liveRx
application.receiveProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshRx.aead,
key = freshRx.key,
iv = freshRx.iv,
hp = liveRx.hp,
hpKey = liveRx.hpKey,
)
application.sendProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshTx.aead,
key = freshTx.key,
iv = freshTx.iv,
hp = liveSend.hp,
hpKey = liveSend.hpKey,
)
appReceiveSecret = nextRxSecret
appSendSecret = nextTxSecret
currentReceiveKeyPhase = !currentReceiveKeyPhase
currentSendKeyPhase = !currentSendKeyPhase
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
return true
}
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */ /** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
internal fun streamsLocked(): Map<Long, QuicStream> = streams internal fun streamsLocked(): Map<Long, QuicStream> = streams