Pre-fix QpackHuffman.decode allocated two boxed Integers per output
character: one for the `candidate` Int passed into
`HashMap<Int, Int>.get` and one for the wrapper-Integer return value.
Output also went through `ArrayList<Byte>`, boxing every emitted
byte as a `java.lang.Byte` (~16 bytes per output byte on a 64-bit
JVM). On a typical HTTP/3 response with ~30 header values, that's
hundreds of throwaway wrapper objects per request — pure GC churn
on the hot path.
The new layout keeps two parallel `IntArray`s per code-length:
codes[len] (sorted ascending) and syms[len] (the matching symbol
indices). Lookup is a primitive `IntArray.binarySearch(candidate)`
— no boxing, the array stays in JIT-friendly contiguous memory,
and the per-length arrays are tiny (a few entries each, since the
Huffman table is sparse at any given length).
Output uses a growable `ByteArray` with a manual position index
rather than `ArrayList<Byte>`. Pre-grow to 2× input size as a
rough upper bound — ASCII headers compress to ~62% with HPACK
Huffman, so we rarely need to grow.
Also fixes a latent bug: the new init loop covers lengths 5..30
(previously 5..29), restoring decoding for symbols 10 (LF), 13
(CR), 22 (DC2) which all use 30-bit codes per RFC 7541 Appendix B.
Pre-rewrite the HashMap path included these via `for (sym in 0..255)`
walking the full symbol table; the IntArray rewrite needed an
explicit length range and accidentally cut at 29. Added a unit
test exercising hand-encoded length-30 inputs to lock the fix in.
Behavior verified against RFC 7541 Appendix C test vectors and the
new length-30 round-trip. All 269 :quic:jvmTest tests pass.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
When a Namecoin record's value isn't valid JSON (a real failure mode
when an operator hand-builds the value and miscounts braces), the
NIP-05 path used to silently swallow the parser exception and surface
a misleading "no nostr field" message. That sends the publisher
chasing a phantom missing field when the actual problem is the value
itself.
Concrete case that triggered this: a `name_update` published a
474-byte d/testls value with one closing brace short of balanced. The
string parses up to the missing brace, after which kotlinx.serialization
throws "Unfinished JSON term at EOF at line 1, column 474". That error
was previously dropped, leaving the operator to debug "no nostr field"
without ever seeing the underlying JSON parse failure.
Changes:
- New NamecoinResolveOutcome.MalformedRecord(name, error). Distinct
from NoNostrField. The `error` field is the parser's own diagnostic
(e.g. "Unfinished JSON term at EOF at line 1, column 474") so the
publisher can locate the broken byte without spelunking.
- NamecoinNameResolver.performLookupDetailed: parse via a new
parseValueOrError helper and surface MalformedRecord instead of
collapsing into NoNostrField. Also rejects non-object top-level
values (arrays, primitives, null) with a useful diagnostic
("top-level value is JsonArray, expected JSON object").
- DesktopSearchScreen handles the new outcome by surfacing the parser
error verbatim in the Namecoin status banner, so the column number
reaches the publisher's screen.
Tests (commonTest / NamecoinImportTest):
- "NIP-05 lookup surfaces MalformedRecord with parser detail when
value is broken JSON": a deliberately one-brace-short value yields
MalformedRecord with a non-empty diagnostic.
- "NIP-05 lookup surfaces MalformedRecord when top-level value is a
JSON array": ensures non-object top-level values are rejected with
a useful "expected JSON object" message rather than silently
parsing as something unusable.
Tests don't pin the exact parser wording (kotlinx.serialization can
change it across versions); they only pin that the message is
attributed to JSON parsing rather than to a missing field.
Round 5 of the audit follow-ups. The remaining low-leverage items
from the original audit all addressed in one pass.
* PathValidator: pick the smallest spare CID sequence number rather
than LinkedHashMap insertion order. RFC 9000 §19.15 lets the peer
issue NEW_CONNECTION_ID out of sequence (e.g. retransmits arriving
after newer offers); insertion-order picking would land on
whichever offer arrived first instead of the lowest seq, drifting
away from the RFC-expected ordering. forceRotateToHigherSequence
also now filters >= retirePriorToWatermark explicitly so we never
pick a sequence below the watermark even if the pool somehow holds
one.
* QuicWebTransportSessionState.close: driver.wakeup() AFTER enqueuing
the WT_CLOSE_SESSION capsule + FIN but BEFORE driver.close, so the
capsule actually reaches the wire instead of being short-circuited
by the driver shutdown. Pre-fix the peer saw an abrupt UDP-level
tear-down with no application-error-code surfaced.
* QuicStream.resetStream / stopSending: synchronized(this) atomic
CAS for the "first call wins" gate. Pre-fix two concurrent callers
could both observe `resetState == null` and both write — the
second caller's errorCode would clobber the first while
resetEmitPending was already set, so the writer emitted the
RESET_STREAM with whichever value landed last.
* Http3Settings.decodeBody: per-id value range checks. A peer that
advertises e.g. MAX_FIELD_SECTION_SIZE = 2^60 could otherwise
drive our encoder into unbounded heap. Bounds chosen above any
legitimate value (1 GiB for table-capacity / field-section caps,
1 for boolean flags) and below 2^32 for unknown ids.
* Privatize crypto-relevant static byte arrays:
InitialSecrets.V1_INITIAL_SALT, RetryPacket.V1_RETRY_KEY,
RetryPacket.V1_RETRY_NONCE. Pre-fix these were public mutable
ByteArrays — any caller could stomp on them, and any toString /
reflection would leak the bytes. Crypto material doesn't need to
be reachable outside the deriving / sealing path.
* peekHeader length cast: re-verified as already safe (lengthRaw is
bounded by r.remaining before .toInt() — Int.MAX_VALUE ceiling
enforced).
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 2m 17s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 4 of the audit follow-ups.
* Reserved-bit enforcement on unmasked QUIC headers (RFC 9000 §17.2 /
§17.3.1). Pre-fix the parser silently accepted long-header packets
with bits 0x0C set or short-header packets with bits 0x18 set after
HP unmasking — the spec mandates PROTOCOL_VIOLATION close. Added
[QuicProtocolViolationException] and a top-level catch in
feedDatagram that translates the throw into markClosedExternally.
Long-header parse also drops a now-dead `if form==1` branch on the
first-byte mask: we already early-returned in non-long paths above,
so the mask is always 0x0F.
* TLS handshake-message bounds:
- TlsCertificateChain.decodeBody: reject `listLen > r.remaining`
up front; assert `r.position == end` after the cert loop.
Without this, a malicious peer could push us into reading past
the message limit on per-cert extensions.
- TlsServerHello.decodeBody / TlsEncryptedExtensions.decodeBody:
reject trailing bytes after the extensions block.
- TlsEncryptedExtensions.alpn: enforce RFC 7301 §3.1 (server
returns EXACTLY one protocol_name); validate outerLen matches
remaining and reject multi-name responses.
* SendBuffer.data shrink: pre-fix the doubling-on-grow buffer never
shrank, so a stream that ever held N bytes pinned `data.size = N`
for the connection's lifetime. Long-tail memory retention on
per-stream basis. advanceFlushedFloorIfPossible now releases
capacity once live bytes occupy ≤ 1/4 of the allocation, shrinking
to max(SHRINK_FLOOR_BYTES=4096, 2*dataLen). Below the floor the
doubling cost is negligible; above it the multi-MiB transients
release back to the heap.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 3 of the audit follow-ups.
* Http3FrameReader gains a [StreamContext] parameter that enforces
RFC 9114 §7.2 per-stream rules:
- CONTROL: first frame MUST be SETTINGS (else H3_MISSING_SETTINGS);
duplicate SETTINGS, DATA, HEADERS, PUSH_PROMISE all forbidden.
- REQUEST: SETTINGS / GOAWAY / MAX_PUSH_ID / CANCEL_PUSH forbidden.
- PUSH: similar set including PUSH_PROMISE.
- Reserved types 0x02 / 0x06 / 0x08 / 0x09 explicitly rejected.
- WT_BIDI_DATA / WT_UNI_DATA: reader is the wrong tool, throw.
- UNCHECKED preserves prior test behaviour and is the default.
WtPeerStreamDemux's CONTROL drain now constructs the reader with
StreamContext.CONTROL, so a buggy server can no longer slip a DATA
frame into our SETTINGS expectations and silently confuse the
parser. The validation throws QuicCodecException, which the
drainControlStream catch records on a new peerH3ProtocolError
field — the QUIC layer / application reads it to close with the
proper diagnostic instead of having the route() catch swallow it.
* ReceiveBuffer no longer coalesces overlapping segments on insert.
Pre-fix every reorder fill allocated a fresh merged ByteArray of
size (hi - lo) and copyInto'd each existing segment — under a 200-
chunk reorder burst that was O(N²) bytes. The new layout keeps
segments as a sorted, non-overlapping list (binary-searched on
insert) and only allocates at readContiguous time, where it walks
consecutive segments and concats them in a single pass. Adjacent
segments are not eagerly merged — the read-side concat is bounded
by the contiguous prefix the consumer is about to drain anyway.
bufferedAhead becomes O(1) (cached counter) instead of O(N) sum.
* New tests cover the per-context rejection paths (CONTROL-stream
first-frame check, DATA-on-control, SETTINGS-on-request, all four
reserved frame types).
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 2 of the audit follow-ups. Each item caps a peer-controlled
allocation that pre-fix could be inflated to hold gigabytes of heap
or pin a CPU core indefinitely.
* Http3FrameReader: cap pending unparsed buffer (1 MiB) and per-frame
body length (16 MiB). A peer streaming a partial-frame prefix
without ever delivering the body now raises QuicCodecException
instead of growing buf indefinitely.
* CapsuleReader: cap pending buffer (1 MiB) and per-capsule body
(64 KiB). Symmetric encoder-side check on WT_CLOSE_SESSION reason
size, matching the existing decoder cap.
* WtPeerStreamDemux: replace Channel.UNLIMITED with bounded channels
+ suspending sends. readyStreams now caps queued peer-initiated
streams at 1024; per-stream chunkChannel caps at 64 chunks. The
collector's suspending send naturally back-pressures via QUIC flow
control when the application is slow, rather than pinning heap.
* WtDatagram.decode: validate quarter-id is in [0, (2^62-1)/4] so
`r.value * 4` cannot overflow Long and wrap into a small signed
value matching our session id (cross-session datagram injection).
* QuicReader.readBytes / skip: translate negative-count into typed
QuicCodecException instead of letting IllegalArgumentException
escape from copyOfRange.
* AckTracker: cap stored disjoint ranges at 64. A peer that sends
alternating-bit-pattern PNs can no longer grow our ACK frame past
what fits in a packet; oldest range evicts on overflow.
* JcaAesGcmAead: track recent encrypt nonces (8) instead of just the
most-recent, so a single intermediate seal between two rebuilds
can't mask a duplicate against the second-most-recent. Drop the
remembered nonce on doFinal failure so a retry takes the safe
fresh-Cipher path. Add synchronized() defence-in-depth.
Each cap has a generous default (above any legitimate use) but
finite. Tests use no-arg construction; existing call sites unaffected.
All 269 :quic:jvmTest tests pass.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Verified and applied 12 focused fixes from a four-agent review of the
quic module. Each fix verified against the actual code; agent
findings that traced to false positives (pendingPing clear-without-emit,
sentPackets on encrypt failure) are documented in the review thread
but not changed.
Concurrency / flakiness:
- PTO consecutive count: double-increment removed; now incremented
exactly once per PTO event in handlePtoFired before requeue, so the
threshold check inside requeueInflightForProbe sees the post-
increment value AND the between-probe re-requeue doesn't bump again.
- Wallclock → monotonic: QuicConnection.nowMillis defaults to
TimeSource.Monotonic anchored at construction, so NTP step /
suspend-resume can't poison RTT samples. Driver uses
connection.nowMillis instead of carrying its own clock.
- Close-state race: atomic CAS via closeStateMonitor in close() and
markClosedExternally so concurrent teardown paths can't both fire
qlog "connection closed" and stomp on closeReason.
- streamsList CME: converted to @Volatile var List<QuicStream> with
immutable-snapshot publishing under streamsLock. closeAllSignals'
iteration is now CME-free without holding the (suspending) lock.
- JcaAesGcmAead: synchronized seal/open; multi-entry recent-nonce
history (was single most-recent — could mask a duplicate against
the second-most-recent under intermediate seals); on seal failure
evict the cached nonce so a retry with the same nonce takes the
safe fresh-Cipher path.
Wire correctness / spec:
- Connection-level send credit no longer debited on retransmits
(added Chunk.isRetransmit; writer skips sendConnectionFlowConsumed
+= data.size when set). Pre-fix a few PTO rounds on a long stream
exhausted credit and stalled the connection.
- ACK-delay shl overflow: clamp ackDelayExponent to 0..20 and
clamp the peer's varint to (Long.MAX_VALUE >>> exponent) before
shift; clamp negative now-vs-recv-time before shift on outbound
AckTracker.
- Key-update commit only when new-phase packet PN exceeds
largestReceived (RFC 9001 §6.1).
- RESET_STREAM final-size validation: enforce equality with prior
FIN size and ≥ highestObservedOffset; close FINAL_SIZE_ERROR
otherwise.
- STOP_SENDING handling: respond with RESET_STREAM on the local
send side (was silently dropped, peer's flow-credit wasted).
- ReceiveBuffer.insert: typed InsertResult; second FIN with
conflicting size and offset-past-FIN data both surface
FINAL_SIZE_ERROR via the parser instead of being silently dropped.
- Retry SCID==DCID self-loop: reject Retry where the peer's SCID
equals our original DCID (RFC 9000 §17.2.5.2).
DoS hardening:
- ACK PN walk: drainAckedSentPackets rewritten to scan in-flight
keys against parsed ranges instead of walking every PN. A peer
with firstAckRange = 2^62-1 used to pin a core forever; now
bounded by the sent-packets map size.
- UDP socket: channel.connect(remote) after bind so the kernel
filters off-path datagrams. Stops trivial source spoofing from
burning AEAD attempts.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
When the comment composer is replying into a NIP-72 community
(`replyingTo.event is CommunityDefinitionEvent`), subscribe to the
community's latest `kind:34551` rules document, run
`CommunityRulesValidator.validate(...)` on every draft change, render an
inline banner above the bottom action row when the draft would be
rejected, and disable the post button until the violation is resolved.
- `CommunityRulesFilterSubAssembler` is added to the existing
`CommunityFilterAssembler.group` so any screen that already mounts
`CommunityFilterAssemblerSubscription` (community feed, this composer)
also pulls the latest rules event from the community's relays. Filter
is `kinds=[34551]`, `authors=<owner+moderators>`, `#a=<addressTag>`,
matching the NIP-72 trust model.
- `CommentPostViewModel` observes `kind:34551` via
`LocalCache.observeEvents` keyed on the reply target, picks the latest
`created_at` matching the community address, and re-runs the validator
on every draft change. The validator's `postsTodayByKind` and `wot`
callbacks are intentionally null for this PR (per-day quota lookups
and NIP-02 follow-graph traversal are deferred to follow-ups; the
validator skips those checks cleanly).
- Draft size is conservatively estimated from `content.toByteArray(UTF-8)`
— tags add bytes, so this can under-count on the boundary, but relays
still enforce the real cap. Good enough for a pre-send preview.
- `CommunityRulesViolationBanner` renders the first
`CommunityRulesValidator.Violation` with a localized message; new
strings cover all 7 sealed-violation variants.
- `CommentPostViewModelTest` covers valid drafts, kind-not-allowed,
oversize, denied-author, the under-size boundary, and multibyte UTF-8
size accounting.
Compose state — not StateFlow — backs `validationResult` and
`communityRules` so `canPost()` and the banner recompose without an
explicit `collectAsState` site at the top bar (`isActive` reads
`validationResult` directly).
Refs nostr-protocol/nips#2331
Closes#2759
Surface a soft "this content's relay may be stale" UX cue on addressable
replaceable events (kind:30xxx) when every delivering relay's most recent
NIP-66 kind:30166 Relay Discovery monitor report cached locally is older
than 14 days (or has never been observed).
Read-only: uses only what's already in LocalCache, populated by the
existing RelayInfoNip66FilterSubAssembler. No new network fetches.
Implementation:
- New `StaleRelayHint` composable in `ui/note/elements/`, hooked into
`NoteBody` after the zap-splits row. Skips quietly for non-addressable
events, empty relay sets, or any relay still observed within 14 days.
- Pure `isStaleByLatestMonitorReports(latestPerRelay, now, threshold)`
predicate — `null` (never monitored) and "older than cutoff" both count
as stale, but a single fresh relay short-circuits the hint to off.
- Reactively tracks the note's relay set via
`baseNote.flow().relays.stateFlow` so newly-observed relays update the
hint without recomposition tricks.
- Latest monitor `created_at` per relay is read from `LocalCache` with
the same `Filter(kinds=[30166], #d=[relay.url], limit=1)` shape used
on the Relay Information screen.
Out of scope (per issue):
- Auto-refreshing or hiding stale content.
- Heuristics beyond the "all delivering relays stale" check.
Tests: 9 unit tests for the pure predicate covering empty/single/mixed
sets, null-handling, exact-cutoff boundary, and a custom threshold.
Closes#2762.
Single-packet probes need 6 PTO doublings (~19s) to land one datagram
through the `amplificationlimit` interop scenario's 6-drop window.
quic-go and msquic kill the connection at ~10s of silence regardless
of our handshake-timeout budget, so we never recovered against them
(diagnosed in the parent investigation; the 10s→30s timeout bump in
0a892b0d4b only fixed picoquic).
RFC 9002 §6.2.4 allows up to 2 ack-eliciting packets per PTO. Adding
the second probe halves recovery to ~3 PTO rounds (~5s) and lands
within strict server tolerances.
Wiring:
- New `QuicConnection.pendingProbePackets`, set to 2 by handlePtoFired.
- Extracted `requeueInflightForProbe` from handlePtoFired so the send
loop can re-requeue inflight CRYPTO / STREAM bytes between probes.
- Send loop decrements the budget after each probe-bearing send; if
the budget is still positive, re-requeues AND re-arms `pendingPing`
so the no-data fallback (post-handshake idle) still emits a second
PING. Without the `pendingPing` re-arm, only the first probe fires
when CRYPTO is fully ACK'd — `pendingPing` is one-shot in
collectHandshakeLevelFrames.
Verified end-to-end:
- amplificationlimit: ✕→✓ vs quic-go (35s→7s); ✓ no-regression vs
picoquic (19s→7s) and quinn (15s→7s); msquic now reports server-
side UNSUPPORTED (was failing). Recovery times across the board
drop ~3x because handshake-loss recovery is ~3 PTO rounds instead
of ~6.
- handshake / transfer / multiplexing / handshakeloss all green vs
quic-go, quinn, picoquic, msquic — no regression on the core matrix.
Tests:
- New `ptoEmitsTwoProbePacketsPerRfc9002` in PtoCryptoRetransmitTest
invokes the EXACT helpers the send loop uses (handlePtoFired then
requeueInflightForProbe between drains) and asserts two distinct
Initial datagrams with the same CRYPTO bytes at offset 0 on
distinct PNs. Verified the test fails when budget is reverted to 1.
- Existing PTO + recovery tests stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The amplificationlimit testcase scenario drops client→server packets
2–7. Recovering past 6 consecutive drops via RFC 9000 PTO doublings
(0.3+0.6+1.2+2.4+4.8+9.6 ≈ 19s) is more than the previous 10s
budget allowed — we declared handshake_failed mid-recovery. Bumping
to 30s matches the multiconnect handshake budget and gives clean
PTO headroom.
Fixes: amplificationlimit ✕→✓ vs picoquic. No regression vs quinn
(was already passing at ~14s). Normal handshakes complete in <1s
so the bump is invisible outside lossy paths.
Still fails: amplificationlimit vs quic-go and msquic. Their
server-side handshake-progress watchdog gives up at ~10s of silence
regardless of our budget. The proper fix is RFC 9002 §6.2.4 — send
2 ack-eliciting packets per PTO probe instead of 1, halving the
recovery time for consecutive drops. That's a writer-side change,
deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-handshake status check uses lifecycleLock (status is guarded
by lifecycleLock per the lock-split refactor); the malformed-datagram
test uses streamsLock since feedDatagram requires streamsLock.
Three correctness bugs surfaced by post-fix re-audit, plus minor
cleanups.
- Bug A (validation hang): checkPathValidationTimeoutLocked was
only called from handlePtoFired. PATH_CHALLENGE is ack-eliciting
so the peer ACKs it; that ACK resets consecutivePtoCount, which
means the PTO timer that used to host the budget check stops
firing. Validation could hang indefinitely on a peer that ACKs
but doesn't reply with PATH_RESPONSE. Fix: drive the budget
check from drainOutbound (every send-loop wake).
- Bug B (stale retire): under abrupt-migration semantics the
prior CID is abandoned the moment we rotate. Queuing the retire
only inside applyPathResponse meant two consecutive failed
validations would leave the original seq=0 unretired forever.
Fix: queue priorSeq in tryStartValidation; advance
activeCidSequence at trigger time so it tracks the on-wire DCID.
- Bug C (spec MUST violation): RFC 9000 §5.1.2 requires server-
forced retirement of the active CID when the peer's
retire_prior_to advances past it. Previously the parser silently
accepted the offer and we kept stamping a now-retired CID.
Fix: new PathValidator.forceRotateToHigherSequence; called from
applyPeerNewConnectionIdLocked after a successful Stored result.
No PATH_CHALLENGE needed (same path, just different CID).
Closes connection with CONNECTION_ID_LIMIT_ERROR if the pool
is empty when forced rotation is needed.
Concurrency:
- Add @Volatile to consecutivePtoCount. The driver kdoc claimed
it already was; it wasn't. The send-loop reads it lockless for
backoff calculation while three writers mutate it (driver PTO
fire, parser ACK reset, applyPeerPathResponseLocked reset).
Cleanup:
- Drop redundant destinationConnectionId re-stamp in
applyPeerPathResponseLocked (already rotated at challenge time).
- Fix PathMigrationResult kdoc to acknowledge that NotConnected
is produced only by the connection-level wrapper.
- Update applyPeerNewConnectionIdLocked kdoc with the §5.1.2
forced-rotation contract.
Tests:
- PathValidatorTest:
+ triggerRetiresPriorSequenceImmediately (Bug B)
+ twoConsecutiveFailedValidationsRetireAllAbandonedSequences
(Bug B regression — would have caught the original miss)
+ forceRotateRunsWhenWatermarkPassesActiveCid (Bug C)
+ forceRotateNoOpWhenWatermarkBelowActive (Bug C edge)
+ forceRotateRotatesAgainWhenNewerOfferAdvancesWatermark (Bug C cascading)
- ClientPathMigrationTest:
+ newConnectionIdWithRetirePriorToPastActiveForcesRotationOnSamePath
(Bug C wire-level)
+ Updated fullMigrationRoundTrip to assert RETIRE rides in the
same packet as PATH_CHALLENGE under abrupt-migration semantics.
+ Updated pathResponseWithMismatchingPayloadKeepsValidatingAndDcid
to reflect activeCidSequence advances at trigger time.
All :quic:jvmTest (39 tests in path-validation suite) and
:nestsClient:jvmTest pass.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
Addresses seven bugs surfaced by post-landing audit of the path
validation feature.
Spec fixes (RFC 9000 §9):
- Bug 1: PATH_CHALLENGE was going out on the OLD DCID because the
writer reads conn.destinationConnectionId per packet and the
rotation only happened on PATH_RESPONSE arrival. Now rotate the
DCID inside triggerPathMigrationLocked (abrupt-migration model
appropriate for the "old path looks dead" trigger condition).
Fixes the headline feature — without this the challenge cannot
actually exercise the new path.
- Bug 2: 3 * PTO timeout dropped the failed CID without queuing a
RETIRE_CONNECTION_ID. The peer kept the routing entry forever.
checkValidationTimeout now queues the failed sequence per §5.1.2.
- Bug 3: RETIRE_CONNECTION_ID for seq 0 was silently honored. We
have no replacement SCID to give the peer (we don't issue our
own NEW_CONNECTION_ID frames), so the connection is unusable.
Close with INTERNAL_ERROR instead.
- Bug 4: triggerPathMigration had no handshake-confirmed gate;
§9.1 forbids migration before handshake confirmation. Returns
new PathMigrationResult.NotConnected when status != CONNECTED.
Implementation fixes:
- Bug 5: driver was calling Clock.System.now() directly instead
of conn.nowMillis(), breaking virtual-clock tests.
- Bug 6: PTO threshold check ran BEFORE the consecutive-PTO
counter increment, so threshold=2 actually required 3 PTOs.
Increment first; threshold semantics now match the constant.
- Bug 7: applyPeerPathResponseLocked didn't reset
consecutivePtoCount on successful validation; the next sleep
inherited a stale exponential-backoff multiplier even though
the peer just proved liveness.
Code quality:
- Rename ValidationOutcome.Validated.newConnectionIdBytes →
connectionId; PathValidationState.Validating.newCidBytes →
newConnectionId. The "Bytes" suffix was redundant.
- Drop unused PathValidator(initialActiveCidSequence) parameter.
- Drop dead coerceAtLeast(2) in pool size calculation.
- Make pendingChallenges and pendingRetireSequences internal.
- Fix stale KDoc references (activatePendingValidatedCid,
forceRetireActiveIfNeeded, "retirePriorTo decreased" — none
survived the §19.15 clamp fix).
- PathValidator.RecordResult: drop RetirePriorToRegressed
enum value (clamped, never returned).
- Surface qlogObserver.onConnectionIdRetired in both the
success and timeout paths.
Tests:
- ClientPathMigrationTest: existing fullMigrationRoundTrip
test now asserts DCID rotates AT challenge time, not on
PATH_RESPONSE.
- New retireConnectionIdForSequenceZeroClosesConnection.
- New pathResponseSuccessResetsConsecutivePtoCount.
- New triggerPathMigrationBeforeHandshakeReturnsNotConnected.
- PathValidatorTest:
validationTimeoutAfter3PtoTransitionsToFailedAndRetiresFailedCid
now asserts the failed sequence is queued for retire.
- retirePriorToRegressionIsRejected → renamed to
retirePriorToRegressionIsClampedNotRejected.
All :quic:jvmTest and :nestsClient:jvmTest pass.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
Comprehensive design doc for a headers-only Bitcoin P2P client that would
let NIP-03 OTS attestations be verified locally against the proof-of-work
chain instead of a trusted block explorer. Parked pending direction on
NIP-BC onchain-zaps verification, which has overlapping requirements.
Implements the client side of connection migration so a path that
stops receiving ACKs (NAT rebind, route flap, dead peer) can be
recovered without a fresh handshake:
1. NEW_CONNECTION_ID frames from the server are stored in a
PathValidator pool (was: parsed and dropped).
2. After PATH_PROBE_PTO_THRESHOLD consecutive PTOs, the driver
calls triggerPathMigrationLocked(); the validator picks an
unused CID and queues a PATH_CHALLENGE with a CSPRNG payload.
3. The writer drains the challenge into the next outbound 1-RTT
packet using the new DCID; a RecoveryToken.PathChallenge is
attached so loss recovery can re-queue on packet drop.
4. Inbound PATH_RESPONSE that byte-equals the outstanding payload
promotes destinationConnectionId to the new bytes and queues
RETIRE_CONNECTION_ID for the prior sequence.
5. RFC 9000 §8.2.4: validation is abandoned after 3 * PTO;
timeout transitions to PathValidationState.Failed for retry.
Spec coverage:
- §5.1.1 initial DCID is sequence 0
- §5.1.2 retire_prior_to enforcement (clamping per §19.15
reordering rule, force-retire of cached entries below
watermark)
- §8.2.2 byte-equal payload match
- §8.2.4 3 * PTO abandonment
- §19.15 frame-encoding error checks (retire_prior_to >
sequence_number, invalid CID/token length)
- §19.16 RETIRE_CONNECTION_ID frame codec + protocol-violation
close on retire of an unissued sequence
Observability: QlogObserver gains onPathValidationStarted /
Succeeded / Failed and onConnectionIdActivated / Retired hooks
for qvis sequence diagrams.
Tests: PathValidatorTest (state-machine unit) +
ClientPathMigrationTest (full round-trip through InMemoryQuicPipe:
NEW_CONNECTION_ID -> trigger -> PATH_CHALLENGE -> PATH_RESPONSE ->
DCID rotated + RETIRE_CONNECTION_ID emitted). Existing
PathValidationTest (peer-initiated PATH_CHALLENGE echo) continues
to pass unchanged.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
NIP-92 imeta tag entries are encoded as "key SPACE value" strings.
When IMetaTagBuilder.add() received an empty (or whitespace-only)
value the encoder produced "key " with a trailing space, which
schema-validating relays reject as a malformed tag value.
`ls -1dt run-*` returns both the per-run directories AND the
`.stdout.log` files run-matrix.sh tees alongside them, interleaved by
mtime. `head -n 1` could land on a `.stdout.log` regular file, after
which the rest of the script trying to walk subdirectories under it
silently produced empty output ("no <pair> dir under …"). Walk the
listing instead and pick the first entry that is actually a directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`LC_CTYPE=C tr -dc '[:alnum:]' </dev/urandom` in the upstream runner's
certs.sh trips "tr: Illegal byte sequence" on macOS — LC_CTYPE alone
doesn't override the runtime locale chain. Only the amplificationlimit
testcase exercises this loop (chain length > 1 → fakedns SAN inflation),
but if it aborts the runner stops the whole matrix BEFORE any later
test in TESTCASES_QUIC runs: handshakeloss, transferloss,
handshakecorruption, transfercorruption, ipv6, v2, rebind-port,
rebind-addr, connectionmigration, and the goodput/crosstraffic
measurements all silently never execute. The post-mortem summary
shows the 12 testcases that ran before the abort and looks deceptively
complete.
Add an idempotent `sed` step to run-matrix.sh that rewrites the line
to `LC_ALL=C tr` on every invocation, plus a plan-file note so the
next person hitting the deceptive summary doesn't repeat the
diagnosis. The patch is a working-tree edit to ../quic-interop-runner/,
not a fork; re-applied on every clone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The audit-4 #5 guard ran before the existing phantom-stream check, so an
msquic-style aggressive STREAM retransmit on a stream we'd opened and
retired (peer's loss-detector refire racing our FIN-ACK) closed the
connection with STREAM_STATE_ERROR. Observed in the parallel `transfer`
interop test where retransmits on retired streams 0/4 truncated whichever
URL was still mid-receive (5 MB → 2.2 MB).
Add `!isStreamIdRetiredLocked` to the guard so legitimate retransmits
fall through to the existing silent-drop branch. Genuine squatting on
never-opened CLIENT_* ids still closes — the existing FrameRoutingTest
case stays green because id 0 is never put into the retired ring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ifa-0001 doesn't mandate that domain records use the nostr.names
sub-dictionary. Operators who own a name outright commonly publish:
{"nostr": {"pubkey": "<hex>", "relays": ["wss://..."]}}
(the same shape id/ records use). Before this fix d/-namespace branch
only accepted {"nostr":"<hex>"} or {"nostr":{"names":{...}}},
so a record like d/mstrofnone with the single-identity object form
silently failed with NoNostrField even though id/mstrofnone resolved
fine.
Resolution rules:
1. nostr.names wins for any sub-identity.
2. Root lookups fall back to bare pubkey when names["_"] is absent.
3. Non-root lookups against names-only or single-identity records
do NOT silently use the bare pubkey.
Names whose latest on-chain transaction is still the initial registration
(OP_NAME_FIRSTUPDATE = OP_2 = 0x52) were silently dropped because the
parser only matched OP_NAME_UPDATE (OP_3 = 0x53). The scripthash index
returns the FIRSTUPDATE tx in that case, so resolution looked
'unreachable' even though every server answered.
Accept both opcodes when scanning vouts and when parsing the script.
FIRSTUPDATE pushes <name> <rand> <value>, so skip the extra <rand> push
before reading the value.
The lazy-init declarations and CompositionLocalProvider entries were lost
during the rebase, so SearchScreen always saw null services and skipped
Namecoin resolution. Add them back inside the LoggedIn branch alongside
LocalTorState.
Re-add the Namecoin results UI block that was lost during the rebase.
Renders Loading/Resolved/NotFound/Error states above bech32/people/note
results when the query is a Namecoin identifier (.bit, d/, id/).
Adapt cherry-picked PR commits to current main where:
- ElectrumxServer.trustAllCerts was renamed to usePinnedTrustStore
- IRequestListener was renamed to SubscriptionListener
Move Namecoin service initialization from App-level (eager, on every startup)
to inside the LoggedIn branch (only created when user logs in and screens
that use Namecoin are reachable). Matches the Android lazy pattern in AppModules.
Also deduplicate NamecoinSettings: Android module now uses a typealias to the
commons module version, matching the original PR intent.
Eliminates code duplication between Android and Desktop:
- Move NamecoinSettings to commons/model/nip05DnsIdentifiers/namecoin/
(with @Serializable and @Stable annotations)
- Extract NamecoinResolveState sealed class to its own file in commons
- Move NamecoinSettingsTest to commons (shared by both platforms)
- Replace Android NamecoinSettings.kt with a typealias to commons
- Update all imports in Desktop and Android modules
- Remove debug println statements from ImportFollowListDialog and Main
Root cause: decodePublicKeyAsHexOrNull() returns truncated hex for
non-bech32 input (its else branch runs Hex.decode on arbitrary strings).
The relay then rejects the filter with 'Invalid author length'.
Fixes:
- Check raw 64-char hex FIRST (before bech32 parsing)
- Only attempt bech32 decode for strings starting with npub1/nprofile1/nsec1
- Validate all resolved pubkeys are exactly 64 hex chars
- Show specific error messages per identifier type instead of generic fallback
- Improved debug logging with pubkey prefix for each resolution path
Crash monitoring:
- Added Thread.setDefaultUncaughtExceptionHandler in Main.kt
- Logs crashes to ~/.amethyst-desktop-crash.log with timestamp and full stack trace
- Also prints to stderr for Gradle console visibility
The search bar was using resolve() which lets
NamecoinLookupException.ServersUnreachable propagate as an exception,
causing 'servers unreachable' to appear immediately instead of waiting
for the actual lookup to complete.
Switch to resolveDetailed() which catches exceptions internally and
returns typed outcomes (Success/NameNotFound/NoNostrField/
ServersUnreachable/InvalidIdentifier/Timeout). The search screen now
shows Loading for the full duration of the attempt (up to 20s) and
only shows the error after all servers have actually been tried.
The dialog was created but never accessible from the UI. Now:
- Added 'Import Follow List…' menu item in File menu (Shift+Cmd+I / Shift+Ctrl+I)
- showImportFollowListDialog state threaded through App composable
- Dialog rendered alongside ComposeNoteDialog and AddColumnDialog
Add censorship-resistant NIP-05 verification using the Namecoin blockchain
to the Desktop (JVM) app, porting functionality from PRs #1734, #1771,
New files:
- DesktopNamecoinNameService: app-level service wrapping the Quartz
NamecoinNameResolver with caching, custom server support, and live
state flows. Uses plain JVM sockets (no Tor support on Desktop yet).
- DesktopNamecoinPreferences: Java Preferences API-backed persistence
for Namecoin settings (enabled toggle + custom ElectrumX servers).
- NamecoinSettings: Desktop copy of the settings data class (no Android
dependencies).
- LocalNamecoin: CompositionLocals for threading Namecoin service/prefs
through the compose tree.
- NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX
servers — toggle, active server display with DEFAULT/CUSTOM badge,
add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent
for Enter-to-submit instead of Android KeyboardActions.
- ImportFollowListDialog: Dialog for importing follow lists via npub,
hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys
with Namecoin blockchain support.
Modified:
- Main.kt: Instantiate DesktopNamecoinPreferences and
DesktopNamecoinNameService in App composable, provide via
CompositionLocals, wire into RelaySettingsScreen.
- SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the
search bar, resolve via DesktopNamecoinNameService with loading/error
states, display resolved user above standard results. Uses
LaunchedEffect with key-based cancellation for stale lookups.
- DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen
from CompositionLocal.
Tests:
- DesktopNamecoinPreferencesTest: round-trip persistence, add/remove
servers, enable/disable, reset, duplicate handling.
- NamecoinSettingsTest: server string parsing/formatting, round-trips,
edge cases, toElectrumxServers conversion.
The Quartz KMP library (commonMain + jvmAndroid) already contains the
core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver,
NamecoinLookupCache) shared across Android and Desktop.
The left navigation rail in single-pane mode renders a fixed list of
pinned screens (Home, Reads, Notifications, ...) plus a 'More' launcher
and a stack of bottom controls (relay health, bunker heartbeat, tor
status, account switcher).
Material3 `NavigationRail` lays its children out in a non-scrollable
`Column`. When the window is short — either because the OS window is
small or the user pinned several screens — the bottom items in the
list (and the 'More' button) get clipped and become unreachable.
Replace the `NavigationRail` with a `Column` that mirrors the rail's
container styling and splits content into two regions:
- A scrollable region (weight(1f) + verticalScroll) holding the pinned
screens and the 'More' launcher. Overflow now scrolls instead of
clipping.
- A fixed bottom region holding the relay health indicator, bunker
heartbeat, tor status indicator, and the account switcher. These
remain anchored at the bottom of the rail.
Item visuals are preserved by keeping `NavigationRailItem` for the
items themselves, with `NavigationRailItemDefaults.colors()`.
No behavior change when the rail content already fits the window.