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
This commit is contained in:
Claude
2026-05-05 01:04:32 +00:00
parent 303caa8cf1
commit 086a9c75dc
3 changed files with 98 additions and 18 deletions
@@ -183,6 +183,56 @@ class ResetStopSendingEmitTest {
assertEquals(false, stream.stopSendingEmitPending)
}
@Test
fun resetStream_secondCallIsNoOp_finalSizeFrozen() =
runBlocking {
// RFC 9000 §3.5: finalSize is fixed at first emission.
// A second resetStream() must not overwrite resetState
// — otherwise a retransmit after additional enqueue would
// replay with a larger finalSize, triggering FINAL_SIZE_ERROR.
val client = handshakedClient()
val stream = client.openUniStream()
stream.send.enqueue("first".encodeToByteArray()) // 5 bytes
stream.resetStream(errorCode = 1L)
// App enqueues more bytes (writer's send loop is racing) and
// calls resetStream again with a different code.
stream.send.enqueue("more-bytes".encodeToByteArray()) // +10 bytes
stream.resetStream(errorCode = 99L)
runCatching { drainOutbound(client, nowMillis = 1L) }
val tokenEntry =
client.application.sentPackets.entries
.firstOrNull { it.value.tokens.any { t -> t is RecoveryToken.ResetStream } }
assertNotNull(tokenEntry)
val token =
tokenEntry.value.tokens
.filterIsInstance<RecoveryToken.ResetStream>()
.single()
assertEquals(1L, token.errorCode, "first errorCode wins")
assertEquals(5L, token.finalSize, "finalSize frozen at first call (RFC 9000 §3.5)")
}
@Test
fun stopSending_secondCallIsNoOp() =
runBlocking {
val client = handshakedClient()
val stream = client.openBidiStream()
stream.stopSending(errorCode = 5L)
stream.stopSending(errorCode = 42L)
runCatching { drainOutbound(client, nowMillis = 1L) }
val tokenEntry =
client.application.sentPackets.entries
.firstOrNull { it.value.tokens.any { t -> t is RecoveryToken.StopSending } }
assertNotNull(tokenEntry)
val token =
tokenEntry.value.tokens
.filterIsInstance<RecoveryToken.StopSending>()
.single()
assertEquals(5L, token.errorCode, "first errorCode wins")
}
@Test
fun newConnectionId_retransmittedOnLoss() =
runBlocking {