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:
+50
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user