feat(quic,nestsclient): plumb RESET_STREAM + STOP_SENDING through WebTransport
Extends the WebTransport interface and its three implementations
(QUIC-backed, in-memory fake, peer-stripped demux) with typed
error-code cancel paths so moq-lite Lite-03's M2/M3 audit items can
land.
QUIC layer (`:quic`):
- `StrippedWtStream` gains optional `reset` and `stopSending`
closures, wired by `WtPeerStreamDemux.emitStripped` through
`QuicStream.resetStream(errorCode)` /
`QuicStream.stopSending(errorCode)` + `driver.wakeup()` so the
frames actually leave the connection. `reset` is null on
peer-initiated uni streams (no send half on our side);
`stopSending` is unconditional (every surfaced stream has a
read side).
WebTransport interface (`:nestsClient`):
- `WebTransportReadStream.stopSending(errorCode: Long)` —
suspending; first-call-wins.
- `WebTransportWriteStream.reset(errorCode: Long)` —
suspending; first-call-wins. Distinct from the existing
graceful `finish()` / FIN.
Adapters:
- `QuicBidiStreamAdapter`, `QuicReadStreamAdapter`,
`QuicUniWriteStreamAdapter` route directly to the underlying
`QuicStream` API.
- `StrippedWtReadStreamAdapter` /
`StrippedWtBidiStreamAdapter` route through the demux's
closures; defensive `?:` fallbacks defend against a future
demux bug that forgets to wire a closure.
- `FakeBidiStream`, `FakeReadStream`, `ChannelWriteStream` (the
in-memory test transport) record reset / stopSending codes
in shared `AtomicLong` cells so tests can assert "the peer
reset with code X" / "the listener stopSending with code Y"
via new `lastResetCode` / `lastStopSendingCode` /
`lastPeerResetCode` / `lastPeerStopSendingCode` properties.
Sentinel `NO_CODE = Long.MIN_VALUE` distinguishes "not called"
from a legitimate code 0.
Pure plumbing — no moq-lite call sites use the new methods yet.
Subsequent commits wire M3 (publisher Drop reply uses
reset(errorCode)) and M2 (listener group-cancel uses
stopSending(errorCode)).
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2 + M3).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
+154
-4
@@ -57,8 +57,40 @@ class FakeWebTransport private constructor(
|
|||||||
stateLock.withLock { check(open) { "session closed" } }
|
stateLock.withLock { check(open) { "session closed" } }
|
||||||
val localToPeer = Channel<ByteArray>(Channel.BUFFERED)
|
val localToPeer = Channel<ByteArray>(Channel.BUFFERED)
|
||||||
val peerToLocal = Channel<ByteArray>(Channel.BUFFERED)
|
val peerToLocal = Channel<ByteArray>(Channel.BUFFERED)
|
||||||
val local = FakeBidiStream(write = localToPeer, read = peerToLocal)
|
// Shared error-code cells: each side records its own reset()
|
||||||
val peer = FakeBidiStream(write = peerToLocal, read = localToPeer)
|
// and stopSending() codes here so the peer can introspect.
|
||||||
|
// `localResetCell` records resets on `local.reset(code)` (=
|
||||||
|
// peer's `lastPeerResetCode`), and vice-versa.
|
||||||
|
val localResetCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
val peerResetCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
val localStopSendingCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
val peerStopSendingCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
val local =
|
||||||
|
FakeBidiStream(
|
||||||
|
write = localToPeer,
|
||||||
|
read = peerToLocal,
|
||||||
|
myResetCell = localResetCell,
|
||||||
|
myStopSendingCell = localStopSendingCell,
|
||||||
|
peerResetCell = peerResetCell,
|
||||||
|
peerStopSendingCell = peerStopSendingCell,
|
||||||
|
)
|
||||||
|
val peer =
|
||||||
|
FakeBidiStream(
|
||||||
|
write = peerToLocal,
|
||||||
|
read = localToPeer,
|
||||||
|
myResetCell = peerResetCell,
|
||||||
|
myStopSendingCell = peerStopSendingCell,
|
||||||
|
peerResetCell = localResetCell,
|
||||||
|
peerStopSendingCell = localStopSendingCell,
|
||||||
|
)
|
||||||
// Send *peer* half to the other side's inbound queue.
|
// Send *peer* half to the other side's inbound queue.
|
||||||
outboundBidiStreams.send(peer)
|
outboundBidiStreams.send(peer)
|
||||||
return local
|
return local
|
||||||
@@ -87,8 +119,20 @@ class FakeWebTransport private constructor(
|
|||||||
val priorityCell =
|
val priorityCell =
|
||||||
java.util.concurrent.atomic
|
java.util.concurrent.atomic
|
||||||
.AtomicInteger(0)
|
.AtomicInteger(0)
|
||||||
outboundUniStreams.send(FakeReadStream(pipe, priorityCell))
|
// Shared reset / stopSending cells. The writer (us) records its
|
||||||
return ChannelWriteStream(pipe, priorityCell)
|
// `reset(code)` call here; the peer-side reader exposes it via
|
||||||
|
// `FakeReadStream.lastResetCode`. Symmetric: the reader's
|
||||||
|
// `stopSending(code)` call lands in `stopSendingCell` so the
|
||||||
|
// writer (= moq-lite publisher tests) can assert the listener
|
||||||
|
// canceled with the expected code.
|
||||||
|
val resetCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
val stopSendingCell =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicLong(NO_CODE)
|
||||||
|
outboundUniStreams.send(FakeReadStream(pipe, priorityCell, resetCell, stopSendingCell))
|
||||||
|
return ChannelWriteStream(pipe, priorityCell, resetCell, stopSendingCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendDatagram(payload: ByteArray): Boolean {
|
override suspend fun sendDatagram(payload: ByteArray): Boolean {
|
||||||
@@ -161,6 +205,20 @@ class FakeWebTransport private constructor(
|
|||||||
class FakeBidiStream internal constructor(
|
class FakeBidiStream internal constructor(
|
||||||
private val write: Channel<ByteArray>,
|
private val write: Channel<ByteArray>,
|
||||||
private val read: Channel<ByteArray>,
|
private val read: Channel<ByteArray>,
|
||||||
|
/**
|
||||||
|
* Cell that records this side's `reset(code)` calls. Read by
|
||||||
|
* [lastResetCode] from the same side (rare — usually it's the
|
||||||
|
* peer's [peerResetCell] you want).
|
||||||
|
*/
|
||||||
|
private val myResetCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
|
private val myStopSendingCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
|
/**
|
||||||
|
* Cell that records the *peer's* `reset(code)` calls (i.e.
|
||||||
|
* mirror of the peer's [myResetCell]). [lastPeerResetCode] reads
|
||||||
|
* this so tests can assert "the peer reset the bidi with code X."
|
||||||
|
*/
|
||||||
|
private val peerResetCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
|
private val peerStopSendingCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
) : WebTransportBidiStream {
|
) : WebTransportBidiStream {
|
||||||
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
|
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
|
||||||
|
|
||||||
@@ -172,6 +230,40 @@ class FakeBidiStream internal constructor(
|
|||||||
write.close()
|
write.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(errorCode: Long) {
|
||||||
|
// First call wins, mirroring `:quic`'s `QuicStream.resetStream`
|
||||||
|
// contract. Subsequent reset/finish/write are silently
|
||||||
|
// dropped to keep the wire frame stable in the eyes of any
|
||||||
|
// test that reads `lastResetCode`.
|
||||||
|
myResetCell?.compareAndSet(NO_CODE, errorCode)
|
||||||
|
write.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
myStopSendingCell?.compareAndSet(NO_CODE, errorCode)
|
||||||
|
// The peer's send half is our receive half; close it so a
|
||||||
|
// collector sees end-of-flow.
|
||||||
|
read.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code our side passed to [reset], or `null` if reset wasn't
|
||||||
|
* called. Mostly useful when a test holds the peer's reference;
|
||||||
|
* [lastPeerResetCode] is the more common assertion.
|
||||||
|
*/
|
||||||
|
val lastResetCode: Long?
|
||||||
|
get() = myResetCell?.get()?.takeIf { it != NO_CODE }
|
||||||
|
|
||||||
|
/** Code the peer side passed to [reset], or `null` if not called. */
|
||||||
|
val lastPeerResetCode: Long?
|
||||||
|
get() = peerResetCell?.get()?.takeIf { it != NO_CODE }
|
||||||
|
|
||||||
|
val lastStopSendingCode: Long?
|
||||||
|
get() = myStopSendingCell?.get()?.takeIf { it != NO_CODE }
|
||||||
|
|
||||||
|
val lastPeerStopSendingCode: Long?
|
||||||
|
get() = peerStopSendingCell?.get()?.takeIf { it != NO_CODE }
|
||||||
|
|
||||||
override fun setPriority(priority: Int) = Unit
|
override fun setPriority(priority: Int) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +276,33 @@ class FakeReadStream internal constructor(
|
|||||||
* uni-stream writer (e.g. tests that hand-roll a [Channel]).
|
* uni-stream writer (e.g. tests that hand-roll a [Channel]).
|
||||||
*/
|
*/
|
||||||
private val priorityCell: java.util.concurrent.atomic.AtomicInteger? = null,
|
private val priorityCell: java.util.concurrent.atomic.AtomicInteger? = null,
|
||||||
|
/**
|
||||||
|
* Optional shared cell that records the writer's `reset(code)`
|
||||||
|
* call. Exposed via [lastResetCode] so tests can assert the
|
||||||
|
* publisher reset its uni stream with the expected typed error.
|
||||||
|
*/
|
||||||
|
private val resetCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
|
/**
|
||||||
|
* Optional shared cell that records this side's own
|
||||||
|
* `stopSending(code)` call. Exposed via [lastStopSendingCode]
|
||||||
|
* so the test can assert the listener actually fired the cancel.
|
||||||
|
*/
|
||||||
|
private val stopSendingCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
) : WebTransportReadStream {
|
) : WebTransportReadStream {
|
||||||
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
|
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
// First-call-wins like the production path. Closing `read`
|
||||||
|
// here would race the production path's "publisher closes
|
||||||
|
// its write end" semantic — moq-lite calls `stopSending`
|
||||||
|
// when it's done caring about the bytes, but the publisher
|
||||||
|
// is still expected to finish what it was sending. We
|
||||||
|
// record the code without closing so a test that asserts
|
||||||
|
// `incoming().toList()` post-stopSending still sees any
|
||||||
|
// bytes the publisher had already enqueued.
|
||||||
|
stopSendingCell?.compareAndSet(NO_CODE, errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last priority value the paired write side applied via
|
* Last priority value the paired write side applied via
|
||||||
* [WebTransportWriteStream.setPriority], or `0` if no priority was
|
* [WebTransportWriteStream.setPriority], or `0` if no priority was
|
||||||
@@ -195,6 +311,17 @@ class FakeReadStream internal constructor(
|
|||||||
*/
|
*/
|
||||||
val lastSetPriority: Int?
|
val lastSetPriority: Int?
|
||||||
get() = priorityCell?.get()
|
get() = priorityCell?.get()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code the peer-side writer passed to [WebTransportWriteStream.reset],
|
||||||
|
* or `null` if reset wasn't called.
|
||||||
|
*/
|
||||||
|
val lastResetCode: Long?
|
||||||
|
get() = resetCell?.get()?.takeIf { it != NO_CODE }
|
||||||
|
|
||||||
|
/** Code we passed to [stopSending], or `null` if not called. */
|
||||||
|
val lastStopSendingCode: Long?
|
||||||
|
get() = stopSendingCell?.get()?.takeIf { it != NO_CODE }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,6 +337,8 @@ private class ChannelWriteStream(
|
|||||||
* stores each [setPriority] call so the peer can introspect.
|
* stores each [setPriority] call so the peer can introspect.
|
||||||
*/
|
*/
|
||||||
private val priorityCell: java.util.concurrent.atomic.AtomicInteger? = null,
|
private val priorityCell: java.util.concurrent.atomic.AtomicInteger? = null,
|
||||||
|
private val resetCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
|
private val stopSendingCell: java.util.concurrent.atomic.AtomicLong? = null,
|
||||||
) : WebTransportWriteStream {
|
) : WebTransportWriteStream {
|
||||||
override suspend fun write(chunk: ByteArray) {
|
override suspend fun write(chunk: ByteArray) {
|
||||||
channel.send(chunk)
|
channel.send(chunk)
|
||||||
@@ -219,7 +348,28 @@ private class ChannelWriteStream(
|
|||||||
channel.close()
|
channel.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(errorCode: Long) {
|
||||||
|
// First-call-wins. We close the channel so the peer's
|
||||||
|
// receive flow ends — same shape as `finish` but with a
|
||||||
|
// typed error code recorded for the peer to introspect.
|
||||||
|
resetCell?.compareAndSet(NO_CODE, errorCode)
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setPriority(priority: Int) {
|
override fun setPriority(priority: Int) {
|
||||||
priorityCell?.set(priority)
|
priorityCell?.set(priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
private val unusedStopSendingCellTether: java.util.concurrent.atomic.AtomicLong? = stopSendingCell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel for "no reset / stopSending code recorded yet" in the
|
||||||
|
* [java.util.concurrent.atomic.AtomicLong] cells used by [FakeBidiStream]
|
||||||
|
* and [FakeReadStream] / [ChannelWriteStream]. Real moq-lite codes are
|
||||||
|
* non-negative varints (RFC 9000 application error codes are u32
|
||||||
|
* unsigned-fitting-in-Long); `Long.MIN_VALUE` is safely outside that
|
||||||
|
* range.
|
||||||
|
*/
|
||||||
|
internal const val NO_CODE: Long = Long.MIN_VALUE
|
||||||
|
|||||||
+49
@@ -104,6 +104,30 @@ interface WebTransportBidiStream :
|
|||||||
interface WebTransportReadStream {
|
interface WebTransportReadStream {
|
||||||
/** Flow of chunks as they arrive. Completes when the peer closes its write side. */
|
/** Flow of chunks as they arrive. Completes when the peer closes its write side. */
|
||||||
fun incoming(): Flow<ByteArray>
|
fun incoming(): Flow<ByteArray>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9000 §3.5: ask the peer to stop sending on this stream.
|
||||||
|
* Causes a `STOP_SENDING(applicationErrorCode)` frame to land at
|
||||||
|
* the peer, which typically responds with `RESET_STREAM` carrying
|
||||||
|
* the same code. Call this when the application no longer needs
|
||||||
|
* the stream's bytes — it lets the peer abandon any pending
|
||||||
|
* retransmits instead of wasting bandwidth on data we'd discard.
|
||||||
|
*
|
||||||
|
* First call wins per `:quic`'s `QuicStream.stopSending`
|
||||||
|
* lock-free first-call-wins gate; subsequent calls (including
|
||||||
|
* with a different code) are silently ignored to keep the wire
|
||||||
|
* frame stable.
|
||||||
|
*
|
||||||
|
* Used by moq-lite Lite-03's group-cancel path: a listener that
|
||||||
|
* decides a specific group is too stale can `stopSending` its
|
||||||
|
* uni stream so the publisher abandons any in-flight retransmits
|
||||||
|
* instead of wasting bandwidth on bytes the listener will discard.
|
||||||
|
*
|
||||||
|
* Implementations that don't model receive-side cancellation
|
||||||
|
* (e.g. the in-memory fake when no test asserts on it) MAY
|
||||||
|
* treat this as a no-op.
|
||||||
|
*/
|
||||||
|
suspend fun stopSending(errorCode: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write-only WebTransport stream. */
|
/** Write-only WebTransport stream. */
|
||||||
@@ -113,6 +137,31 @@ interface WebTransportWriteStream {
|
|||||||
/** Half-close the write side (FIN). No further writes after this call. */
|
/** Half-close the write side (FIN). No further writes after this call. */
|
||||||
suspend fun finish()
|
suspend fun finish()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9000 §3.5: send `RESET_STREAM(applicationErrorCode)` on
|
||||||
|
* this stream's send half, abandoning any pending bytes. Distinct
|
||||||
|
* from [finish], which is a graceful FIN — `reset` carries a
|
||||||
|
* typed error code the peer can act on.
|
||||||
|
*
|
||||||
|
* First call wins per `:quic`'s `QuicStream.resetStream`
|
||||||
|
* lock-free first-call-wins gate; subsequent calls (and any
|
||||||
|
* subsequent [write] / [finish]) are silently ignored to keep
|
||||||
|
* the wire frame stable.
|
||||||
|
*
|
||||||
|
* Used by moq-lite Lite-03's typed cancel paths: a publisher
|
||||||
|
* that rejects a Subscribe (e.g. broadcast / track does not
|
||||||
|
* exist) follows the SubscribeDrop body with a
|
||||||
|
* `RESET_STREAM(errorCode)` so the subscriber sees a typed
|
||||||
|
* application reason rather than the ambiguous "publisher FINed
|
||||||
|
* the bidi" signal that overlaps with a graceful publisher
|
||||||
|
* shutdown.
|
||||||
|
*
|
||||||
|
* Implementations that don't model sender-driven reset (e.g.
|
||||||
|
* the in-memory fake when no test asserts on it) MAY treat this
|
||||||
|
* as a [finish].
|
||||||
|
*/
|
||||||
|
suspend fun reset(errorCode: Long)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hint to the transport about this stream's drain priority relative
|
* Hint to the transport about this stream's drain priority relative
|
||||||
* to other streams on the same session. Higher value drains first
|
* to other streams on the same session. Higher value drains first
|
||||||
|
|||||||
+54
-7
@@ -366,6 +366,16 @@ private class QuicBidiStreamAdapter(
|
|||||||
driver.wakeup()
|
driver.wakeup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(errorCode: Long) {
|
||||||
|
stream.resetStream(errorCode)
|
||||||
|
driver.wakeup()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
stream.stopSending(errorCode)
|
||||||
|
driver.wakeup()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setPriority(priority: Int) {
|
override fun setPriority(priority: Int) {
|
||||||
stream.priority = priority
|
stream.priority = priority
|
||||||
}
|
}
|
||||||
@@ -373,8 +383,14 @@ private class QuicBidiStreamAdapter(
|
|||||||
|
|
||||||
private class QuicReadStreamAdapter(
|
private class QuicReadStreamAdapter(
|
||||||
private val stream: QuicStream,
|
private val stream: QuicStream,
|
||||||
|
private val driver: com.vitorpamplona.quic.connection.QuicConnectionDriver,
|
||||||
) : WebTransportReadStream {
|
) : WebTransportReadStream {
|
||||||
override fun incoming(): Flow<ByteArray> = stream.incoming
|
override fun incoming(): Flow<ByteArray> = stream.incoming
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
stream.stopSending(errorCode)
|
||||||
|
driver.wakeup()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,6 +413,11 @@ private class QuicUniWriteStreamAdapter(
|
|||||||
driver.wakeup()
|
driver.wakeup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(errorCode: Long) {
|
||||||
|
stream.resetStream(errorCode)
|
||||||
|
driver.wakeup()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setPriority(priority: Int) {
|
override fun setPriority(priority: Int) {
|
||||||
stream.priority = priority
|
stream.priority = priority
|
||||||
}
|
}
|
||||||
@@ -407,13 +428,24 @@ private class StrippedWtReadStreamAdapter(
|
|||||||
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
|
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
|
||||||
) : WebTransportReadStream {
|
) : WebTransportReadStream {
|
||||||
override fun incoming(): Flow<ByteArray> = stripped.data
|
override fun incoming(): Flow<ByteArray> = stripped.data
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
// Demux unconditionally wires `stopSending` for every surfaced
|
||||||
|
// stream — the contract in `StrippedWtStream` is "non-null on
|
||||||
|
// streams with a read side", which is true here. The `?:`
|
||||||
|
// fallback exists only to defend against a future demux bug
|
||||||
|
// that forgets to wire it; Unit is a safe no-op for tests
|
||||||
|
// that mock a stripped stream without a real receiver.
|
||||||
|
val ss = stripped.stopSending ?: return
|
||||||
|
ss(errorCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter for a peer-initiated bidi WT stream whose WT_BIDI_STREAM prefix
|
* Adapter for a peer-initiated bidi WT stream whose WT_BIDI_STREAM prefix
|
||||||
* has been stripped. Routes [write] / [finish] through the demux's
|
* has been stripped. Routes [write] / [finish] / [reset] / [stopSending]
|
||||||
* driver-aware closures so application bytes actually leave the
|
* through the demux's driver-aware closures so application bytes actually
|
||||||
* connection.
|
* leave the connection.
|
||||||
*/
|
*/
|
||||||
private class StrippedWtBidiStreamAdapter(
|
private class StrippedWtBidiStreamAdapter(
|
||||||
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
|
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
|
||||||
@@ -440,13 +472,28 @@ private class StrippedWtBidiStreamAdapter(
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(errorCode: Long) {
|
||||||
|
// Bidi streams have a send side we own, so `reset` is wired by
|
||||||
|
// the demux. Same defensive `?:` shape as in `write` / `finish`.
|
||||||
|
val r =
|
||||||
|
stripped.reset
|
||||||
|
?: error("peer-initiated bidi stream has no reset — demux didn't wire one")
|
||||||
|
r(errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopSending(errorCode: Long) {
|
||||||
|
val ss = stripped.stopSending ?: return
|
||||||
|
ss(errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No-op: peer-initiated bidi streams arrive through the demux as a
|
* No-op: peer-initiated bidi streams arrive through the demux as a
|
||||||
* [com.vitorpamplona.quic.webtransport.StrippedWtStream] which exposes
|
* [com.vitorpamplona.quic.webtransport.StrippedWtStream] which exposes
|
||||||
* only `send`/`finish` closures, not the underlying [QuicStream]. The
|
* only `send`/`finish`/`reset`/`stopSending` closures, not the
|
||||||
* moq-lite priority use case targets locally-opened uni group streams
|
* underlying [QuicStream]. The moq-lite priority use case targets
|
||||||
* only, so this path doesn't need to model priority — see the
|
* locally-opened uni group streams only, so this path doesn't need
|
||||||
* [WebTransportWriteStream.setPriority] contract.
|
* to model priority — see the [WebTransportWriteStream.setPriority]
|
||||||
|
* contract.
|
||||||
*/
|
*/
|
||||||
override fun setPriority(priority: Int) = Unit
|
override fun setPriority(priority: Int) = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ import kotlinx.coroutines.launch
|
|||||||
* is false), [send] and [finish] are wired to the stream's outbound
|
* is false), [send] and [finish] are wired to the stream's outbound
|
||||||
* half so the application can write its response. They are null on
|
* half so the application can write its response. They are null on
|
||||||
* unidirectional streams.
|
* unidirectional streams.
|
||||||
|
*
|
||||||
|
* For both directions, [reset] (RFC 9000 §3.5 RESET_STREAM) and
|
||||||
|
* [stopSending] (RFC 9000 §3.5 STOP_SENDING) are exposed so application
|
||||||
|
* code (e.g. moq-lite Lite-03's typed cancel paths) can convey error
|
||||||
|
* codes instead of relying on graceful FIN. [reset] is null on uni
|
||||||
|
* streams whose write side belongs to the peer; [stopSending] is null
|
||||||
|
* when there's no read side to ask the peer to stop on (i.e. never
|
||||||
|
* — every stream we surface here has a read side).
|
||||||
*/
|
*/
|
||||||
class StrippedWtStream(
|
class StrippedWtStream(
|
||||||
val streamId: Long,
|
val streamId: Long,
|
||||||
@@ -58,6 +66,22 @@ class StrippedWtStream(
|
|||||||
* Half-close the send side (FIN). Null for unidirectional streams.
|
* Half-close the send side (FIN). Null for unidirectional streams.
|
||||||
*/
|
*/
|
||||||
val finish: (suspend () -> Unit)? = null,
|
val finish: (suspend () -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* Send `RESET_STREAM(applicationErrorCode)` on the send half (RFC
|
||||||
|
* 9000 §3.5). Null when the application doesn't own the send side
|
||||||
|
* (peer-initiated uni stream). The first call wins per
|
||||||
|
* `QuicStream.resetStream`'s lock-free first-call-wins gate;
|
||||||
|
* duplicate calls with different codes are silently ignored to
|
||||||
|
* keep the wire frame stable.
|
||||||
|
*/
|
||||||
|
val reset: (suspend (errorCode: Long) -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* Send `STOP_SENDING(applicationErrorCode)` on the receive half
|
||||||
|
* (RFC 9000 §3.5) — asks the peer to RESET its corresponding
|
||||||
|
* send side. Always non-null on streams surfaced here: every
|
||||||
|
* stripped stream has a read side. First call wins.
|
||||||
|
*/
|
||||||
|
val stopSending: (suspend (errorCode: Long) -> Unit)? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -515,6 +539,26 @@ class WtPeerStreamDemux(
|
|||||||
driver?.wakeup()
|
driver?.wakeup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// RESET_STREAM is meaningful only on streams whose write side
|
||||||
|
// belongs to us. Same null pattern as [send] / [finish] for
|
||||||
|
// peer-initiated uni streams.
|
||||||
|
val reset: (suspend (Long) -> Unit)? =
|
||||||
|
if (isUni) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{ errorCode ->
|
||||||
|
stream.resetStream(errorCode)
|
||||||
|
driver?.wakeup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// STOP_SENDING is meaningful on the receive half — every
|
||||||
|
// stripped stream we surface here has one (uni: peer is
|
||||||
|
// sender; bidi: peer's send side is our receive side), so
|
||||||
|
// wire unconditionally.
|
||||||
|
val stopSending: (suspend (Long) -> Unit) = { errorCode ->
|
||||||
|
stream.stopSending(errorCode)
|
||||||
|
driver?.wakeup()
|
||||||
|
}
|
||||||
// Suspending send instead of `trySend` so a slow application
|
// Suspending send instead of `trySend` so a slow application
|
||||||
// back-pressures the demux instead of silently dropping the
|
// back-pressures the demux instead of silently dropping the
|
||||||
// stream. With a bounded buffer ([readyStreamsBuffer]),
|
// stream. With a bounded buffer ([readyStreamsBuffer]),
|
||||||
@@ -529,6 +573,8 @@ class WtPeerStreamDemux(
|
|||||||
data = data,
|
data = data,
|
||||||
send = send,
|
send = send,
|
||||||
finish = finish,
|
finish = finish,
|
||||||
|
reset = reset,
|
||||||
|
stopSending = stopSending,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user