quinn is now treated as a flawless-required interop target alongside
aioquic, picoquic, and quic-go. Most Rust-based Nostr/MoQ relays our
users run their servers on are built on quinn, so an interop regression
there is a user-visible regression.
Validated by a 3-round flakiness sweep (1 full matrix + 2 audio-critical
subsets) — zero result flakiness across 528 test executions across all
four peers, with two environmental docker-compose stall classes documented
separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an in-process local relay to Amethyst Desktop that persists all
received events to a per-account SQLite database using quartz's existing
EventStore infrastructure. On startup, the local store hydrates
DesktopLocalCache for instant feed rendering before remote relays connect.
- LocalRelayStore: manages per-account EventStore lifecycle, batched
write-through via BasicBundledInsert (250ms window), startup hydration
(contact list -> metadata -> recent content events)
- LocalRelayMaintenance: periodic cleanup (NIP-40 expiration, 30-day
prune, weekly VACUUM), disk space monitoring
- Settings UI: integrated into RelaySettingsScreen with statistics,
storage management (prune/vacuum/clear), JSONL export/import, and
error display
- OfflineBanner: animated banner in both SinglePaneLayout and
DeckColumnContainer showing offline status with local cache indicator
- Thread-safe store access via @Volatile + synchronized lock
- Skips re-enqueue during hydration (checks LOCAL_RELAY_URL)
- DB stored at ~/.amethyst/accounts/<pubkey8>/events.db
- Corrupt DB auto-detected and recreated on open
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cleaner conceptual model: the parsed state object is the place where
all derived facts about the content live, so `isMarkdown` becomes a
field on `RichTextViewerState` (populated by `RichTextParser.parseText`
using the same cheap heuristic).
`RichTextViewer` now calls `CachedRichTextParser.parseText` once at the
top and dispatches on `state.isMarkdown` instead of running a separate
scan. `CachedRichTextParser.isMarkdown(content)` and its dedicated
`isMarkdownCache` go away — the single `richTextCache` carries the
decision alongside the parsed segments.
Inner callers (`DisplaySecretEmoji`, `MultiSetCompose` reaction
preview, `DisplayUncitedHashtags`) are unaffected: they continue to
receive a fully-parsed state with all segments populated.
The markdown check is purely a function of `content`, but it was a
top-level function in RichTextViewer.kt — every distinct instance of
the same content (e.g. a quoted note rendered inside its quoter,
or the same viral note shown multiple places in a feed) re-scanned
the string.
Move the decision into `CachedRichTextParser` with its own small
LRU keyed on content.hashCode(). Composables still wrap the call in
`remember(content)` so recompositions skip even the LRU lookup.
`RelayBadges` runs once per note in complete-UI mode. The closed view
was opening three separate `relays.stateFlow` subscriptions (one per
icon slot, each with its own `mapNotNull`), and both the closed and
expanded views collected the relay list raw — every relay arrival on
a fanned-out note triggered an immediate recomposition.
Collapse to a single subscription that emits `relays.take(3)`,
throttled with `sample(500)` and `distinctUntilChanged`. Slot widgets
now pull from the resulting list instead of each owning a flow.
Same treatment for the expanded `RenderAllRelayList` (now sampled)
and `ShouldShowExpandButton` (wraps the cached
`createMustShowExpandButtonFlows` lookup in `remember(note)` so the
LRU is hit once per note instead of per recomposition).
Three targeted fixes for the jitter that shows up as soon as a feed
contains nested NoteCompose (reposts, quotes, BechLink previews):
* `calculateBackgroundColor`: only schedule the 5s "new item" fade
LaunchedEffect when the item is actually new and tracks read state.
Inner notes pass `routeForLastRead = null` and were parking a
coroutine for 5s per item, every scroll. Also drop a per-call
`Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`.
* `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow`
lookups in `remember(note)` so each recomposition of the same note
doesn't re-resolve `note.flow().…stateFlow` (and, in
`WatchBlockAndReport`, hit the synchronized LRU lookup) on every
pass. Affects observeNote / Replies / Reactions / Zaps / Reposts /
Ots / Edits and the per-item hidden-flow check.
* `produceCachedState` / `produceCachedStateAsync`: short-circuit on
cache hits. The previous `produceState` body always launched a
coroutine even when the LRU already had the value; this is the
common path for BechLink previews and draft notes during scroll.
Now we read the cache synchronously inside `remember`, and only
launch a `LaunchedEffect` for the actual miss.
No behavior change.
refactor(discover): restore exhaustive when in DiscoverTab.toTabIndex
refactor(discover): collapse DiscoverTab.toTabIndex to ordinal and drop redundant pager guards
processBitmap silently swallowed any exception from bitmap.toBlurhash()
or bitmap.toThumbhash() via runCatching{...}.getOrNull(), making it
impossible to diagnose why a video upload's published imeta had blurhash
but no thumbhash (or neither). Add an .onFailure { Log.w(...) } to each
runCatching so the actual exception class + stack reach logcat.
When the user long-pressed a playing video and tapped "Add Media to
Gallery", the share-action overlay (RenderTopButtons.kt:302) hard-coded
blurhash/dim/hash to null and built an inline MediaUrlVideo without
those fields. Result: the published ProfileGalleryEntryEvent (kind 1163)
carried only the URL, alt, e, m, and client tags — no x/dim/blurhash/
thumbhash — even when the source kind:1 imeta carried them.
Upstream commit df6103ffdd ("truthful ECN reporting") replaced
`AckEcnCounts(0, 0, 0)` on 1-RTT ACK frames with
`ecnCounts = null`, on the rationale that hardcoded zero counts
were "lying" while we marked outbound ECT(0) but didn't read
inbound TOS.
That broke the interop runner's `ecn` testcase against quinn:
the runner's `_check_ack_ecn` requires at least one ACK_ECN
frame in the trace (`hasattr(p["quic"], "ack.ect0_count")`),
and explicitly says "we only check whether the trace contains
any ACK-ECN information, not whether it is valid". Without
the field present the runner reports "Client did not send any
ACK-ECN frames" and fails. 0/3 against quinn (was 19/22).
Re-emit `AckEcnCounts(0, 0, 0)` with corrected reasoning:
- We mark outbound packets ECT(0) (peers' tracking benefits).
- We don't read inbound TOS (JDK DatagramChannel needs JNI for
`IP_RECVTOS`, deferred). So we observe ZERO ECT-marked
inbound packets.
- Reporting 0 counts IS accurate to that observation. RFC 9000
§13.4.2 doesn't penalize a path that never sees marks; it
only requires the count be a "cumulative count of received
QUIC packets that were marked with the corresponding ECN
codepoint" — which is exactly 0 under our limitation.
The Initial / Handshake-space ACKs stay plain (RFC 9000 §13.4
forbids ECN on long headers); only application-space gets the
ECN counts.
Verified end-to-end:
- quinn ecn: 3/3 (was 0/3 post-rebase, 1/1 pre-rebase).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC 9000 §5.1.1 + §19.15: a client SHOULD advertise spare source
CIDs to the peer via NEW_CONNECTION_ID frames so the peer has
DCIDs available for path migration / NAT rebind. Strict server
stacks (quic-go, picoquic, msquic, mvfst) refuse to validate a new
client path until they have a spare DCID — server log shows
"skipping validation of new path … since no connection ID is
available". Quinn migrates on src-port-change alone, which is why
rebind-port / rebind-addr passed against quinn pre-fix and failed
against the others.
Adds:
- IssuedSourceConnectionIdEntry (data class) — sequence + CID +
stateless-reset token tuple.
- QuicConnection.issuedSourceConnectionIds (LinkedHashMap) — pool
of currently-active issued SCIDs, seeded with seq=0 (the
initial CID, no token because the stateless_reset_token TP is
server-only per RFC 9000 §18.2).
- QuicConnection.issueOwnConnectionIdsLocked(count) — drives the
initial issuance once handshake is confirmed; appends recovery
tokens to pendingOwnNewConnectionIdEmits for the writer to emit.
- QuicConnection.issueOwnReplacementSourceCidLocked() — top-up
after each peer RETIRE_CONNECTION_ID so the pool stays at
full capacity for repeated migrations.
- QuicConnectionWriter — once handshakeConfirmed flips, calls
issueOwnConnectionIdsLocked with `peer.activeConnectionIdLimit
- 1` (cap 7); drains pendingOwnNewConnectionIdEmits as fresh
NEW_CONNECTION_ID frames; the existing pendingNewConnectionId
map continues to handle loss-recovery retransmits.
- applyPeerRetireConnectionIdLocked — three cases: in-pool seq
is freed and replaced; below-next-seq missing seq is benign
retransmit; above-next-seq is PROTOCOL_VIOLATION (peer
retiring something we never advertised).
Verified against the live interop runner:
- quic-go rebind-port: 2/2 (was 0/N — Task 2). Now passes in ~64s.
- quic-go rebind-addr: 2/2 (was 0/N). Now passes in ~125-138s.
- picoquic rebind-port: 2/2.
- picoquic rebind-addr: 1/2 (flaky — separate investigation;
one run the connection migrates correctly, the other times
out at 110s; not blocking the rebind-port / quic-go win).
- picoquic connectionmigration: 0/2 still failing (the runner's
"active migration" testcase exercises a more sophisticated
migration shape — TBD; tracked separately).
Tests: IssuedSourceConnectionIdTest (5 cases pinning the
post-handshake emission, peer-RETIRE replacement, retransmit
tolerance, and protocol-violation closure for above-next-seq retires).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC 9002 §6.2.2 says the consecutive-PTO count goes up by exactly
ONE per PTO timer expiry, so the §6.2.1 backoff is `pto_base * 2^count`
per firing. Pre-fix, the count incremented THREE times per firing:
1. inside `handlePtoFired` itself,
2. inside `requeueInflightForProbe` (which `handlePtoFired` calls),
3. again from the send loop's between-probe re-requeue (RFC §6.2.4
two-packet probe budget calls `requeueInflightForProbe` a
second time).
That made the effective backoff `2^(3*N)` per firing — 1×, 8×, 64×
of pto_base. With pto_base ≈ 150 ms post-handshake, PTO #3 didn't
fire until ~11 s post-handshake, well past the 5 s amp-limited
idle timeout strict servers (quic-go) enforce.
Quic-go's `amplificationlimit` testcase exposed this. The sim
drops client packets 2–7. Our PTO #2 second probe is packet #8
— the first one that gets through. With correct 1× increment
per PTO, packet #8 reaches the server in ~900 ms; pre-fix it
arrived at ~12 s, after quic-go had already destroyed the
connection ("Amplification window limited" → "Destroying
connection: timeout: no recent network activity"). Same root
cause for `handshakeloss` flakiness against quic-go (multiconnect
under 30% loss can't recover within the 30 s per-iteration
budget when PTO ramps to 64× by iteration 3).
Fix:
- Move the count increment to the top of `handlePtoFired`,
before it calls `requeueInflightForProbe`. The threshold
check inside the latter (RFC 9000 §9 — trigger
PATH_PROBE_PTO_THRESHOLD migration) reads the
post-increment value, preserving the prior 2-PTO-firing
trigger semantics.
- Remove the count increment from `requeueInflightForProbe`.
The send loop's between-probe re-requeue stays a no-op for
the count (same PTO firing).
Tests: PtoCryptoRetransmitTest.consecutivePtoCountAdvancesByOnePerPtoFiringNotPerProbePacket
pins the contract (full handlePtoFired → drain → re-requeue →
drain → handlePtoFired sequence; asserts count after each step).
Verified end-to-end against the live interop runner:
- amplificationlimit vs quic-go: 5/5 (was 0/3 — consistent
30 s timeout). Now passes in ~7 s.
- handshakeloss vs quic-go: 5/5 (was 2/3 — flaky multiconnect
iteration timeouts). Now consistently ~30 s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-fix, [QuicConnection.initiateKeyUpdate] only checked that 1-RTT
keys were installed — which fires when the TLS handshake derives
Finished, well before HANDSHAKE_DONE arrives. RFC 9001 §6.1 + §4.1.2
require the CLIENT to consider the handshake confirmed (HANDSHAKE_DONE
received) before rolling KEY_PHASE; quinn / quic-go / picoquic
correctly close the connection with PROTOCOL_VIOLATION ("illegal
packet: key update error") when an early update arrives.
The interop runner's keyupdate testcase against quinn flushed this:
the client polled `status == CONNECTED` (which flips on TLS Finished
via `onHandshakeComplete`) and called `initiateKeyUpdate()` before
HANDSHAKE_DONE landed. ~33% repro rate; the rest of the runs HANDSHAKE_DONE
happened to arrive in the gap between the status check and the
update.
Fix:
- Add `QuicConnection.handshakeConfirmed: Boolean` flipped only by
the parser when a HANDSHAKE_DONE frame is processed.
- Add `awaitHandshakeConfirmed()` for callers that need the
spec-confirmed state.
- Gate `initiateKeyUpdate()` on `handshakeConfirmed` (returns
false otherwise — same shape as the existing app-keys-not-yet
branch).
- Tighten `triggerPathMigrationLocked` to also gate on
`handshakeConfirmed` (RFC 9000 §9.1: same confirmation
requirement). Pre-fix it gated on `handshakeComplete`, which
flips at TLS-Finished too.
- InteropClient's keyupdate flow now waits on
`awaitHandshakeConfirmed` (with a 2s upper bound) rather than
polling `status == CONNECTED`.
The test fixture in ConnectedClientFixture.newConnectedClient now
delivers HANDSHAKE_DONE by default — most tests want the
production "fully ready" state. New `deliverHandshakeDone = false`
parameter for tests that need to exercise the pre-confirmation
window (KeyUpdateClientInitiatedTest).
Tests:
- KeyUpdateClientInitiatedTest:
initiateKeyUpdateBeforeHandshakeDoneIsRejectedAndDoesNotRotateKeys
initiateKeyUpdateAfterHandshakeDoneRotatesBothDirections
awaitHandshakeConfirmedSuspendsUntilHandshakeDone
triggerPathMigrationBeforeHandshakeDoneReturnsNotConnected
Verified against the live interop runner — 5/5 against quinn,
3/3 against quic-go, 3/3 against picoquic (pre-fix: ~33% pass rate
against quinn).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>