- Add missing `## Commits` header so the ToC anchor resolves.
- Drop "Modules touched" and "Risk / rollback" from the PR description
checklist so CONTRIBUTING matches the PR template (PR template lost
those sections in 8ea7d1e).
- Fold "Human and AI contributions" into "Proof of testing" — the two
sections restated the same author-of-record rule.
- Remove duplicate "Sending issues over Nostr" subsection (already
covered under "Ways to contribute").
- Compress "Project layout" ascii tree into a bullet list and merge
with "Where code belongs".
- Tighten "Coding standards" (drop bullets that restated generic
don't-over-engineer guidance) and "Bounties" (single paragraph
instead of a quote block plus six bullets).
- Trim the interop "Running them" subsection to the non-obvious bits
(opt-in flags, run-matrix.sh not concurrency-safe) instead of
repeating paths from the table.
Net: 406 → 316 lines, no information lost.
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>
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>