fix(quic): tier-local round-robin so priority survives every drain
The previous priority-then-round-robin shape applied the rotating start-index globally over the sorted list, so the cross-tier order flipped on alternate drains: drain N had high-priority first, drain N+1 advanced streamRoundRobinStart and had low-priority first. The priority hint was silently defeated under any sustained traffic. Replace it with strict priority across tiers + round-robin only within each same-priority tier. Higher tiers always drain ahead of lower ones; same-priority peers continue to take turns via the existing rotating start. Default-priority callers see no behaviour change (single tier, identical rotation semantics). Tighten the test: drain twice and assert the higher-priority stream emits first on BOTH drains — the regression case that the single- drain version of the test missed. Add a regression guard for the same-priority round-robin so a future refactor can't silently serialise on the first stream. https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
This commit is contained in:
+31
-15
@@ -410,22 +410,36 @@ private fun buildApplicationPacket(
|
|||||||
// insertion-ordered and stays in sync with the streams map.
|
// insertion-ordered and stays in sync with the streams map.
|
||||||
val streamsView = conn.streamsListLocked()
|
val streamsView = conn.streamsListLocked()
|
||||||
if (streamsView.isNotEmpty()) {
|
if (streamsView.isNotEmpty()) {
|
||||||
// Priority-then-round-robin: stable sortedByDescending preserves
|
// Strict priority across tiers, round-robin within each tier.
|
||||||
// insertion order within a tier, so same-priority streams keep
|
// Higher-priority streams (e.g. moq-lite newer-sequence group
|
||||||
// the rotating start-index round-robin behaviour. Higher-priority
|
// streams) ALWAYS drain ahead of lower-priority ones; the
|
||||||
// streams (e.g. moq-lite newer-sequence group streams) drain
|
// rotating start-index only rotates among same-priority peers.
|
||||||
// first under congestion. Default priority is 0; if every stream
|
// This is the spec-aligned shape — applying the rotation
|
||||||
// is at the default, iteration order matches pre-priority code.
|
// globally over the sorted list would flip cross-tier order on
|
||||||
// Cost: O(N log N) per drain pass and one transient list
|
// alternating drains, defeating the priority hint entirely.
|
||||||
// allocation. N is small (1–10 in the moq-lite audio path); if
|
//
|
||||||
// it ever grows enough to matter, switch to an indirect index
|
// Default priority is 0; if every stream is at the default, all
|
||||||
// sort or maintain an incrementally-sorted view on setPriority.
|
// streams form a single tier and iteration order matches the
|
||||||
|
// pre-priority round-robin behaviour exactly.
|
||||||
|
//
|
||||||
|
// Cost: O(N log N) per drain pass plus one transient sorted
|
||||||
|
// list. N is small (1–10 in the moq-lite audio path); if it
|
||||||
|
// ever grows enough to matter, switch to an indirect index sort
|
||||||
|
// or maintain an incrementally-sorted view on setPriority.
|
||||||
val sorted =
|
val sorted =
|
||||||
if (streamsView.size > 1) streamsView.sortedByDescending { it.priority } else streamsView
|
if (streamsView.size > 1) streamsView.sortedByDescending { it.priority } else streamsView
|
||||||
val start = conn.streamRoundRobinStart % sorted.size
|
val rotation = conn.streamRoundRobinStart
|
||||||
for (i in sorted.indices) {
|
var tierStart = 0
|
||||||
if (packetBudget <= 64) break
|
outer@ while (tierStart < sorted.size) {
|
||||||
val stream = sorted[(start + i) % sorted.size]
|
// Walk the contiguous run of same-priority streams.
|
||||||
|
val tierPriority = sorted[tierStart].priority
|
||||||
|
var tierEnd = tierStart + 1
|
||||||
|
while (tierEnd < sorted.size && sorted[tierEnd].priority == tierPriority) tierEnd++
|
||||||
|
val tierSize = tierEnd - tierStart
|
||||||
|
val tierRotation = if (tierSize > 1) rotation % tierSize else 0
|
||||||
|
for (k in 0 until tierSize) {
|
||||||
|
if (packetBudget <= 64) break@outer
|
||||||
|
val stream = sorted[tierStart + ((tierRotation + k) % tierSize)]
|
||||||
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
|
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
|
||||||
// Skip if both stream and connection have no credit; FIN-only
|
// Skip if both stream and connection have no credit; FIN-only
|
||||||
// (zero-byte) chunks may still go through because they don't
|
// (zero-byte) chunks may still go through because they don't
|
||||||
@@ -461,7 +475,9 @@ private fun buildApplicationPacket(
|
|||||||
conn.sendConnectionFlowConsumed += chunk.data.size
|
conn.sendConnectionFlowConsumed += chunk.data.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.streamRoundRobinStart = (start + 1) % streamsView.size
|
tierStart = tierEnd
|
||||||
|
}
|
||||||
|
conn.streamRoundRobinStart = (rotation + 1) % streamsView.size
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frames.isEmpty()) return null
|
if (frames.isEmpty()) return null
|
||||||
|
|||||||
+57
-21
@@ -112,45 +112,81 @@ class QuicConnectionWriterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun writer_drains_higher_priority_streams_before_lower_priority() {
|
fun writer_drains_higher_priority_streams_before_lower_priority() {
|
||||||
// T11.3 follow-up: the writer's drain loop must iterate streams
|
// T11.3 follow-up: priority must dominate iteration order on
|
||||||
// in descending priority order so moq-lite group streams with a
|
// EVERY drain, not just the first. The naive "sort by priority
|
||||||
// higher sequence number drain ahead of older ones under
|
// then apply the existing rotating start globally" shape looks
|
||||||
// congestion. Pre-fix this test, the writer iterated in stable
|
// right at a glance but the rotating start advances on every
|
||||||
// round-robin order regardless of priority — so a backlog of
|
// drain, so cross-tier ordering flips on alternate drains and
|
||||||
// retransmits on an older group could starve the listener of
|
// the priority hint is silently defeated. The correct shape is
|
||||||
// fresh frames. We pin the load-bearing invariant by inspecting
|
// strict priority across tiers + round-robin only within a
|
||||||
// StreamFrame order in a single emitted packet: low-priority
|
// tier, which we pin here by draining TWICE and asserting the
|
||||||
// stream is opened FIRST (so insertion order would normally win
|
// higher-priority stream's StreamFrame lands first in BOTH
|
||||||
// round-robin), but the high-priority stream's bytes must land
|
// packets. Low-priority stream is opened FIRST so insertion-
|
||||||
// earlier in the packet.
|
// order can't accidentally pass for priority ordering.
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val (client, pipe) = connectedClient()
|
val (client, pipe) = connectedClient()
|
||||||
val low = client.openBidiStream()
|
val low = client.openBidiStream()
|
||||||
val high = client.openBidiStream()
|
val high = client.openBidiStream()
|
||||||
low.priority = 0
|
low.priority = 0
|
||||||
high.priority = 10
|
high.priority = 10
|
||||||
// Distinct payloads small enough to coexist in one packet.
|
|
||||||
val lowPayload = ByteArray(200) { 0xAA.toByte() }
|
repeat(2) { round ->
|
||||||
val highPayload = ByteArray(200) { 0xBB.toByte() }
|
low.send.enqueue(ByteArray(200) { 0xAA.toByte() })
|
||||||
low.send.enqueue(lowPayload)
|
high.send.enqueue(ByteArray(200) { 0xBB.toByte() })
|
||||||
high.send.enqueue(highPayload)
|
|
||||||
|
|
||||||
val datagram = drainOutbound(client, nowMillis = 0L)
|
val datagram = drainOutbound(client, nowMillis = 0L)
|
||||||
assertNotNull(datagram, "drain must emit a packet")
|
assertNotNull(datagram, "drain on round $round must emit a packet")
|
||||||
val frames = pipe.decryptClientApplicationFrames(datagram)
|
val frames = pipe.decryptClientApplicationFrames(datagram)
|
||||||
assertNotNull(frames, "decrypt must succeed at the application level")
|
assertNotNull(frames, "decrypt must succeed on round $round")
|
||||||
val streamFrames = frames.filterIsInstance<StreamFrame>()
|
val streamFrames = frames.filterIsInstance<StreamFrame>()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
2,
|
2,
|
||||||
streamFrames.size,
|
streamFrames.size,
|
||||||
"expected one StreamFrame per stream in this drain, got $streamFrames",
|
"expected one StreamFrame per stream on round $round, got $streamFrames",
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
high.streamId,
|
high.streamId,
|
||||||
streamFrames[0].streamId,
|
streamFrames[0].streamId,
|
||||||
"higher-priority stream must drain first; saw ${streamFrames.map { it.streamId }}",
|
"higher-priority stream must drain first on round $round; " +
|
||||||
|
"saw ${streamFrames.map { it.streamId }}",
|
||||||
)
|
)
|
||||||
assertEquals(low.streamId, streamFrames[1].streamId)
|
assertEquals(low.streamId, streamFrames[1].streamId, "low second on round $round")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writer_round_robins_within_a_priority_tier() {
|
||||||
|
// Regression guard for the tier-local round-robin: same-priority
|
||||||
|
// streams must still rotate so an early-opened stream doesn't
|
||||||
|
// monopolise a packet's stream-frame slot indefinitely. We open
|
||||||
|
// three streams at the default (0) priority and verify the
|
||||||
|
// rotating start advances by one per drain, matching the
|
||||||
|
// pre-priority behaviour.
|
||||||
|
runBlocking {
|
||||||
|
val (client, pipe) = connectedClient()
|
||||||
|
val a = client.openBidiStream()
|
||||||
|
val b = client.openBidiStream()
|
||||||
|
val c = client.openBidiStream()
|
||||||
|
// All default priority — single tier, three streams.
|
||||||
|
val expectedRotation =
|
||||||
|
listOf(
|
||||||
|
listOf(a.streamId, b.streamId, c.streamId),
|
||||||
|
listOf(b.streamId, c.streamId, a.streamId),
|
||||||
|
listOf(c.streamId, a.streamId, b.streamId),
|
||||||
|
)
|
||||||
|
for ((round, expected) in expectedRotation.withIndex()) {
|
||||||
|
a.send.enqueue(ByteArray(64) { 0xA1.toByte() })
|
||||||
|
b.send.enqueue(ByteArray(64) { 0xB2.toByte() })
|
||||||
|
c.send.enqueue(ByteArray(64) { 0xC3.toByte() })
|
||||||
|
|
||||||
|
val datagram = drainOutbound(client, nowMillis = 0L)
|
||||||
|
assertNotNull(datagram, "drain $round must emit a packet")
|
||||||
|
val frames = pipe.decryptClientApplicationFrames(datagram)
|
||||||
|
assertNotNull(frames, "decrypt must succeed on round $round")
|
||||||
|
val ids = frames.filterIsInstance<StreamFrame>().map { it.streamId }
|
||||||
|
assertEquals(expected, ids, "round $round round-robin order")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user