Commit Graph

12870 Commits

Author SHA1 Message Date
Claude fba0a5c952 feat(quic): bestEffort streams + park CC plan indefinitely
After drafting the congestion-control plan we concluded the audio-rooms
workload doesn't actually need CC — speakers push ~8 KB/sec, which
never fills any modern link's capacity. The one real concern that
surfaced — STREAM retransmit wasting bandwidth on stale Opus frames
on lossy uplinks — is much cheaper to fix directly than to bound via
a 14-test CC subsystem.

SendBuffer gains a `bestEffort: Boolean = false` constructor flag.
When true, markLost drops the lost ranges instead of moving them to
the retransmit queue and lets the underlying byte storage compact as
if the bytes had been ACK'd. The FIN flag (if covered) also stays
sent — best-effort skips FIN re-emission too. The peer may end up
with a truncated stream; moq-lite's per-stream timeouts handle that.

Plumbed through QuicStream → QuicConnection.openUniStream(bestEffort)
→ QuicWebTransportSessionState.openUniStream(bestEffort) →
WebTransportSession.openUniStream(bestEffort). Default is false
everywhere, so reliable streams (HTTP/3 control, moq-lite SUBSCRIBE
bidi, etc.) keep RFC 9000 §3.5 semantics.

MoqLiteSession.openGroupStream now passes `bestEffort = true` —
group streams carry a single Opus packet, are real-time, and don't
benefit from retransmit.

Internal cleanup: `removeOverlap`'s `ackedNotLost: Boolean` parameter
became `OverlapAction { ACK, RETRANSMIT, DROP }` so the third best-
effort disposition has a name. Same code paths, same tests, just
clearer at the call site.

CC plan (quic/plans/2026-05-05-congestion-control.md) is updated to
"parked indefinitely" with a note that this commit is the lighter-
weight alternative that addresses the only practical concern. The
plan is preserved as a reference if a future workload justifies CC.

New tests: SendBufferBestEffortTest (6 cases — reliable baseline,
best-effort drops, FIN drop in best-effort mode, partial overlap,
idempotent stale loss, ACK path still works).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 02:06:08 +00:00
Claude 08bb3e14e1 docs(quic): plan congestion control (NewReno per RFC 9002 §7)
Open plan, not started. Conservative scope:
- NewReno reference algorithm (RFC 9002 §7).
- Bytes-in-flight tracking + send-side gating.
- Persistent-congestion handling.
- ~14 unit + 2 integration tests, ~3-5 days work.

Out of scope (deferred follow-ups): pacing, CUBIC, BBR, ECN.

The Why section is honest that this is "good citizen" work, not
"fix a bug" work — the audio cliff was a stream-id / forwarding
issue, not a rate-control issue, and the retransmit subsystem we
just shipped is what production actually needed. Plan stays open as
the natural next item but should not be prioritised over field
validation of the retransmit work.

Reference: neqo's cc/classic_cc.rs.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:46:00 +00:00
Claude 2053f50f35 fix(quic): discard Initial/Handshake keys per RFC 9001 §4.9
Pre-fix `:quic` held Initial AND Handshake encryption-level state
indefinitely once derived. AEAD cipher state, per-level CRYPTO
buffers, and the per-level sent-packet map all stayed alive for the
lifetime of the connection — a real memory leak for long sessions
(audio rooms run for hours).

LevelState.discardKeys() (idempotent):
- Nulls sendProtection / receiveProtection (frees AEAD state).
- Replaces cryptoSend / cryptoReceive with empty instances.
- Replaces ackTracker with an empty instance.
- Clears sentPackets and resets largestAckedPn /
  largestAckedSentTimeMs.
- Latches keysDiscarded = true.

Hook locations:
- Initial discard (RFC 9001 §4.9.1, client side): in
  QuicConnectionWriter.drainOutbound, after a Handshake-level packet
  is built into the outbound datagram. The next drainOutbound MUST
  NOT touch the Initial level; any retransmitted Initial from the
  peer is silently dropped (receiveProtection == null), which is
  correct per the same RFC since the server has also moved up
  encryption levels by then.
- Handshake discard (RFC 9001 §4.9.2 + §4.1.2, client side): in
  QuicConnectionParser, on receipt of a HANDSHAKE_DONE frame.

Once a level's protection is null, parser-side decrypt at that level
returns null silently (existing receiveProtection == null check) and
writer-side build skips it (existing sendProtection == null check),
so no further code paths needed updating.

New test: KeyDiscardTest (4 cases — Initial keys discarded after
first Handshake packet, Handshake keys still live until
HANDSHAKE_DONE, Handshake keys discarded on HANDSHAKE_DONE,
discardKeys is idempotent).

Listed in the audit-summary deferred-work as item 3
(`No Initial / Handshake key discard`).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:41:28 +00:00
Claude e3a3ffd1d9 fix(quic): correct AckTracker.purgeBelow via ACK-of-ACK dispatch
Pre-fix, QuicConnectionParser purged the inbound AckTracker on every
inbound AckFrame using `frame.largestAcknowledged - frame.firstAckRange`
— but that value lives in OUR outbound PN space, while the tracker
holds inbound PNs we received from the peer. The two PN spaces are
unrelated; the bug mostly hid because they grow at similar rates,
but caused range-list bloat over long sessions where traffic is
asymmetric (e.g. listener receives ~50 audio frames/sec while sending
back ~1 ACK/sec).

The correct semantics: purge only when the peer has confirmed receipt
of OUR outbound ACK frame. Now driven by the ACK-of-ACK dispatch.

- RecoveryToken.Ack changed from data object to data class carrying
  (level, largestAcked) — the encryption level and the largest inbound
  PN our outbound ACK frame covered.
- QuicConnectionWriter populates these fields from the AckFrame at
  emit time.
- QuicConnection.onTokensAcked dispatches RecoveryToken.Ack to
  levelState(level).ackTracker.purgeBelow(largestAcked + 1).
- The wrong purge in QuicConnectionParser is removed (replaced with a
  comment pointing at the new dispatch path).

Listed in the audit-summary deferred-work as item 6
(`AckTracker.purgeBelow threshold semantics`).

New test: AckTrackerPurgeOnAckOfAckTest (4 cases — purge on
ack-of-ack, level routing, partial purge keeps higher PNs, out-of-order
ACKs are safe).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:30:02 +00:00
Claude 70af1953dc docs(quic): record retransmit subsystem implementation status
Update the two affected plan docs now that RFC 9002 retransmit is
shipped on this branch.

quic/plans/2026-05-04-control-frame-retransmit.md:
- Mark plan as shipped 2026-05-05; list the 15 commits that landed it
  (plan + 9 steps + 5 follow-ups + perf + audit).
- Document what changed vs the original scope: the deferred follow-ups
  (STREAM data, CRYPTO, RESET_STREAM/STOP_SENDING/NEW_CONNECTION_ID)
  all shipped on top of the receive-flow-control core.
- Note the binary-search SendBuffer perf optimisation and the
  audit-driven first-call-wins fix.
- Update the cap-workaround status: initialMaxStreamsUni is back to
  10_000 (not the 1_000_000 mentioned in the original Why).

quic/plans/2026-04-26-quic-stack-status.md:
- Phase F downgraded from "partial" to "done (no CC)" — loss
  detection, RTT estimator, PTO, and per-frame retransmit shipped.
- Removed "no STREAM retransmit" / "SendBuffer doesn't retain bytes
  until ACK" from the deliberately-don't-do list (now do).
- Added congestion-control as the new deliberately-don't-do entry.
- Crossed out the corresponding deferred-work items; added congestion
  control as deferred item #8.
- Listed the new recovery test files in the test inventory.
- Linked to the retransmit plan + implementation log.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:15:29 +00:00
Claude 086a9c75dc fix(quic): RESET_STREAM/STOP_SENDING first-call-wins + threading contract
Audit follow-up from the prior two commits.

1. resetStream() / stopSending() now no-op on the second call. RFC 9000
   §3.5 pins finalSize at first emission; replaying retransmits with a
   larger value (because the app enqueued more bytes between two
   resetStream calls) would trigger FINAL_SIZE_ERROR on the peer. The
   "idempotent: a second call overwrites with the newer error code"
   claim was simply wrong. Two new tests lock the contract:
   resetStream_secondCallIsNoOp_finalSizeFrozen and
   stopSending_secondCallIsNoOp.

2. resetEmitPending / resetAcked / stopSendingEmitPending /
   stopSendingAcked are now @Volatile. The public emit APIs are
   callable from any coroutine while the writer / loss / ACK
   dispatchers read the same fields under QuicConnection.lock; volatile
   gives the cross-thread happens-before, and the first-call-wins gate
   above eliminates the only multi-writer race (two app threads racing
   the writer's clear-after-emit).

3. SendBuffer's class-level KDoc still claimed range arithmetic was
   O(N) "swap to TreeMap if profiling flags it" — stale after the
   binary-search refactor in 303caa8. Updated to reflect the actual
   O(log N + k) cost.

4. The bulk-removal comment in removeOverlap overstated the win
   ("O(k) per call, single shift of trailing entries"). ArrayDeque
   removeAt(i) shifts on every call, so the actual cost is
   O(k * (size - end + k)). Toned down — it's still cheap because
   k is 1-2 in steady state.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:04:32 +00:00
Claude 303caa8cf1 perf(quic): binary-search SendBuffer overlap + insert (O(log N))
removeOverlap was O(N) on the in-flight list — every ACK or loss
notification scanned the whole deque. Replaced with a binary-search
firstOverlapIndex helper plus a forward early-exit walk and a backward
bulk-removal pass. addToInFlight likewise binary-searches for the
middle-insert position instead of linear-scanning.

The list is sorted by offset and non-overlapping by construction, so
firstOverlapIndex finds the first entry whose end-offset > target in
O(log N), and the walk terminates as soon as r.offset >= rangeEnd.
Workload today is small (<100 entries per stream), but audio rooms
with many active streams compound the per-ACK cost.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 00:23:18 +00:00
Claude 996ab39940 feat(quic): emit RESET_STREAM / STOP_SENDING + per-stream retransmit dispatch
Application code can now call QuicStream.resetStream(errorCode) and
stopSending(errorCode); the next writer drain emits the matching frame
with a RecoveryToken. Loss dispatch re-flags the per-stream emit-pending
bit; ACK dispatch latches resetAcked / stopSendingAcked so stale loss
notifications can't re-emit. NEW_CONNECTION_ID retransmit drains
QuicConnection.pendingNewConnectionId on next writer pass (no public
emit API since :quic doesn't rotate connection IDs, but the wiring is
in place for a future emit path).

Five tests in ResetStopSendingEmitTest mirror neqo's send-stream reset
coverage: emit-and-token, retransmit-on-loss, ack-then-stale-loss-drop,
stop-sending emission, and NEW_CONNECTION_ID drain.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 00:23:07 +00:00
Claude 0c847b4f69 feat(quic): wire CRYPTO retransmit per encryption level
Closes commits D + E of the deferred-follow-ups pass. With
SendBuffer's retain-until-ACK semantics in commit B and the
Crypto / ResetStream / StopSending / NewConnectionId tokens added
in commit A, the writer now records a Crypto token per CRYPTO
frame at each encryption level (Initial, Handshake) so RFC 9002
retransmit recovers lost handshake bytes.

# Writer side

QuicConnectionWriter.collectHandshakeLevelFrames returns a new
HandshakeLevelContents(frames, tokens) pair instead of a bare
frame list. For each emitted CryptoFrame it appends a matching
RecoveryToken.Crypto(level, offset, length).

QuicConnectionWriter.buildLongHeaderFromFrames now also takes the
parallel tokens list, and after encryption records a SentPacket
in the matching LevelState.sentPackets map. Initial-level rebuilds
with padding (RFC 9000 §14.1) call pnSpace.rewindOutboundForRebuild
to reuse the same PN — the second build's map insert overwrites
the prior entry, so retention reflects the final padded packet.

drainOutbound's two callsites updated to pass tokens through.

# ACK / loss dispatch

Already wired in commit A. QuicConnection.onTokensAcked routes
Crypto tokens to LevelState.cryptoSend.markAcked at the matching
level, releasing buffer memory as the contiguous low end is ACK'd.
QuicConnection.onTokensLost routes them to markLost, re-queueing
the bytes for retransmit at the same level.

# RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID

Same dispatcher-only completion. The pendingResetStream /
pendingStopSending / pendingNewConnectionId maps on QuicConnection
are populated by the loss dispatcher when those token types are
seen. :quic doesn't currently emit any of those frames (no
application code triggers stream reset, connection-ID rotation
isn't wired), so the writer never drains the maps yet —
scaffolding for future emit support. The exhaustive when in
onTokensLost / onTokensAcked is now complete: any future addition
of a new RecoveryToken variant trips the compile-time exhaustive
check, mirroring the test in RecoveryTokenTest.

# Tests added (3, all pass)

CryptoRetransmitTest:
  - handshakePacket_carriesCryptoToken_inSentPacket: ClientHello
    emission produces an Initial-level SentPacket with a Crypto
    token at offset 0 with the expected level.
  - cryptoData_lostAndRetransmittedAtSameLevel: simulate loss via
    direct dispatch, observe re-queue in cryptoSend, verify next
    drain produces a fresh Initial packet replaying the same
    offset (RFC 9000 §13.3 idempotent).
  - cryptoAck_releasesBufferAtSameLevel: ACK via onTokensAcked,
    cryptoSend's readableBytes drops to 0.

# Net result

Lost handshake bytes (ClientHello, EncryptedExtensions, Certificate,
Finished, NewSessionTicket) are now recovered automatically. The
prior 1-second-fixed-PTO placeholder in QuicConnectionDriver
(commit step 7 of the prior plan) becomes meaningfully more useful
— PTO now wakes the writer to retransmit ACTUAL data, not just
emit empty PINGs.

Full :quic test suite + nestsClient moq-lite + amethyst Android
compile all pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 00:10:57 +00:00
Claude f623e886c3 feat(quic): wire STREAM data retransmit — token emission + ACK/loss dispatch
Closes commit C of the deferred-follow-ups pass. With the
SendBuffer rewrite in commit B (retain-until-ACK with markAcked /
markLost), the connection now wires STREAM frames into the same
RFC 9002 retransmit path that already handles flow-control
extensions.

# Writer side

QuicConnectionWriter.buildApplicationPacket records a
RecoveryToken.Stream(streamId, offset, length, fin) for every
STREAM frame it emits. The token captures the on-wire byte range
plus the FIN bit so retransmit can reproduce the same StreamFrame
on next drain.

# ACK side

New QuicConnection.onTokensAcked() mirrors onTokensLost. The
parser's AckFrame handler iterates the drained packets and routes
each to onTokensAcked, which:
  - For Stream tokens: calls SendBuffer.markAcked(offset, length).
    The buffer removes the range from in-flight; if the contiguous
    low end is now fully ACK'd, flushedFloor advances and storage
    shifts forward.
  - For Crypto tokens: same shape, applied to the per-level
    cryptoSend buffer (commit E will exercise this path for
    handshake reliability — Crypto retransmit is wired now but the
    writer's CRYPTO emission path doesn't yet record Crypto tokens;
    that's commit E).
  - For control-frame and Ack tokens: ACK-no-op. The frame already
    did its job by reaching the peer; no per-buffer state to
    release.

# Loss side

onTokensLost (commit A) already routes Stream tokens to
SendBuffer.markLost. With commit B's real implementation (was a
no-op stub), this now actually re-queues the byte range for
retransmit. The next writer drain pulls from the retransmit queue
before any fresh sends, with the original offset preserved (RFC
9000 §13.3 idempotent retransmit).

# Tests added (3, all pass)

  - streamFrame_carriesStreamToken_inSentPacket: writer emits a
    Stream token whose fields match the StreamFrame on the wire
  - streamData_lostAndRetransmittedOnNextDrain: simulate loss via
    direct dispatch, observe re-emit at the same offset in a fresh
    SentPacket (different PN)
  - streamData_ackedReleasesBuffer: ACK via onTokensAcked,
    enqueue more bytes, observe the next send picks up at the
    post-ACK offset (proves bytes were released and floor advanced)

Full :quic test suite, nestsClient moq-lite tests, amethyst Android
compile all pass.

Net result: lost STREAM data (e.g. nestsClient bidi control-stream
bytes — moq-lite Subscribe/Announce control messages travel on
QUIC bidi streams) is now recovered automatically. Audio rooms
benefit indirectly: the relay's announce/subscribe path is more
resilient to packet loss.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:24:26 +00:00
Claude 03cfb3188f feat(quic): rewrite SendBuffer for retain-until-ACK with markAcked/markLost
Foundation for STREAM and CRYPTO retransmit (commits C, D of the
deferred-follow-ups pass). Replaces the prior best-effort mode
where takeChunk released bytes immediately.

# Three logical regions

The buffer covers `[flushedFloor, nextOffset)`. Each byte is in one
of three states:

  - In-flight: sent but not yet ACK'd. Sorted-by-offset list.
  - Needs retransmit: declared lost; re-sent before any fresh bytes.
    FIFO queue.
  - Unsent: `[nextSendOffset, nextOffset)`.

# New API

  - markAcked(offset, length): removes the range from in-flight,
    advances flushedFloor through any contiguous low-end ACKs and
    shifts the underlying byte storage forward. Length=0 means
    FIN-only ACK; latches finAcked = true.
  - markLost(offset, length, fin): removes from in-flight, appends
    to retransmit queue. If fin, clears finSent so the FIN gets
    re-emitted. Idempotent: stale loss notifications below
    flushedFloor are absorbed.

takeChunk priority order:
  1. retransmit queue (preserves original offset; same byte data)
  2. fresh unsent bytes from [nextSendOffset, nextOffset)
  3. FIN-only zero-byte chunk if finPending and everything drained

# Range arithmetic

removeOverlap walks in-flight, computes the three-piece split for
each overlapping range (leftKept, covered, rightKept) and either
drops the covered portion (ACK) or pushes it onto retransmit
(loss). FIN belongs to the rightmost piece, so split at the end of
the original range correctly preserves it.

# Compaction

advanceFlushedFloorIfPossible bumps the floor whenever the lowest
in-flight / retransmit / unsent offset is above it, then
ByteArray.copyInto shifts the data window forward. Memory bounded
by `nextOffset - flushedFloor` rather than growing unboundedly.

# FIN handling

Treated as a virtual byte at offset = nextOffset. finPending arms
takeChunk to attach FIN to the final data chunk; finSent latches
true on emission; markLost(fin=true) clears it for re-emission;
finAcked latches true when the FIN-bearing range is ACK'd.

# Tests added (14, all pass)

  - takeChunk_releasesNothingUntilAcked
  - markAcked_full_releasesBytes_andDoesNotResend
  - markLost_movesBytesToRetransmitQueue_takeChunkReplaysSameOffset
  - markLost_partialRange_splitsInFlight
  - markAcked_partialRange_splitsInFlight
  - retransmitDrainsBeforeFreshBytes (priority order)
  - maxBytesSplits_acrossRetransmitAndFresh
  - fin_carriedOnFinalDataChunk_andRetransmittedOnLoss
  - fin_only_emittedAfterDataDrained
  - fin_only_lostAndRetransmits
  - markAcked_advancesFlushedFloor_releasesMemory
  - markAcked_outOfOrder_preventsFloorAdvance
  - markLost_belowFlushedFloor_isNoop (defensive)
  - finAcked_latchesTrueOnce
  - readableBytes_reflectsRetransmitPlusFresh
  - multipleSendsWithinSingleEnqueue_acksIndependently

Existing :quic tests (handshake, flow control, frame routing) +
nestsClient moq-lite + amethyst Android compile all pass.
SendBufferConcurrencyTest unchanged — the synchronized-on-this
discipline carries over from the prior implementation.

Wires nothing yet — commits C/D will route Stream/Crypto tokens
through markLost. The dispatcher in QuicConnection.onTokensLost
already calls markLost (added in commit A as a no-op stub); now
those calls actually do work.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:20:39 +00:00
Claude 7f6d9085a4 feat(quic): extend RecoveryToken — Stream, Crypto, ResetStream, StopSending, NewConnectionId
Token-shape commit (commit A of the deferred-follow-ups pass that
extends RFC 9002 retransmit beyond the receive-side flow-control
extensions originally shipped at commit 9e6fa3d).

New variants on RecoveryToken sealed class:
  - Stream(streamId, offset, length, fin): STREAM data range we
    sent. On loss the dispatcher will re-queue the range on the
    stream's SendBuffer (commits C, D wire that).
  - Crypto(level, offset, length): CRYPTO bytes at a specific
    encryption level. RFC 9000 §17.2.5 + §13.3: handshake bytes
    are reliable; lost CRYPTO retransmits at the same encryption
    level. Commit E wires the per-level dispatch.
  - ResetStream(streamId, errorCode, finalSize),
    StopSending(streamId, errorCode),
    NewConnectionId(seq, retirePriorTo, cid, statelessResetToken):
    reliable per RFC 9000 §13.3, scaffolding-only since :quic
    doesn't currently emit any of these. The pendingResetStream /
    pendingStopSending / pendingNewConnectionId maps on
    QuicConnection are populated by the dispatcher but not yet
    drained by any writer code path.

QuicConnection.onTokensLost extended with the new variants:
  - Stream/Crypto: route to the matching SendBuffer.markLost
    (currently a no-op — see SendBuffer.markLost kdoc; commit C
    replaces the stub with the retain-until-ACK rewrite).
  - ResetStream/StopSending/NewConnectionId: write to the
    pending* maps; future emit code drains them.

NewConnectionId data-class needs explicit equals/hashCode because
its ByteArray fields would otherwise compare by identity (Kotlin
data-class auto-equals limitation). Tested.

Tests added (4):
  - whenDispatch_isExhaustive extended with all 10 variants;
    catches at compile time if a new variant is added without
    updating the dispatcher
  - newConnectionId_arrayEqualityIsByContent
  - stream_equalityByValue
  - crypto_equalityByValue

SendBuffer gains placeholder markLost(offset, length, fin) and
markAcked(offset, length) methods — both no-ops today. Their
signatures are stable so the dispatcher wiring lands once now and
doesn't need re-touching when commit C makes them functional.

Full :quic test suite passes.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:14:42 +00:00
Claude c43c95184e feat(quic): steps 7, 8, 9 of RFC 9002 retransmit — PTO + integration test + revert workaround
Step 7: Probe Timeout (RFC 9002 §6.2).
  - QuicLossDetection.ptoBaseMs(maxAckDelayMs) computes
    `smoothed_rtt + max(4 * rttvar, 1ms) + max_ack_delay`.
  - QuicConnection gains pendingPing: Boolean and
    consecutivePtoCount: Int. The driver sets pendingPing = true
    when the PTO timer fires; the writer drains it as a PingFrame
    (smallest ack-eliciting frame). The peer ACKs the PING; that
    ACK feeds loss detection (steps 5–6) and triggers retransmit.
  - QuicConnectionDriver.sendLoop replaces the prior fixed 1-second
    placeholder with RFC 9002 PTO timing, doubling backoff per
    consecutivePtoCount per §6.2.2.
  - Parser resets consecutivePtoCount on any new ack-eliciting ACK.

Step 8: end-to-end integration test (RetransmitIntegrationTest, 2
tests). Drives the full chain (writer → SentPacket → loss detection
→ dispatch → re-emit) by simulating loss directly on
QuicConnection state, since the in-process pipe doesn't model loss:
  - maxStreamsUni_lostByPacketThreshold_isRetransmitted: emit a
    MAX_STREAMS_UNI, simulate ACK at PN+4 (above
    PACKET_THRESHOLD), verify retransmit lands in a NEW packet
    with a fresh PN.
  - lossDispatch_handlesSupersedeAcrossMultipleEmits: emit two
    successive MAX_STREAMS_UNI bumps (caps 6 and 10), declare the
    OLDER one lost. Supersede check drops the stale lost token —
    no retransmit because the newer cap covers it.

Step 9: revert the cap workaround.
  initialMaxStreamsUni: 1_000_000 → 10_000 (moq-rs's own default).
  The 1M value was a workaround for the moq-rs cliff that fired
  when our :quic emitted its first MAX_STREAMS_UNI extension. With
  retransmit now durable, a single dropped extension is recovered
  automatically — no need for the high-cap dodge. Lowering back
  exercises the rolling-extension path (and its retransmit) in
  production, which is what we want to validate the new code.

Tests added (4):
  - PtoTest x4: RFC 9002 §6.2.1 duration math
    (initial / with-ack-delay / after-rtt-sample / variance-floor)
  - RetransmitIntegrationTest x2: end-to-end retransmit cycle and
    supersede semantics

Full :quic test suite (~80 tests) + nestsClient moq-lite tests +
amethyst Android compile all green.

Closes the 9-step plan started at commit 9e6fa3d (step 1).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:02:01 +00:00
Claude 15a6bfcc84 feat(quic): step 6 of RFC 9002 retransmit — dispatch lost tokens to pending*
QuicConnection.onTokensLost(tokens) closes the loop between loss
detection (step 5) and writer drain (step 4). For each lost token:

  - Ack: ignored (RFC 9000 §13.2.1: ACK frames not retransmittable)
  - MaxStreamsUni: pendingMaxStreamsUni := token.maxStreams
    iff token.maxStreams == advertisedMaxStreamsUni
  - MaxStreamsBidi / MaxData: same shape, against their advertised cap
  - MaxStreamData: pendingMaxStreamData[streamId] := maxData iff
    stream exists AND token.maxData == stream.receiveLimit

The supersede check (`lost == advertised`) mirrors neqo's
`fc.rs::frame_lost` line 322. If a higher extension has gone out
since, the older lost frame is irrelevant — the newer value
covers the receiver's grant. Without the check we'd resurrect
stale extensions and waste wire bandwidth re-emitting values the
peer already has.

Wired into the parser's AckFrame handler immediately after
detectAndRemoveLost: walk each lost packet's tokens and call
onTokensLost. Caller already holds the connection lock.

Tests added (8, all pass):
  - ackToken_doesNotPopulateAnyPending
  - lostMaxStreamsUni_matchingAdvertised_setsPending
  - lostMaxStreamsUni_supersededByHigherEmit_isDropped (the
    supersede-check invariant from neqo's fc.rs:322)
  - lostMaxStreamsBidi_matchingAdvertised_setsPending
  - lostMaxData_matchingAdvertised_setsPending
  - lostMaxData_supersededIsDropped
  - lostMaxStreamData_unknownStream_dropped (defensive)
  - multipleLostTokens_dispatchAll (one packet's worth of mixed
    tokens dispatched in one call)
  - lostTokensFromMultiplePackets_unionInPending (sequential
    dispatch across multiple lost packets — older stale, newer
    valid; the valid one survives)

Mirror of neqo's `streams.rs::lost` dispatch + the
`no_max_allowed_frame_after_old_loss` and
`set_max_active_equal_does_not_set_frame_pending` tests from
`fc.rs` deferred from step 4 per the plan.

Full :quic test suite + nestsClient moq-lite tests pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:55:34 +00:00
Claude 1df6441639 feat(quic): step 5 of RFC 9002 retransmit — loss detection + RTT estimator
QuicLossDetection encapsulates the RFC 9002 §5–§6 algorithms:

  - RTT estimation (§5): smoothedRtt, rttVar, latestRtt, minRtt
    with first-sample bootstrap; ack-delay clamped against minRtt
    so a peer reporting a large delay can't push the estimate
    below its observed floor (§5.3 anti-exploit clamp).
  - Loss-delay (§6.1.2): max(latestRtt, smoothedRtt) * 9/8,
    clamped to GRANULARITY_MS (1 ms).
  - detectAndRemoveLost (§6.1): walks the in-flight set,
    removes entries that are either:
      - more than PACKET_THRESHOLD (3) PNs below largestAckedPn,
        OR
      - sent more than lossDelay ago.
    Returns the lost packets so step 6 can dispatch their tokens.

Wired into QuicConnectionParser's AckFrame handler:

  1. Snapshot largest-acked send time BEFORE drain
  2. Drain ACK'd packets (step 3)
  3. If largestAckedPn advanced AND any drained packet was
     ack-eliciting, update RTT (RFC 9002 §5.2 sample conditions)
  4. detectAndRemoveLost on the surviving set; lost list dropped
     for now — step 6 wires the dispatch

LevelState gains:
  - largestAckedPn: Long? (high-water mark for packet-threshold)
  - largestAckedSentTimeMs: Long? (RTT sample input)

QuicConnection gains:
  - lossDetection: QuicLossDetection (single instance, RTT is
    per-path; we model a single path)

Tests added (11, all pass):
  - firstRttSample_setsAllRttFieldsAtomically
  - secondRttSample_movesSmoothedRttTowardSample (math: 7/8 + 1/8)
  - ackDelay_clampedAgainstMinRtt (anti-exploit clamp)
  - negativeRttSample_isIgnored (clock skew defense)
  - lossDelay_floor (initial 333*9/8 = 374)
  - packetThresholdLost_removesPacketsBelowThreshold (PNs 0..6
    when largestAcked=10, threshold=3 → 7..9 survive)
  - timeThresholdLost_removesPacketsSentTooLongAgo (sentAt + delay
    < now → lost; recent → kept)
  - packetEqualToLargestAcked_notLost (edge case)
  - emptyMap_returnsEmpty
  - lostPackets_carryOriginalTokens (drain preserves token list)
  - packetThresholdAndTimeThresholdMatch_singleRemoval (no
    double-iteration)

Mirrors the subset of neqo's `recovery/mod.rs` tests in scope per
the plan: remove_acked, time_loss_detection_gap,
time_loss_detection_timeout, big_gap_loss,
duplicate_ack_does_not_update_largest_acked_sent_time. PTO tests
land in step 7.

Full :quic test suite passes.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:52:49 +00:00
Claude 29282634e5 feat(quic): step 4 of RFC 9002 retransmit — pending* fields + writer drain
Adds the writer's drain side of the retransmit path. The QuicConnection
gains four `pending*` fields:

  - pendingMaxStreamsUni: Long?
  - pendingMaxStreamsBidi: Long?
  - pendingMaxData: Long?
  - pendingMaxStreamData: MutableMap<Long, Long>  (keyed by stream id)

Each non-null entry signals "the last extension we sent at this value
was lost; re-emit it". appendFlowControlUpdates now drains all four
ahead of the rolling-extension threshold check, emitting a fresh
frame + RecoveryToken for each pending entry and clearing it.

Step 4 only wires the consumer side; the setter side (loss
dispatcher) is step 6. Tests populate `pending*` directly to exercise
the drain in isolation.

Tests added (8, all pass):
  - pendingMaxStreamsUni / Bidi / MaxData / MaxStreamData each
    individually drain to a SentPacket carrying the matching token
  - multiplePending: all four pending types drain into one packet
    (writer drains them sequentially, no fan-out)
  - noPending: drain produces no extension tokens
  - pendingDrainBeforeThresholdCheck_supersedeOrderObservable:
    writer drains the pending value as-is — supersede check is the
    setter's responsibility (step 6), not the drain's
  - pendingClearedAcrossDrains: a cleared pending stays cleared on
    subsequent drains

Mirrors neqo's `fc.rs` retransmit tests in the plan
(`need_max_allowed_frame_after_loss`, `lost_after_increase`,
`multiple_retries_after_frame_pending_is_set`,
`new_retired_before_loss`). The supersede-check tests
(`no_max_allowed_frame_after_old_loss`,
`set_max_active_equal_does_not_set_frame_pending`) belong to step 6
and land there.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:48:40 +00:00
Claude 0ced269b27 feat(quic): step 3 of RFC 9002 retransmit — drain SentPacket on ACK
QuicConnectionParser's AckFrame handler now drains
state.sentPackets of every entry whose packet number is covered by
the ACK's ranges. The drained SentPackets are returned but
discarded for now; step 5 will route them to loss detection / RTT.

New helper file `connection/recovery/AckedPackets.kt`:

  - forEachAckedPacketNumber(ack, block): inline iterator over an
    AckFrame's ranges in RFC 9000 §19.3.1 order. Walks first range
    [largestAcked - firstAckRange, largestAcked] then each
    additional range with `nextLargest = previousSmallest - gap - 2`,
    `nextSmallest = nextLargest - ackRangeLength`. Defensive clamp
    at PN 0 against malformed peer ACKs.
  - drainAckedSentPackets(sentPackets, ack): walks via
    forEachAckedPacketNumber and removes each matching entry from
    the map. Returns the drained list.

Wired into QuicConnectionParser.kt:165 alongside the existing
ackTracker.purgeBelow call.

Tests added (9, all pass):
  - simpleRange / multipleRanges / singlePacketAck: range walking
    for typical ACK shapes
  - ackForUnsentPn_isNoOp / emptyMap_returnsEmptyDrain: defensive
    paths
  - ackBoundary_pn0Inclusive: PN 0 is correctly included, no
    underflow
  - forEachAckedPacketNumber_iteratesDescending /
    forEachAckedPacketNumber_acrossMultipleRanges_descending:
    iterator semantics
  - returnedDrain_preservesTokens: drained SentPacket retains its
    full tokens list — step 5+ will dispatch these to RTT / loss

Mirror of neqo's `recovery/mod.rs::remove_acked` (one of the 20
recovery tests we owe per
`quic/plans/2026-05-04-control-frame-retransmit.md`). The remaining
loss-detection tests land in step 5.

Full :quic test suite + nestsClient moq-lite tests pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:45:10 +00:00
Claude ea15a9afa1 feat(quic): step 2 of RFC 9002 retransmit — record SentPacket per outbound
Plumbs sent-packet retention into the writer. From now on every
Application packet emission stores a SentPacket in
`LevelState.sentPackets`, keyed by packet number, carrying:

  - the packet number (from `pnSpace.allocateOutbound()`)
  - the writer's `nowMillis` send time
  - whether the packet is ack-eliciting (RFC 9000 §13.2.1)
  - the encrypted on-wire size (or 0 if encrypt threw)
  - a list of RecoveryTokens — one per retransmittable frame in the
    packet (Ack token for ACK frames; MaxStreamsUni / MaxStreamsBidi
    / MaxData / MaxStreamData for the corresponding flow-control
    extensions)

`appendFlowControlUpdates` now takes a parallel `tokens: MutableList`
and writes lock-step with `frames`. The writer's existing semantics
are unchanged — same frames go on the wire, same advertised-cap
bookkeeping. Step 2 only adds the retention; nothing reads
`sentPackets` yet (steps 3–6 do that).

Order of operations on packet emission:
  1. Allocate packet number
  2. runCatching the encrypt step
  3. Record SentPacket regardless of encrypt outcome (sizeBytes=0 if
     it threw — the bookkeeping survives so loss detection can later
     declare the gap lost on the time threshold)
  4. Re-throw the encrypt exception so the driver loop sees the same
     error it did before this change

Tests added (3, all pass):
  - writer_records_sent_packet_with_max_streams_uni_token: cross
    half-window, drain, observe a SentPacket whose tokens contain
    MaxStreamsUni with maxStreams matching advertisedMaxStreamsUni
  - ack_only_outbound_records_sent_packet_with_ack_token_and_not_ack_eliciting:
    pending ACK only, observe a SentPacket with single Ack token and
    ackEliciting=false
  - successive_drains_record_distinct_packet_numbers: two drains
    record disjoint PN sets

Full :quic test suite passes (no regressions). nestsClient moq-lite
tests pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:42:15 +00:00
Claude 9e6fa3d3f0 feat(quic): step 1 of RFC 9002 retransmit — RecoveryToken + SentPacket types
First step of `quic/plans/2026-05-04-control-frame-retransmit.md`.
Pure type definitions, no behavior change yet — sets up the data
shape the next steps will populate from the writer (step 2) and
drain from ACK / loss-detection paths (steps 3–6).

RecoveryToken: sealed class mirroring neqo's
neqo-transport/src/recovery/token.rs:21 StreamRecoveryToken.

  - Ack (singleton object): tracked but never retransmitted, so the
    sent-packet map invariant ("every retained entry has at least
    one token") holds for ACK-only packets too
  - MaxStreamsUni / MaxStreamsBidi: receive-side stream-id cap
    extension (RFC 9000 §19.11) — the frame whose loss tripped
    the moq-rs cliff
  - MaxData: connection-level data cap extension (§19.9)
  - MaxStreamData: per-stream data cap extension (§19.10)

SentPacket: data class mirroring neqo's
neqo-transport/src/recovery/sent.rs::Packet. Held in a per-pn-space
map on QuicConnection (step 2 wires this up). Carries packet number,
send time, ack-eliciting flag, on-wire size, and the token list to
dispatch on loss.

Tests: 6 token tests (data-class equality, sealed-hierarchy
exhaustiveness, Ack singleton-ness) + 6 SentPacket tests (equality
across fields, copy semantics, ACK-only-with-Ack-token convention,
multi-token packet shape). All pass; full :quic test suite still
passes — types are purely additive.

Out of scope as documented in the plan: STREAM data retransmit,
CRYPTO retransmit, RESET_STREAM/STOP_SENDING/etc, congestion control,
0-RTT. Those are separate follow-ups.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:31:07 +00:00
Claude c24630513f docs(quic): plan control-frame retransmit subsystem mirroring neqo
The shipping fix raises initialMaxStreamsUni to 1M to dodge the
moq-rs cliff that fired when our :quic emitted its first
MAX_STREAMS_UNI extension. That sidesteps the symptom but leaves
every ack-eliciting control frame one wire-loss away from a silent
stall (MAX_DATA, MAX_STREAM_DATA, RESET_STREAM, etc.). Browser
QUIC stacks (Firefox neqo and Chrome quiche, both verified by
reading source) implement RFC 9002 §6 loss detection + per-frame
retransmit; :quic does not.

Plan documents:
  - the architecture, mirroring neqo's typed-token shape (chosen
    over quiche's monotonic-control-frame-id deque on code-fit
    grounds — better match for :quic's existing sealed-class
    Frame / MoqLiteControl idioms)
  - file-by-file implementation order in 9 steps that each compile
    and test independently
  - inventory of ~50 tests to port from neqo (9 from fc.rs, ~20
    from recovery/mod.rs, ~10 connection-level, plus codec round
    trips). Each row links to the upstream neqo test by file:line
    and the equivalent Kotlin test name we'll add
  - explicit out-of-scope list (STREAM data retransmit, CRYPTO
    retransmit, congestion control, 0-RTT, multipath) — separate
    follow-ups
  - effort estimate (4–7 days) and acceptance criteria

References upstream code at /tmp/quic-refs/neqo (mozilla/neqo) and
/tmp/quic-refs/quiche (google/quiche), sparse-cloned for source
verification.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:25:34 +00:00
Claude c3d6cadffb fix(quic): raise stream-id cap to 1M to support multi-hour Nests; strip diagnostic logs
Two changes once the cliff is confirmed sidestepped:

1. Raise initialMaxStreamsUni from 10 000 → 1 000 000.

   At framesPerGroup=5 with 20 ms Opus frames the relay opens ~10
   uni streams/sec to a listener. The half-window threshold check
   (`count + initialMaxStreamsUni/2 >= advertisedMaxStreamsUni`)
   now trips at count=500 000 ≈ 13.9 hours of continuous audio.
   For any realistic Nest the rolling MAX_STREAMS_UNI extension
   path — which is what tripped the moq-rs cliff — is dormant.

   Memory cost: QuicConnection.streams grows for the connection's
   lifetime (no removal in current model), so 2 hours costs ~72k
   stream entries. Per-stream overhead is small enough that this
   is tolerable for an audio-room workload; bounded growth is a
   known follow-up.

2. Strip the high-frequency diagnostic logs that were added during
   investigation. Production keeps:
     - SUBSCRIBE_DROP (rare error)
     - MAX_STREAMS_UNI / MAX_STREAMS_BIDI emit (should never fire
       in normal operation now; if it does, we want to know)
     - pumpUniStreams / pumpInboundBidis ended (pump death)
     - announce / subscribe bidi.incoming() exception path
     - ReconnectingHandle.opener throw + retry

   Stripped:
     - per-stream "transport delivered uni stream #N"
     - per-group "uni grpHdr id=N seq=M"
     - per-group "openGroup seq=N keyedOnSubId=…"
     - per-25-stream peerInitiatedUniCount milestone
     - per-chunk "subscribe id=N: bidi chunk #M"
     - per-update RoomAnnouncement
     - 5-second QuicWebTransportSession flow-control snapshot ticker
     - "VM.openSubscription ->/<- subscribeSpeaker"
     - "VM.onSpeakerActivity FIRST frame"
     - "broadcaster: send accepted (subscriber attached)"
     - "broadcaster: publisher.send returned false (no inbound subscriber)"
     - "first inbound subscriber attached"
     - "ignoring inbound SUBSCRIBE id=N track=catalog.json"
     - "publish suffix=…"
     - "transport delivered inbound bidi #N"
     - "inbound AnnouncePlease prefix=…"
     - "inbound SUBSCRIBE id=… track=…"
     - "inbound SUBSCRIBE FIN'd: removing id=…"

   The diagnostic logs can be re-added behind a debug flag later if
   we need to chase a different regression.

Confirmed durable for at least 15 s of continuous audio against
nostrnests.com production with the prior 10 000-cap fix; bumping to
1 000 000 expands the headroom to multi-hour broadcasts without any
new code path firing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 21:55:49 +00:00
Claude f32131d073 fix(quic): raise initialMaxStreamsUni from 100 → 10000 to dodge MAX_STREAMS_UNI cliff
Production trace pinned the audio-cliff to the exact moment our
writer emits its first MAX_STREAMS_UNI extension (frame 0x13).
With the default cap of 100 and the half-window threshold check at
count=50, the listener emits MAX_STREAMS_UNI(150) at count=50, then:

    16:47:18.092 MAX_STREAMS_UNI emit oldCap=100 → newCap=150
    16:47:18.210 transport delivered uni stream #50 ← LAST stream
    16:47:20.204 udpRecvDatagrams=431
    16:47:25.209 udpRecvDatagrams=443  (+12)
    16:47:30.213 udpRecvDatagrams=443  ← FROZEN forever

`udpRecvDatagrams` (kernel-level UDP receive counter) freezes:
the relay's UDP packets stop reaching the OS. Our QUIC state still
believes the connection is alive (sendCredit=2^62-1, pendingBytes=0,
peerInitMaxStreamsUni=10000) but `peerInitiatedUniCount` is stuck at
51 forever. The relay, meanwhile, FINs both inbound SUBSCRIBE bidis
on the publisher side at +10s, telling the publisher "this listener
went away" — split-brain.

The cliff is repeatable but not at a fixed stream count: prior runs
saw cliffs at 124 (where the *second* bump at count=100 would have
fired) and 61 (where some intermediate condition tripped). The
common thread is the rolling-extension path, not the absolute count.

Without a packet capture from the relay we can't pin whether our
MAX_STREAMS_UNI frame is malformed, mis-sequenced, ill-timed against
moq-rs's flow-control state machine, or something subtler. But moq-rs
itself advertises `max_concurrent_uni_streams = 10000` for exactly
the audio-rooms workload — every Opus group is a fresh peer-initiated
uni stream — and matching that takes the listener to ~5 000 groups
(at framesPerGroup=5, Opus 20ms, that's ~8 minutes of audio) before
the half-window threshold trips at all. For any realistic Nest
duration we never need to extend.

The extension code path stays in QuicConnectionWriter.kt:395 — it's
correct per RFC 9000 §19.11, the bug is in moq-rs (or its Quinn
config) and we're working around it for now.

Side effect: we also bump the bidi cap implicitly — but that's
controlled by initialMaxStreamsBidi (still 100), unaffected by this
change. Bidi streams are control-stream-only in moq-lite (Subscribe
+ Announce), well below 100.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 20:53:58 +00:00
Vitor Pamplona cbf129ed12 Merge pull request #2730 from vitorpamplona/claude/fix-nostr-relay-reconnect-95vpR
Add keep-alive mechanism to reconnect disconnected relays
2026-05-04 16:48:57 -04:00
Claude 36c707f98a debug(quic): periodic 5s flow-control snapshot from QuicWebTransportSession
Listener cliffs at uni stream #61 even though MAX_STREAMS_UNI(150)
was emitted at count=50 — and the cliff number is variable across
runs (124 in one trace, 61 in another), which strongly suggests the
limiter isn't the stream-id cap any more. Need visibility into what
QUIC flow-control state looks like at the moment streams stop.

QuicWebTransportSession now optionally takes a parentScope and, when
provided, launches a daemon coroutine that calls
QuicConnection.flowControlSnapshot() every 5 s. The snapshot dumps
the fields the cliff-investigation plan
(nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md)
identified as smoking-gun candidates:

  - peerInitiatedUniCount + advertisedMaxStreamsUni (stream-id cap)
  - peerInitMaxStreamsUni (peer's view of our cap, from handshake)
  - sendCredit + consumed (connection-level data flow control)
  - pendingBytes / pendingStreams (anything stuck in send buffers)
  - udpRecvDatagrams + udpRecvBytes (raw socket-level reception)

The factory passes its parentScope into the session so the snapshot
job dies cleanly with the same supervisor that owns the driver.
QuicWebTransportFactory tests and SendTraceScenario don't pass a
scope and won't see the periodic logger.

Filter `adb logcat -s NestQuic:D` and look for "snapshot peerInitiatedUni=…".
At cliff time the snapshot will print whichever counter is wedged.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 20:28:41 +00:00
Claude 709e254c15 fix: periodic keep-alive to revive relays in long backoff
When a relay enters a long backoff (5 min, e.g. host unreachable or
a server returned an HTTP error during handshake), the per-relay
delayToConnectInSeconds blocks reconnect attempts for up to 5 minutes.
Without a wakeup, nothing inside NostrClient revisits that relay until
the next subscribe/count/publish.

Add a keep-alive coroutine that calls reconnectIfNeedsTo(false) every
60s while the client is active. The per-relay backoff still gates the
actual reconnect, so dead relays are not hammered, but a relay whose
backoff window has elapsed is reconnected within ~60s of becoming
eligible.

The job lives in scope and is cancelled by close().
2026-05-04 20:28:02 +00:00
Claude 575fe952c6 fix: auto-reconnect relays after server-initiated disconnect
Previously, when a relay closed the WebSocket (or the connection
dropped), NostrClient.onDisconnected only updated state and notified
listeners — it never tried to reconnect. The relay then stayed
disconnected until the next subscribe/count/publish call (which
triggers reconnect()) or an explicit reconnect() from the caller.

Now, if the client is still active and the relay is still in the
desired set (i.e. some sub/count/outbox still wants it),
onDisconnected schedules a debounced reconnect via reconnectIfNeedsTo,
which respects per-relay exponential backoff so we don't hammer dead
relays.
2026-05-04 20:17:29 +00:00
Vitor Pamplona 8dd13973db Merge pull request #2729 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-04 16:10:29 -04:00
Crowdin Bot 13b35468a2 New Crowdin translations by GitHub Action 2026-05-04 20:08:13 +00:00
Claude 1555023ea2 Merge remote-tracking branch 'origin/main' into claude/fix-nest-audio-display-3chAG 2026-05-04 20:07:25 +00:00
Vitor Pamplona 8cea0fa869 Merge pull request #2725 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-04 16:06:37 -04:00
Vitor Pamplona b053a08173 Merge pull request #2728 from vitorpamplona/claude/trace-nostrclient-lifecycle-JXoP8
Replace KeyDataSourceSubscription with LifecycleAwareKeyDataSourceSubscription
2026-05-04 16:06:30 -04:00
Claude 6bcb94a658 feat(subscriptions): split foreground-only account loaders into AccountForegroundFilterAssembler
Move AccountFollowsLoaderSubAssembler and AccountNotificationsEoseFromRandomRelaysManager out
of the always-on AccountFilterAssembler and into a new AccountForegroundFilterAssembler that
is mounted via LifecycleAwareKeyDataSourceSubscription. These two scan-heavy loaders now pause
on ON_STOP and resume on ON_START, while the lightweight always-on account work (metadata,
gift wraps, drafts, inbox-relay notifications, marmot groups) keeps running in background.

https://claude.ai/code/session_01RUmRxmzUAcVXezF9PEETGz
2026-05-04 19:43:09 +00:00
Claude 98c0438526 feat(subscriptions): make screen subscriptions lifecycle-aware
Migrates 32 call sites from KeyDataSourceSubscription to
LifecycleAwareKeyDataSourceSubscription so feed/screen REQs are
paused when the app goes to background and resumed on foreground,
saving relay bandwidth while the always-on notification service
keeps the socket open.

Adds a MutableComposeSubscriptionManager overload to
LifecycleAwareKeyDataSourceSubscription so the search bars (which
bind to a flow-driven query) can also opt in.

Skips AccountFilterAssemblerSubscription (always-on account state)
and NWCFinderFilterAssemblerSubscription (in-flight zap payments
that must complete in the background).

https://claude.ai/code/session_01RUmRxmzUAcVXezF9PEETGz
2026-05-04 19:43:09 +00:00
Crowdin Bot 3e9b05b2fe New Crowdin translations by GitHub Action 2026-05-04 18:35:45 +00:00
Vitor Pamplona 846b25dcd4 Merge pull request #2727 from vitorpamplona/claude/fix-foreground-service-U8P5d
Handle Android 12+ ForegroundServiceStartNotAllowedException gracefully
2026-05-04 14:34:08 -04:00
Claude 9f542a6ff4 fix(nests): retry SUBSCRIBE on relay-FIN instead of permanently giving up
When a listener joins a Nest before the speaker starts publishing,
moq-rs FINs the SUBSCRIBE bidi immediately without sending
SubscribeOk or SubscribeDrop. MoqLiteSession.subscribe surfaces that
as a MoqLiteSubscribeException("subscribe stream FIN before reply"),
which the listener wrapper translated to MoqProtocolException via
MoqLiteNestsListener.wrapSubscription.

The reconnecting wrapper's inner re-issue loop then did:

    val handle = try { opener(listener) } catch (...) { null } ?: break

— breaking out of the loop forever. The outer collectLatest only
re-runs on listener swap (session reconnect), so once the first
SUBSCRIBE failed the audio path stayed dead until the user manually
disconnected and reconnected. With the typical join-order being
"listener taps Join before speaker taps Start", this hit nearly
every nest.

The kdoc on pumpAnnounceWatch claims "moq-lite supports subscribe-
before-announce, so a subscribe issued during the gap … attaches
cleanly when the new publisher comes up" — true for some publisher-
cycle gaps mid-broadcast, but verifiably false for the cold-start
case where the publisher hasn't existed yet on the relay's view of
the namespace. Production trace (commit 283e776):

    14:23:29.556 subscribe id=0 track='audio/data': SUBSCRIBE bytes flushed
    14:23:29.597 subscribe id=0: bidi closed BEFORE any response parsed
    14:23:29.617 ReconnectingHandle.opener threw MoqProtocolException — pump breaks
    14:23:33.604 announce update status=Active suffix=…  (4 s later)
    14:23:34.218 publish suffix=…  (speaker starts publishing)

Fix: instead of `break`, treat opener-throw as a transient failure
and retry after SUBSCRIBE_RETRY_BACKOFF_MS (1 s). The outer
collectLatest still cancels the inner loop on listener swap, and
unsubscribeAction.cancel() still tears the pump down on consumer
release, so the retry loop is bounded by both the session and the
caller. 1 s is well over moq-rs's typical announce-propagation
window (< 200 ms in traces) and short enough that the listener
attaches within a second of the speaker actually publishing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 18:28:42 +00:00
Claude e693a954d1 fix(notifications): handle FGS-not-allowed exception from background
AlwaysOnNotificationServiceManager calls NotificationRelayService.start()
from a flow collector that can fire during cold-start (before the activity
reaches foreground), where Android 12+ blocks startForegroundService() with
ForegroundServiceStartNotAllowedException. The exception was already caught
but logged as an error.

- Distinguish ForegroundServiceStartNotAllowedException from real failures
  and log it at WARN — the other layers (boot receiver, watchdog alarm,
  catch-up worker) retry from contexts that have FGS exemption.
- Retry the start in MainActivity.onResume() so the service comes up as
  soon as the user opens the app.
2026-05-04 18:22:31 +00:00
Vitor Pamplona aaa35b1a06 Merge pull request #2726 from vitorpamplona/claude/delayed-unsubscribe-lifecycle-93ICJ
Add grace period to lifecycle-aware subscriptions
2026-05-04 13:59:27 -04:00
Claude e6eca9362a feat(commons): delay lifecycle-aware unsubscribe by 30s
Tearing down a relay REQ on every ON_STOP and rebuilding it on ON_START
churns EOSE state and triggers a refetch when the user briefly switches
to another app. Schedule the unsubscribe 30s after ON_STOP and cancel it
on ON_START so short app switches keep the subscription alive.

https://claude.ai/code/session_01W3RY9Rf4gc4eEkL4v1v8Bg
2026-05-04 17:56:38 +00:00
Claude 283e7760bd debug(quic): log MAX_STREAMS_UNI emission and per-25-stream count milestones
Listener cliff at uni stream #124 with the publisher continuing to
push for ~55 s past that point — the prior MAX_STREAMS_UNI extension
fix is either firing too late or not firing at all on the live
device. The trace can't tell us which because the bump itself is
silent.

- QuicConnectionWriter: log every MAX_STREAMS_UNI / MAX_STREAMS_BIDI
  emission with old→new cap and the current peerInitiated count, so
  we can see whether the writer is bumping the cap at all and how
  far behind reception it falls.
- QuicConnection: log peerInitiatedUniCount every 25 streams along
  with the currently advertised cap and headroom. Cheap and lets us
  correlate "stream count growth" against "cap bumps" without
  printing per-stream noise.

Tag is `NestQuic` so listeners can scope `adb logcat -s NestRx:D
NestTx:D NestQuic:D` to capture the full picture.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 17:48:03 +00:00
Vitor Pamplona eb73069459 Merge pull request #2724 from vitorpamplona/claude/enhance-speaker-animation-8AlyR
Add animated outer ring indicator for speaking participants
2026-05-04 13:34:30 -04:00
Claude 43720dd14d fix(nests): scope moq-lite publisher to a single track to stop catalog hijack
Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:

    13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
    13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
    13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
    13:21:27.610 broadcaster: send accepted (subscriber attached)
    ... every subsequent group keyed=1, every audio frame on the
        catalog stream

Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.

Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").

Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.

The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 17:28:18 +00:00
Claude 9b6e09838e debug(nests): expose silent failures inside subscribe / announce / reissue
Round-2 instrumentation after phone-B trace showed silence between
SUBSCRIBE_OK-expected and the first audio frame. Three additions:

- MoqLiteSession.subscribe: log the SUBSCRIBE-bytes-flushed transition,
  every chunk arriving on the response bidi (chunks=N), and any
  exception or natural close on bidi.incoming() that previously
  swallowed the cause.
- MoqLiteSession.announce: same treatment for the AnnouncePlease bidi
  — chunks=N, natural-close, and exception path now visible.
- ReconnectingNestsListener.reissuingSubscribe: previously had a
  `catch (_: Throwable) { null }` black hole on the re-issue pump
  (line 354); now logs the throwable's class + message before the
  pump breaks. Also logs natural-close of handle.objects so a
  publisher cycle vs a real failure are distinguishable in the trace.

These narrow the listener-stuck-on-spinner diagnosis from
"something downstream of subscribe never fires" to which exact step
inside `MoqLiteSession.subscribe` is silent: the write, the response
read, or the framing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 17:16:28 +00:00
Claude 62bedf1372 debug(nests): log QUIC↔moq seam in pumpUniStreams + pumpInboundBidis
Splits the diagnosis into "QUIC delivered streams" vs "moq routed
them". Without this seam log, a silent NestRx trace after SUBSCRIBE_OK
gives no signal whether the relay is forwarding (and :quic isn't
delivering streams to the moq pump) or moq is dropping frames.

Also surfaces the prior `_: Throwable` swallow in both pumps — those
hid transport-close exceptions which are exactly the kind of clue
we want when the listener silently stalls.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 14:05:03 +00:00
Claude 520949af20 feat(nests): add detached outer ring to speaker avatar
The existing speaker indicator was easy to miss — only a thin border ring
plus a soft halo, and on busy stages neither stood out. Adds a second,
solid green ring drawn 5dp away from the profile picture so the
"is talking" signal reads at a glance. Stroke width still pulses with
the live audio level for liveness; ring fades out when speech stops.
Drawn via drawBehind so it doesn't affect cell layout — the gap + ring
fit inside the existing GRID_SPACING.
2026-05-04 14:04:50 +00:00
Claude 726894362f debug(nests): wire NestRx/NestTx logs across listener and speaker paths
Diagnostic instrumentation to localise the listener-stuck-on-spinner bug.
None of these are intended for merge — pair logs with a logcat capture,
diagnose, then revert.

Receiver (tag NestRx):
  - MoqLiteNestsListener: log subscribeSpeaker / subscribeCatalog entry
    and per-update RoomAnnouncement emission to the VM
  - MoqLiteSession.subscribe: log SUBSCRIBE_OK / SUBSCRIBE_DROP outcomes
  - MoqLiteSession.drainOneGroup: log every uni group header decode
    and warn on subscription-lookup miss (frame dropped)
  - MoqLiteSession.pumpAnnounceWatch: log every announce update and
    flag the Ended branch that closes the subscription's frames
  - NestViewModel: log VM.openSubscription wiring and the first-frame
    trigger that clears the connectingSpeakers spinner

Speaker (tag NestTx):
  - MoqLiteSession.publish: log entry suffix
  - MoqLiteSession.handleInboundBidi: log inbound AnnouncePlease and
    SUBSCRIBE dispatches plus FIN cleanup
  - MoqLiteSession PublisherStateImpl: log first inbound subscriber
    attach and every group-stream open
  - NestMoqLiteBroadcaster: log when publisher.send returns false
    (no inbound subscriber on relay) and the subscriber-attached
    transition, plus surface throws that runCatching previously
    swallowed only into onError

Capture with `adb logcat -s NestRx:D NestTx:D`.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 13:10:24 +00:00
Vitor Pamplona 0210d608b5 Merge pull request #2719 from nrobi144/fix/bunker-timeout-and-decrypt
fix(nip46): fix bunker decrypt/encrypt parsing and increase timeout
2026-05-04 08:09:33 -04:00
Vitor Pamplona b1fb13cddc Merge pull request #2720 from davotoula/feat/log-lambda-overloads
chore: convert interpolated Log calls to lambda overload + restore throwables in Marmot catch blocks
2026-05-04 08:08:19 -04:00
Vitor Pamplona 85402c0d27 Merge pull request #2721 from greenart7c3/main
Add bulk unblock/unmute functionality to Security Filters
2026-05-04 08:04:26 -04:00
Claude 76b69cd440 feat: bulk-remove for blocked users and hidden words
Long-press a row in Settings -> Security Filters to enter selection mode,
pick more rows, tap Unblock. The mute list (kind 10000) and block list
are rebuilt and re-broadcast once for all selected entries instead of
once per item.

- Quartz: add `MuteListEvent.removeAll` and `PeopleListEvent.removeAll`
  bulk overloads using the existing `TagArray.removeAny` primitive.
- Account / state classes: add `showUsers(List)` and `showWords(List)`
  that produce a single resigned event per list.
- AccountViewModel: thin wrappers `showUsers` / `showWords`.
- SecurityFiltersScreen: hoist per-tab selection sets, swap top bar
  in selection mode, add a local selectable user list (the shared
  `UserCompose` row was kept untouched).
2026-05-04 08:55:29 -03:00