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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+6
-10
@@ -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(
|
||||||
|
|||||||
+140
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user