fix(nestsclient): keep stream priority pack non-negative — Lite-03 priority bit-layout
Pre-fix, the M1 priority pack `((trackPriority and 0xFF) shl 24)`
would set bit 31 whenever `trackPriority >= 0x80`, producing a
negative `Int`. `QuicConnectionWriter.sortedByDescending { it.priority }`
sorts via signed `Int.compareTo`, so negative-signed values land
BELOW every positive priority — exactly inverting the intended
"higher trackPriority drains first" ordering.
The default `DEFAULT_TRACK_PRIORITY = 0x80` sits exactly on this
boundary, so this would have hit production the moment a hand-
tuned `trackPriority=0xFF` (highest intended) appeared in any
multi-track scenario.
Reshape the layout to keep bit 31 clear:
bit 31 : 0 (reserved as the sign bit)
bits 30..23 : trackPriority u8 (0..255)
bits 22..0 : sequence low 23 bits (~97 days at 1 grp/sec)
The trackPriority byte still occupies an 8-bit slot, just shifted
one bit lower. Sequence saturation moves from 24 bits (≈ 6 days)
to 23 bits (≈ 97 days) — still ample for the 9-minute JWT refresh
cycle.
Two regression tests:
- The existing `publisher_packs_trackPriority_and_sequence_into_setPriority_value`
is updated to assert the new bit layout AND that the encoded
value is non-negative.
- New `publisher_priority_pack_keeps_top_trackPriority_above_lower_trackPriority`
is the direct guard against the bug: `trackPriority=0xFF`
must encode HIGHER than `trackPriority=0x7F`. Pre-fix this
test fails with `0xFF` encoding to a negative Int below the
positive `0x7F` encoding; post-fix both are positive and
correctly ordered.
Surfaced by the merge-prep security review of the moq-lite
Lite-03/04 audit branch — agent flagged it as "QoS/correctness
not security, excluded by DoS rule" but it's a real bug worth
fixing before merge.
Audit doc updated: bit layout in `nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md`
(M1 + L4 entries).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
+22
-10
@@ -1264,18 +1264,30 @@ class MoqLiteSession internal constructor(
|
||||
// queue gets congested.
|
||||
//
|
||||
// Wire layout into `Int`:
|
||||
// bits 31..24 trackPriority u8 (0..255) — top byte
|
||||
// bits 23..0 sequence low 24 bits — wraps every
|
||||
// ~16M groups within a single track
|
||||
// bit 31 0 (always — keeps the value non-negative)
|
||||
// bits 30..23 trackPriority u8 (0..255)
|
||||
// bits 22..0 sequence low 23 bits (~8.4M groups)
|
||||
//
|
||||
// Bit 31 is reserved as the sign bit because
|
||||
// [com.vitorpamplona.quic.connection.QuicConnectionWriter] sorts
|
||||
// streams via signed `sortedByDescending { it.priority }`.
|
||||
// Setting bit 31 (which would happen for `trackPriority >= 0x80`
|
||||
// shifted by 24) produces a negative `Int` that sorts BELOW
|
||||
// positive priorities, inverting the intent. The audit's
|
||||
// priority-byte default `DEFAULT_TRACK_PRIORITY = 0x80` sits
|
||||
// exactly on this boundary — without the 23-bit shift the
|
||||
// default-vs-default case would be fine but a hand-tuned 0xFF
|
||||
// (highest intended) would sort behind 0x7F (lower intended).
|
||||
//
|
||||
// Saturating cast on `sequence` guards a theoretical broadcast
|
||||
// long enough to outgrow 24 bits (≈ 6 days at the production
|
||||
// 1-group/sec cadence; the priority degrades to "all newer
|
||||
// groups tie" past that, but they still beat older groups of
|
||||
// any LOWER-priority track via the top byte). Defensive only;
|
||||
// production sessions cycle on JWT refresh every 9 min.
|
||||
val seq24 = sequence.coerceAtMost(0xFF_FFFFL).toInt() and 0x00FF_FFFF
|
||||
val packedPriority = ((trackPriority and 0xFF) shl 24) or seq24
|
||||
// long enough to outgrow 23 bits (≈ 97 days at the production
|
||||
// 1-group/sec cadence). Past that the priority degrades to "all
|
||||
// newer groups within a track tie", but they still beat older
|
||||
// groups of any LOWER-priority track via the high byte.
|
||||
// Production sessions cycle on JWT refresh every 9 min, so the
|
||||
// wrap is defensive only.
|
||||
val seq23 = sequence.coerceAtMost(0x7F_FFFFL).toInt() and 0x007F_FFFF
|
||||
val packedPriority = ((trackPriority and 0xFF) shl 23) or seq23
|
||||
uni.setPriority(packedPriority)
|
||||
uni.write(Varint.encode(MoqLiteDataType.Group.code))
|
||||
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId, sequence)))
|
||||
|
||||
+95
-15
@@ -507,20 +507,23 @@ class MoqLiteSessionTest {
|
||||
fun publisher_packs_trackPriority_and_sequence_into_setPriority_value() =
|
||||
runBlocking {
|
||||
// Lite-03 audit M1: openGroupStream packs the publisher's
|
||||
// trackPriority byte into bits 31..24 and the group sequence
|
||||
// into bits 23..0 of the value passed to
|
||||
// WebTransportWriteStream.setPriority. This mirrors the
|
||||
// (track.priority, sequence) ordering kixelated's
|
||||
// PriorityHandle uses at `rs/moq-lite/src/lite/priority.rs`.
|
||||
// Verified on the *peer* side via FakeReadStream.lastSetPriority,
|
||||
// which the in-memory transport uses as a side-channel for
|
||||
// priority introspection.
|
||||
// trackPriority byte into bits 30..23 and the group sequence
|
||||
// into bits 22..0 of the value passed to
|
||||
// WebTransportWriteStream.setPriority. Bit 31 stays 0 so
|
||||
// the resulting Int is always non-negative — required
|
||||
// because [QuicConnectionWriter] sorts via signed
|
||||
// `sortedByDescending { it.priority }`. Verified on the
|
||||
// *peer* side via FakeReadStream.lastSetPriority, which the
|
||||
// in-memory transport uses as a side-channel for priority
|
||||
// introspection.
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
// Use a non-default trackPriority so a regression that
|
||||
// accidentally drops it back to DEFAULT_TRACK_PRIORITY shows
|
||||
// up in the assertion.
|
||||
// up in the assertion. 0xC0 sits in the upper half of the
|
||||
// byte range — pre-fix this would have produced a negative
|
||||
// Int (bit 31 set) that mis-sorts.
|
||||
val publisher =
|
||||
session.publish(
|
||||
broadcastSuffix = "speakerPubkey",
|
||||
@@ -552,12 +555,18 @@ class MoqLiteSessionTest {
|
||||
|
||||
val relayUni = withTimeout(2_000) { serverSide.incomingUniStreams().first() }
|
||||
val fakeRead = relayUni as FakeReadStream
|
||||
// Expected: (0xC0 shl 24) or (7 and 0x00FF_FFFF) =
|
||||
// 0xC000_0007 in unsigned terms = -1073741817 as Int.
|
||||
// Compute via the same formula so the test asserts the
|
||||
// CONTRACT, not a hex literal.
|
||||
val expected = ((0xC0 and 0xFF) shl 24) or (7 and 0x00FF_FFFF)
|
||||
assertEquals(expected, fakeRead.lastSetPriority, "trackPriority dominates the high byte; sequence fills the low 24 bits")
|
||||
// Expected: (0xC0 shl 23) or (7 and 0x7FFFFF). Compute via
|
||||
// the same formula so the test asserts the CONTRACT, not
|
||||
// a hex literal.
|
||||
val expected = ((0xC0 and 0xFF) shl 23) or (7 and 0x007F_FFFF)
|
||||
assertEquals(expected, fakeRead.lastSetPriority, "trackPriority occupies bits 30..23; sequence fills the low 23 bits")
|
||||
// Critical guard: the encoded value MUST be non-negative so
|
||||
// the QUIC writer's signed `sortedByDescending` orders it
|
||||
// correctly relative to lower-trackPriority streams.
|
||||
kotlin.test.assertTrue(
|
||||
(fakeRead.lastSetPriority ?: -1) >= 0,
|
||||
"encoded priority must be non-negative — bit 31 is reserved as the sign bit",
|
||||
)
|
||||
|
||||
// Drain the stream so the cleanup path doesn't leak.
|
||||
relayUni.incoming().toList()
|
||||
@@ -565,6 +574,77 @@ class MoqLiteSessionTest {
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_priority_pack_keeps_top_trackPriority_above_lower_trackPriority() =
|
||||
runBlocking {
|
||||
// Direct guard against the pre-fix sign-extension bug: a
|
||||
// higher trackPriority MUST produce a higher encoded
|
||||
// priority value. Pre-fix `trackPriority=0xFF` with
|
||||
// sequence=0 produced a negative Int that sorted BELOW
|
||||
// `trackPriority=0x7F` with the same sequence — exactly
|
||||
// the inversion the audit M1 fix was supposed to prevent.
|
||||
// We exercise the path by opening a uni stream via a
|
||||
// publisher of each priority and comparing.
|
||||
val (clientSide1, serverSide1) = FakeWebTransport.pair()
|
||||
val (clientSide2, serverSide2) = FakeWebTransport.pair()
|
||||
val sessionHigh = MoqLiteSession.client(clientSide1, pumpScope)
|
||||
val sessionLow = MoqLiteSession.client(clientSide2, pumpScope)
|
||||
|
||||
val pubHigh =
|
||||
sessionHigh.publish(
|
||||
broadcastSuffix = "spk",
|
||||
track = "audio/data",
|
||||
trackPriority = 0xFF,
|
||||
)
|
||||
val pubLow =
|
||||
sessionLow.publish(
|
||||
broadcastSuffix = "spk",
|
||||
track = "audio/data",
|
||||
trackPriority = 0x7F,
|
||||
)
|
||||
|
||||
// Wire each up with a subscriber so send() doesn't drop.
|
||||
for ((server, _) in listOf(serverSide1 to "high", serverSide2 to "low")) {
|
||||
val sub = server.openBidiStream()
|
||||
sub.write(Varint.encode(MoqLiteControlType.Subscribe.code))
|
||||
sub.write(
|
||||
MoqLiteCodec.encodeSubscribe(
|
||||
MoqLiteSubscribe(
|
||||
id = 1L,
|
||||
broadcast = "spk",
|
||||
track = "audio/data",
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
withTimeout(2_000) { sub.incoming().first() }
|
||||
}
|
||||
|
||||
pubHigh.send("h".encodeToByteArray())
|
||||
pubHigh.endGroup()
|
||||
pubLow.send("l".encodeToByteArray())
|
||||
pubLow.endGroup()
|
||||
|
||||
val highUni = withTimeout(2_000) { serverSide1.incomingUniStreams().first() } as FakeReadStream
|
||||
val lowUni = withTimeout(2_000) { serverSide2.incomingUniStreams().first() } as FakeReadStream
|
||||
val highPriority = highUni.lastSetPriority ?: error("high priority not set")
|
||||
val lowPriority = lowUni.lastSetPriority ?: error("low priority not set")
|
||||
|
||||
kotlin.test.assertTrue(highPriority > lowPriority, "0xFF trackPriority must encode higher than 0x7F (was $highPriority vs $lowPriority)")
|
||||
kotlin.test.assertTrue(highPriority >= 0, "0xFF trackPriority encoding must stay non-negative (was $highPriority)")
|
||||
|
||||
highUni.incoming().toList()
|
||||
lowUni.incoming().toList()
|
||||
pubHigh.close()
|
||||
pubLow.close()
|
||||
sessionHigh.close()
|
||||
sessionLow.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_send_returns_false_when_no_inbound_subscriber() =
|
||||
runBlocking {
|
||||
|
||||
Reference in New Issue
Block a user