mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(quic): heap-sampling soak test, FD-leak canary, phantom-stream guard, loss harness
Follow-up to b8c6e080 addressing the gaps I called out in the "is this the best we can do?" reply. 1. **Heap-sampling soak test** (`QuicHeapSoakTest`). Long-form, default- skipped via `-PquicSoakSeconds=N` propagated by `quic/build.gradle.kts` to the jvmTest task. Without the property the test early-returns with a printed SKIPPED line, so `./gradlew test` stays fast for CI. With the property, drives moq-lite-shaped peer-uni churn at ~50 streams/s for N seconds, samples `totalMemory - freeMemory` six times across the run, and fails if the post-warmup → final delta exceeds 10 MB (the acceptance threshold from the audio-rooms soak prompt). Production use: `-PquicSoakSeconds=1800` for the 30-minute soak. 2. **FD-leak canary** added to `QuicConnectionDriverLifecycleTest`. On Linux, samples `/proc/self/fd` size before / after the 100-session loop; banded at +16 entries for ambient JVM noise. macOS / Windows silently no-op because /proc isn't there. Catches socket / pipe leaks the thread-count check would miss. 3. **Phantom-stream guard.** Added `retiredStreamIdSet` (capped FIFO ring at 4 096 entries, ~80 s of moq-lite churn) plus `isStreamIdRetiredLocked` on the connection. Parser checks before `getOrCreatePeerStreamLocked` and drops STREAM frames the peer retransmits on already-retired streams. Eliminates the "duplicate ACK lost → peer retransmits FIN → we mint a phantom QuicStream" edge case I papered over in the previous commit. Pinned by `phantomGuardDropsRetransmitOnRetiredPeerStream`. 4. **moq-lite loss harness** (`MoqLiteLossHarnessTest`). First pass at soak target #5: drive 50 best-effort group streams with 5% uniform packet loss, assert the listener surfaces ≥ 90% with payloads intact and the connection stays CONNECTED. Out of scope here: reorder injection, latency-under-loss measurement, full end-to-end with a real moq-lite publisher. Tests: - `QuicHeapSoakTest` — gated, validates 10MB heap acceptance band. - `QuicConnectionDriverLifecycleTest::repeatedSessionLifecycleDoesNotLeakThreads` — now also enforces /proc/self/fd bound. - `StreamRetirementSoakTest::phantomGuardDropsRetransmitOnRetiredPeerStream` — pins the duplicate-frame drop semantics. - `MoqLiteLossHarnessTest` — 2 cases (lossy + lossRate=0 baseline). https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
This commit is contained in:
@@ -124,3 +124,14 @@ tasks.register<JavaExec>("interop") {
|
|||||||
args(host, port)
|
args(host, port)
|
||||||
systemProperty("interopTimeoutSec", timeoutSec)
|
systemProperty("interopTimeoutSec", timeoutSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long-form audio-rooms soak test. Disabled by default — `./gradlew test`
|
||||||
|
// must stay fast for CI. Opt in with `-PquicSoakSeconds=N` (e.g. 1800 for
|
||||||
|
// the 30-minute run from the soak prompt). Without the property the test
|
||||||
|
// class checks for null and skips via `Assume.assumeTrue`.
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
val soakSeconds = (project.findProperty("quicSoakSeconds") as? String)
|
||||||
|
if (soakSeconds != null) {
|
||||||
|
systemProperty("quicSoakSeconds", soakSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -350,6 +350,33 @@ class QuicConnection(
|
|||||||
internal var retiredStreamsCount: Long = 0L
|
internal var retiredStreamsCount: Long = 0L
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIFO ring of recently-retired stream IDs. The parser consults
|
||||||
|
* this in [getOrCreatePeerStreamLocked] to drop duplicate STREAM
|
||||||
|
* frames the peer might retransmit on a stream we've already torn
|
||||||
|
* down — without this guard, a retransmit (which can happen if our
|
||||||
|
* ACK of the FIN frame was lost and the peer's loss detector
|
||||||
|
* re-fired) would create a fresh QuicStream object, deliver
|
||||||
|
* duplicate bytes to the application, and bump
|
||||||
|
* [peerInitiatedUniCount] a second time.
|
||||||
|
*
|
||||||
|
* Bounded at [RETIRED_STREAM_ID_RING_SIZE] entries. Eviction is
|
||||||
|
* FIFO so a long-running session never grows this set unbounded —
|
||||||
|
* at moq-lite churn rates of ~50 streams/sec, the ring covers the
|
||||||
|
* last ~80 seconds of retired IDs, far longer than the peer's
|
||||||
|
* loss-detection retransmit window (a small multiple of RTT).
|
||||||
|
*
|
||||||
|
* Out-of-bounds duplicate retransmits (extremely rare — would need
|
||||||
|
* the peer's ACK to be lost AND our retransmit not arriving for
|
||||||
|
* many seconds) fall through to the existing
|
||||||
|
* "create-and-immediately-retire" path, which is the previous
|
||||||
|
* pre-guard behavior and merely costs a re-iteration.
|
||||||
|
*
|
||||||
|
* Caller of any read/write must hold [streamsLock].
|
||||||
|
*/
|
||||||
|
private val retiredStreamIdsOrder = ArrayDeque<Long>()
|
||||||
|
private val retiredStreamIdSet = HashSet<Long>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Peer-advertised concurrent bidirectional stream cap. Initialised from
|
* Peer-advertised concurrent bidirectional stream cap. Initialised from
|
||||||
* [TransportParameters.initialMaxStreamsBidi] when peer params arrive,
|
* [TransportParameters.initialMaxStreamsBidi] when peer params arrive,
|
||||||
@@ -1753,13 +1780,11 @@ class QuicConnection(
|
|||||||
* will not re-send anything on the stream.
|
* will not re-send anything on the stream.
|
||||||
* - RECEIVE side waits for the parser to have pushed every byte to
|
* - RECEIVE side waits for the parser to have pushed every byte to
|
||||||
* the application's incoming Channel ([ReceiveBuffer.isFullyRead]).
|
* the application's incoming Channel ([ReceiveBuffer.isFullyRead]).
|
||||||
* Subsequent retransmits from the peer (rare — peer only retransmits
|
* Retired stream IDs are recorded in [retiredStreamIdSet] so a
|
||||||
* if its own loss-detection fires) would land on a fresh stream
|
* duplicate STREAM frame the peer retransmits (because its own
|
||||||
* object created by [getOrCreatePeerStreamLocked], deliver
|
* loss-detection fired before our ACK arrived) is dropped at
|
||||||
* duplicate bytes, and re-fire FIN to a closed channel; the moq-lite
|
* [getOrCreatePeerStreamLocked] rather than minting a phantom
|
||||||
* listener treats duplicates as no-ops, so the failure mode is "a
|
* stream that delivers duplicate bytes to the application.
|
||||||
* bit of wasted CPU" rather than "wrong audio". The alternative
|
|
||||||
* (retain every stream forever) is a hard memory leak in soak.
|
|
||||||
*/
|
*/
|
||||||
internal fun retireFullyDoneStreamsLocked(): Int {
|
internal fun retireFullyDoneStreamsLocked(): Int {
|
||||||
if (streamsList.isEmpty()) return 0
|
if (streamsList.isEmpty()) return 0
|
||||||
@@ -1778,6 +1803,12 @@ class QuicConnection(
|
|||||||
// resetAcked never having received any STREAM frame at all).
|
// resetAcked never having received any STREAM frame at all).
|
||||||
// closeIncoming is idempotent.
|
// closeIncoming is idempotent.
|
||||||
stream.closeIncoming()
|
stream.closeIncoming()
|
||||||
|
// Record the id so a peer retransmit on this stream gets
|
||||||
|
// dropped instead of recreating a phantom stream. FIFO
|
||||||
|
// ring with bounded size — ancient retired IDs eventually
|
||||||
|
// age out, but the eviction window is far larger than the
|
||||||
|
// peer's plausible retransmit horizon.
|
||||||
|
recordRetiredStreamIdLocked(stream.streamId)
|
||||||
streams.remove(stream.streamId)
|
streams.remove(stream.streamId)
|
||||||
it.remove()
|
it.remove()
|
||||||
removed++
|
removed++
|
||||||
@@ -1794,6 +1825,38 @@ class QuicConnection(
|
|||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add [streamId] to the retired-IDs ring. FIFO eviction once the
|
||||||
|
* ring is at [RETIRED_STREAM_ID_RING_SIZE] entries — the oldest
|
||||||
|
* entry is removed from both the ordered deque and the lookup
|
||||||
|
* set in lockstep. Idempotent on duplicate adds (the
|
||||||
|
* [retireFullyDoneStreamsLocked] caller already drops the stream
|
||||||
|
* from `streams` before this fires, so a duplicate add can only
|
||||||
|
* happen if a caller manually re-records — defensive `add` skip
|
||||||
|
* keeps the ring entries unique without the cost of a fresh
|
||||||
|
* dedup pass).
|
||||||
|
*
|
||||||
|
* Caller must hold [streamsLock].
|
||||||
|
*/
|
||||||
|
private fun recordRetiredStreamIdLocked(streamId: Long) {
|
||||||
|
if (retiredStreamIdSet.add(streamId)) {
|
||||||
|
retiredStreamIdsOrder.addLast(streamId)
|
||||||
|
while (retiredStreamIdsOrder.size > RETIRED_STREAM_ID_RING_SIZE) {
|
||||||
|
val evicted = retiredStreamIdsOrder.removeFirst()
|
||||||
|
retiredStreamIdSet.remove(evicted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if [streamId] has been retired *and* is still inside the
|
||||||
|
* ring's eviction window. Used by [QuicConnectionParser] to drop
|
||||||
|
* STREAM frames the peer retransmits on already-retired streams.
|
||||||
|
*
|
||||||
|
* Caller must hold [streamsLock].
|
||||||
|
*/
|
||||||
|
internal fun isStreamIdRetiredLocked(streamId: Long): Boolean = streamId in retiredStreamIdSet
|
||||||
|
|
||||||
/** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */
|
/** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */
|
||||||
internal fun pendingDatagramsLocked(): ArrayDeque<ByteArray> = pendingDatagrams
|
internal fun pendingDatagramsLocked(): ArrayDeque<ByteArray> = pendingDatagrams
|
||||||
|
|
||||||
@@ -1983,6 +2046,24 @@ class QuicConnection(
|
|||||||
* audio/video, fresh frames matter more than stale ones.
|
* audio/video, fresh frames matter more than stale ones.
|
||||||
*/
|
*/
|
||||||
const val MAX_INCOMING_DATAGRAM_QUEUE: Int = 256
|
const val MAX_INCOMING_DATAGRAM_QUEUE: Int = 256
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacity of the retired-stream-IDs ring used by
|
||||||
|
* [recordRetiredStreamIdLocked] / [isStreamIdRetiredLocked].
|
||||||
|
* Sized so the ring covers ≥ 80 seconds of retirement at
|
||||||
|
* moq-lite's ~50 streams/sec churn rate, far longer than any
|
||||||
|
* plausible peer retransmit window (a small multiple of RTT
|
||||||
|
* — at the absolute worst tens of seconds on lossy mobile
|
||||||
|
* networks). Older retired IDs eviction-fall-through to the
|
||||||
|
* existing create-and-immediately-retire path, which is
|
||||||
|
* functionally correct, just with one extra round of work
|
||||||
|
* per duplicate.
|
||||||
|
*
|
||||||
|
* Memory: 4 096 × (8 bytes Long key + ~32 bytes HashSet
|
||||||
|
* overhead + 8 bytes ArrayDeque slot) ≈ 200 KB. Trivial vs
|
||||||
|
* the per-stream object size we're saving by retiring.
|
||||||
|
*/
|
||||||
|
const val RETIRED_STREAM_ID_RING_SIZE: Int = 4_096
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -513,6 +513,24 @@ private fun dispatchFrames(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Phantom-stream guard: drop STREAM frames the peer
|
||||||
|
// retransmitted on a stream we've already retired. Without
|
||||||
|
// this, the next line would mint a fresh QuicStream object
|
||||||
|
// and re-deliver duplicate bytes to the application
|
||||||
|
// (worse, on a SERVER_UNI stream it would also bump
|
||||||
|
// peerInitiatedUniCount and trigger a spurious
|
||||||
|
// MAX_STREAMS_UNI emission). The frame is still
|
||||||
|
// ack-eliciting — we set ackEliciting above — so our
|
||||||
|
// ACK goes out and the peer's loss-detector backs off.
|
||||||
|
// Stays inside the StreamFrame handler so other frame
|
||||||
|
// types (RESET_STREAM, MAX_STREAM_DATA, STOP_SENDING)
|
||||||
|
// on retired ids gracefully fall through their existing
|
||||||
|
// streamByIdLocked == null branches as no-ops.
|
||||||
|
if (conn.isStreamIdRetiredLocked(frame.streamId) &&
|
||||||
|
conn.streamByIdLocked(frame.streamId) == null
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
|
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
|
||||||
// RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised.
|
// RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised.
|
||||||
// The connection-level kill protects against unbounded memory
|
// The connection-level kill protects against unbounded memory
|
||||||
|
|||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quic.connection
|
||||||
|
|
||||||
|
import com.vitorpamplona.quic.frame.StreamFrame
|
||||||
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
|
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||||
|
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass at soak target #5 (transient packet loss + jitter) for
|
||||||
|
* the moq-lite group-stream shape.
|
||||||
|
*
|
||||||
|
* **Scope of this draft.** moq-lite's group streams are best-effort
|
||||||
|
* (the [SendBuffer.bestEffort] flag drops lost ranges instead of
|
||||||
|
* retransmitting). On the LISTENER side that means: a peer-uni
|
||||||
|
* stream that the server originated, whose carrying datagram never
|
||||||
|
* arrives, is gone — the relay does not retransmit it. The listener
|
||||||
|
* application is expected to observe a small fraction of frames
|
||||||
|
* "missing" but the connection itself stays healthy and audio for
|
||||||
|
* the surviving frames continues.
|
||||||
|
*
|
||||||
|
* This test pins exactly that contract:
|
||||||
|
* 1. Server publishes 50 group streams (one StreamFrame + FIN per
|
||||||
|
* stream, one stream per datagram — the moq-lite group shape
|
||||||
|
* 1:1).
|
||||||
|
* 2. A configurable loss model drops 5% (uniformly random) of
|
||||||
|
* server datagrams before [feedDatagram].
|
||||||
|
* 3. We assert the listener surfaces ≥ 90% of the published
|
||||||
|
* streams (≥ 45 of 50 at p_loss=0.05) and the connection stays
|
||||||
|
* CONNECTED.
|
||||||
|
*
|
||||||
|
* What's intentionally OUT of scope here:
|
||||||
|
* - **Reordering / jitter.** moq-lite tolerates reordering by
|
||||||
|
* construction (each group is its own stream, contiguous within
|
||||||
|
* itself). A reorder-injecting wrapper is the obvious next
|
||||||
|
* layer; this draft pins the loss-only contract first.
|
||||||
|
* - **Reliable bidi STREAM frame retransmit on loss.** Covered by
|
||||||
|
* the existing CryptoRetransmitTest / MultiplexingRoundTripTest;
|
||||||
|
* the transferloss / handshakeloss interop testcases drive that
|
||||||
|
* end-to-end.
|
||||||
|
* - **Latency under loss.** Worth measuring once we wire a real
|
||||||
|
* moq-lite publisher; for now `runBlocking` + in-memory pipe
|
||||||
|
* means "real time" is meaningless.
|
||||||
|
*
|
||||||
|
* Production link to chase next:
|
||||||
|
* - `nestsClient/.../moq/lite/Subscriber.kt` — the listener path.
|
||||||
|
* Verify it doesn't tear down its own subscription on a missing
|
||||||
|
* group sequence; surfacing the gap to the audio decoder
|
||||||
|
* (silence frames) is the right behaviour.
|
||||||
|
*/
|
||||||
|
class MoqLiteLossHarnessTest {
|
||||||
|
@Test
|
||||||
|
fun listenerToleratesFivePercentLossOnGroupStreams() =
|
||||||
|
runBlocking {
|
||||||
|
// Deterministic RNG so a CI flake is reproducible: the
|
||||||
|
// exact dropped indices are seeded from a fixed value.
|
||||||
|
// Bumping the seed to find pathological loss patterns is
|
||||||
|
// a good follow-up, but for now we just need any seed
|
||||||
|
// that exercises the dropped-and-recovered path.
|
||||||
|
val rng = Random(0xC01DBEEFL)
|
||||||
|
val groupCount = 50
|
||||||
|
val lossRate = 0.05
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
|
||||||
|
val firstId = StreamId.build(StreamId.Kind.SERVER_UNI, 0L)
|
||||||
|
val streamIds = (0 until groupCount).map { firstId + 4L * it }
|
||||||
|
val payloads = streamIds.associateWith { id -> "frame-$id".encodeToByteArray() }
|
||||||
|
val droppedIds = mutableSetOf<Long>()
|
||||||
|
|
||||||
|
for (id in streamIds) {
|
||||||
|
val drop = rng.nextDouble() < lossRate
|
||||||
|
if (drop) {
|
||||||
|
droppedIds += id
|
||||||
|
continue // Don't even build the packet — same shape as kernel
|
||||||
|
// dropping the datagram before it reaches the QUIC parser.
|
||||||
|
}
|
||||||
|
val frame =
|
||||||
|
StreamFrame(
|
||||||
|
streamId = id,
|
||||||
|
offset = 0L,
|
||||||
|
data = payloads[id]!!,
|
||||||
|
fin = true,
|
||||||
|
)
|
||||||
|
val packet = pipe.buildServerApplicationDatagram(listOf(frame))!!
|
||||||
|
feedDatagram(client, packet, nowMillis = 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the listener's incoming Flow for every stream we
|
||||||
|
// EXPECTED to receive (= all server-uni IDs minus the dropped
|
||||||
|
// ones). Streams that were dropped never had a QuicStream
|
||||||
|
// created on our side, so streamById(id) returns null — we
|
||||||
|
// don't even try to drain those.
|
||||||
|
val received = mutableMapOf<Long, ByteArray>()
|
||||||
|
for (id in streamIds) {
|
||||||
|
if (id in droppedIds) continue
|
||||||
|
val stream = client.streamById(id) ?: continue
|
||||||
|
val chunks = withTimeoutOrNull(2_000L) { stream.incoming.toList() } ?: continue
|
||||||
|
val joined = ByteArray(chunks.sumOf { it.size })
|
||||||
|
var p = 0
|
||||||
|
for (c in chunks) {
|
||||||
|
c.copyInto(joined, p)
|
||||||
|
p += c.size
|
||||||
|
}
|
||||||
|
received[id] = joined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surviving streams must surface their full payload.
|
||||||
|
for ((id, bytes) in received) {
|
||||||
|
assertEquals(
|
||||||
|
payloads[id]!!.decodeToString(),
|
||||||
|
bytes.decodeToString(),
|
||||||
|
"stream $id payload corrupted under loss — best-effort path " +
|
||||||
|
"must NOT split or drop bytes WITHIN a delivered group",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// ≥ 90% delivered (lossRate=5% — band leaves room for RNG
|
||||||
|
// tail-end where the seed happens to drop more than the
|
||||||
|
// mean).
|
||||||
|
val expectedFloor = (groupCount * 0.9).toInt()
|
||||||
|
assertTrue(
|
||||||
|
received.size >= expectedFloor,
|
||||||
|
"listener received only ${received.size} / $groupCount groups under " +
|
||||||
|
"${(lossRate * 100).toInt()}% loss — floor was $expectedFloor. " +
|
||||||
|
"dropped=${droppedIds.size} ids=${droppedIds.sorted()}",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
QuicConnection.Status.CONNECTED,
|
||||||
|
client.status,
|
||||||
|
"connection must stay CONNECTED across packet loss on best-effort streams",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listenerSurfacesEveryFrameWhenLossRateIsZero() =
|
||||||
|
runBlocking {
|
||||||
|
// Sanity for the harness itself: with lossRate=0 we must
|
||||||
|
// see all 50 frames intact. If this fails the loss-shape
|
||||||
|
// assertions in the lossy test are unreliable.
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
val groupCount = 50
|
||||||
|
val firstId = StreamId.build(StreamId.Kind.SERVER_UNI, 0L)
|
||||||
|
|
||||||
|
for (i in 0 until groupCount) {
|
||||||
|
val id = firstId + 4L * i
|
||||||
|
val payload = "frame-$id".encodeToByteArray()
|
||||||
|
val frame = StreamFrame(streamId = id, offset = 0L, data = payload, fin = true)
|
||||||
|
val packet = pipe.buildServerApplicationDatagram(listOf(frame))!!
|
||||||
|
feedDatagram(client, packet, nowMillis = 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
var delivered = 0
|
||||||
|
for (i in 0 until groupCount) {
|
||||||
|
val id = firstId + 4L * i
|
||||||
|
val stream = client.streamById(id)!!
|
||||||
|
val chunks = withTimeoutOrNull(2_000L) { stream.incoming.toList() }
|
||||||
|
if (chunks != null) delivered += 1
|
||||||
|
}
|
||||||
|
assertEquals(groupCount, delivered, "lossRate=0 baseline must deliver every frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newConnectedClient(): Pair<QuicConnection, InMemoryQuicPipe> =
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "loss.test",
|
||||||
|
config =
|
||||||
|
QuicConnectionConfig(
|
||||||
|
initialMaxStreamsBidi = 1024,
|
||||||
|
initialMaxStreamsUni = 1024,
|
||||||
|
initialMaxData = 16L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
),
|
||||||
|
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val tlsServer =
|
||||||
|
InProcessTlsServer(
|
||||||
|
transportParameters =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 16L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
initialMaxStreamsBidi = 1024,
|
||||||
|
initialMaxStreamsUni = 1024,
|
||||||
|
initialSourceConnectionId = serverScid.bytes,
|
||||||
|
originalDestinationConnectionId = client.destinationConnectionId.bytes,
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
val pipe =
|
||||||
|
InMemoryQuicPipe(
|
||||||
|
client = client,
|
||||||
|
initialDcid = client.destinationConnectionId.bytes,
|
||||||
|
serverScid = serverScid,
|
||||||
|
tlsServer = tlsServer,
|
||||||
|
)
|
||||||
|
client.start()
|
||||||
|
pipe.drive(maxRounds = 16)
|
||||||
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
|
client to pipe
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -207,6 +207,71 @@ class StreamRetirementSoakTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun phantomGuardDropsRetransmitOnRetiredPeerStream() =
|
||||||
|
runBlocking {
|
||||||
|
// Phantom-stream guard regression: after we've retired a
|
||||||
|
// peer-uni stream, a duplicate STREAM frame the peer
|
||||||
|
// retransmits (because its loss-detector fired before our
|
||||||
|
// ACK reached it) MUST NOT mint a fresh QuicStream object.
|
||||||
|
// Pre-guard, the duplicate frame would:
|
||||||
|
// - re-populate `streams[id]` and `streamsList`
|
||||||
|
// - bump `peerInitiatedUniCount` a second time
|
||||||
|
// - re-fire `newPeerStreams.addLast(...)`, signalling
|
||||||
|
// a phantom stream-arrival to application code
|
||||||
|
// - re-deliver the same bytes on a fresh incoming Flow
|
||||||
|
// The guard at the parser drops the frame (still
|
||||||
|
// ack-eliciting, so our ACK still goes out and the peer's
|
||||||
|
// loss-detector quiets down).
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
val streamId = StreamId.build(StreamId.Kind.SERVER_UNI, 0L)
|
||||||
|
val payload = "phantom-test".encodeToByteArray()
|
||||||
|
val frame = StreamFrame(streamId = streamId, offset = 0L, data = payload, fin = true)
|
||||||
|
|
||||||
|
val firstPacket = pipe.buildServerApplicationDatagram(listOf(frame))!!
|
||||||
|
feedDatagram(client, firstPacket, nowMillis = 0L)
|
||||||
|
// Drain the original delivery to retire the stream.
|
||||||
|
client.streamById(streamId)?.incoming?.toList()
|
||||||
|
drainAll(client, pipe)
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
client.retiredStreamsCount,
|
||||||
|
"first delivery + drain must retire exactly one stream",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
client.isStreamIdRetiredLocked(streamId),
|
||||||
|
"retired-id ring must hold the just-retired stream id",
|
||||||
|
)
|
||||||
|
val countAfterFirstDelivery = client.peerInitiatedUniCount
|
||||||
|
|
||||||
|
// Replay the same STREAM+FIN frame — i.e. peer retransmit.
|
||||||
|
val replayPacket = pipe.buildServerApplicationDatagram(listOf(frame))!!
|
||||||
|
feedDatagram(client, replayPacket, nowMillis = 0L)
|
||||||
|
drainAll(client, pipe)
|
||||||
|
|
||||||
|
// No phantom stream — counters must not move.
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
client.retiredStreamsCount,
|
||||||
|
"duplicate STREAM frame must not create a phantom stream that re-retires",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
countAfterFirstDelivery,
|
||||||
|
client.peerInitiatedUniCount,
|
||||||
|
"peerInitiatedUniCount must not double-count the retransmitted FIN",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
client.streamsListLocked().size,
|
||||||
|
"streamsList must stay empty after the phantom-guard drop",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
QuicConnection.Status.CONNECTED,
|
||||||
|
client.status,
|
||||||
|
"connection must stay CONNECTED across a duplicate STREAM frame",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun retirementPreservesConnectionLevelMaxDataAccounting() =
|
fun retirementPreservesConnectionLevelMaxDataAccounting() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
+33
@@ -29,6 +29,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.io.File
|
||||||
import java.net.DatagramSocket
|
import java.net.DatagramSocket
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
@@ -139,6 +140,7 @@ class QuicConnectionDriverLifecycleTest {
|
|||||||
// shows up as a leak.
|
// shows up as a leak.
|
||||||
warmUpDispatchersIo()
|
warmUpDispatchersIo()
|
||||||
val baseline = liveThreadCount()
|
val baseline = liveThreadCount()
|
||||||
|
val baselineFds = liveFileDescriptorCount()
|
||||||
val sessions = 100
|
val sessions = 100
|
||||||
for (i in 0 until sessions) {
|
for (i in 0 until sessions) {
|
||||||
val parent = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
val parent = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -182,6 +184,23 @@ class QuicConnectionDriverLifecycleTest {
|
|||||||
"thread count grew by $growth across $sessions sessions " +
|
"thread count grew by $growth across $sessions sessions " +
|
||||||
"(baseline=$baseline final=$finalCount). Anything > 16 indicates a leak.",
|
"(baseline=$baseline final=$finalCount). Anything > 16 indicates a leak.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FD-leak canary. On Linux, /proc/self/fd holds one entry per
|
||||||
|
// open file descriptor (sockets + pipes + regular files).
|
||||||
|
// A leak that misses socket.close() would show up here as
|
||||||
|
// ~1 FD per session — 100 sessions → growth ≥ 100 with
|
||||||
|
// certainty. We band at 16 (same headroom as threads). On
|
||||||
|
// platforms without /proc, [liveFileDescriptorCount] returns
|
||||||
|
// -1 and this branch silently no-ops.
|
||||||
|
val finalFds = liveFileDescriptorCount()
|
||||||
|
if (baselineFds >= 0 && finalFds >= 0) {
|
||||||
|
val fdGrowth = finalFds - baselineFds
|
||||||
|
assertTrue(
|
||||||
|
fdGrowth <= 16,
|
||||||
|
"FD count grew by $fdGrowth across $sessions sessions " +
|
||||||
|
"(baseline=$baselineFds final=$finalFds). UDP socket / pipe leak.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if a UDP send on the socket throws — i.e. close() has run. */
|
/** True if a UDP send on the socket throws — i.e. close() has run. */
|
||||||
@@ -217,4 +236,18 @@ class QuicConnectionDriverLifecycleTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun liveThreadCount(): Int = Thread.getAllStackTraces().keys.size
|
private fun liveThreadCount(): Int = Thread.getAllStackTraces().keys.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux: count entries in `/proc/self/fd`. macOS / Windows / other
|
||||||
|
* platforms don't expose this path; return -1 so the caller
|
||||||
|
* silently skips the FD assertion. This is a strictly additive
|
||||||
|
* canary — no false-positive risk on platforms that can't measure.
|
||||||
|
*/
|
||||||
|
private fun liveFileDescriptorCount(): Int =
|
||||||
|
try {
|
||||||
|
val procFd = File("/proc/self/fd")
|
||||||
|
if (procFd.isDirectory) procFd.list()?.size ?: -1 else -1
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quic.connection
|
||||||
|
|
||||||
|
import com.vitorpamplona.quic.frame.StreamFrame
|
||||||
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
|
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||||
|
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-form heap-sampling canary for soak target #1 (memory growth).
|
||||||
|
*
|
||||||
|
* **DEFAULT-SKIPPED.** This test is gated by the `quicSoakSeconds`
|
||||||
|
* system property, propagated from the `-PquicSoakSeconds=N` Gradle
|
||||||
|
* property by `quic/build.gradle.kts`. Without the property the
|
||||||
|
* test method early-returns with a printed "SKIPPED" line, so a
|
||||||
|
* plain `./gradlew test` run keeps it out of CI's critical path
|
||||||
|
* (~40 ms overhead from the runBlocking + property check).
|
||||||
|
*
|
||||||
|
* To run the production-shaped 30-minute soak from the audio-rooms
|
||||||
|
* prompt:
|
||||||
|
*
|
||||||
|
* ./gradlew :quic:jvmTest \
|
||||||
|
* --tests 'com.vitorpamplona.quic.connection.QuicHeapSoakTest' \
|
||||||
|
* -PquicSoakSeconds=1800
|
||||||
|
*
|
||||||
|
* Quick local sanity (30 seconds, ~25 K stream lifecycles):
|
||||||
|
*
|
||||||
|
* ./gradlew :quic:jvmTest \
|
||||||
|
* --tests 'com.vitorpamplona.quic.connection.QuicHeapSoakTest' \
|
||||||
|
* -PquicSoakSeconds=30
|
||||||
|
*
|
||||||
|
* Test shape: drive moq-lite-shaped peer-uni stream churn through an
|
||||||
|
* [InMemoryQuicPipe] at a fixed rate. Sample
|
||||||
|
* `Runtime.getRuntime().totalMemory() - freeMemory()` at six evenly-
|
||||||
|
* spaced points across the run, calling `System.gc()` first to
|
||||||
|
* minimise allocator-noise. The post-warmup baseline is the second
|
||||||
|
* sample; the acceptance criterion (10 MB max growth past warmup,
|
||||||
|
* direct from the soak prompt) is checked against the final sample.
|
||||||
|
*
|
||||||
|
* Why six samples: enough granularity to spot a slow drift while
|
||||||
|
* keeping the per-sample overhead small relative to the run. The
|
||||||
|
* second sample (~17 % into the run) is the warmup baseline — the
|
||||||
|
* first sample is just before any work, so it understates ambient
|
||||||
|
* usage and would inflate apparent growth.
|
||||||
|
*/
|
||||||
|
class QuicHeapSoakTest {
|
||||||
|
@Test
|
||||||
|
fun heapStaysFlatUnderModulatedStreamChurn() =
|
||||||
|
runBlocking {
|
||||||
|
val durationSec =
|
||||||
|
System.getProperty("quicSoakSeconds")?.toIntOrNull()
|
||||||
|
if (durationSec == null || durationSec <= 0) {
|
||||||
|
// Default-skip path: `./gradlew test` runs this test as a
|
||||||
|
// no-op fast-pass. Opt in via `-PquicSoakSeconds=N` (1800
|
||||||
|
// for the 30-minute soak from the audio-rooms prompt).
|
||||||
|
// We don't use kotlin.test's assumption because the JVM
|
||||||
|
// backend differs between JUnit4 / JUnit5; an early
|
||||||
|
// return prints the reason and keeps CI fast.
|
||||||
|
println(
|
||||||
|
"[QuicHeapSoakTest] SKIPPED — set -PquicSoakSeconds=N " +
|
||||||
|
"to run (e.g. 30 for sanity, 1800 for production-shape).",
|
||||||
|
)
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
// Open the pipe; the handshake should be cheap relative to
|
||||||
|
// the soak duration.
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
|
||||||
|
// moq-lite production rate: ~50 peer-uni streams per second
|
||||||
|
// (one Opus frame per stream). A 1 800 s run mints 90 000
|
||||||
|
// streams; a 30 s scaled-down run still moves through 1 500
|
||||||
|
// generations of churn — plenty for retirement to fire
|
||||||
|
// hundreds of times.
|
||||||
|
val streamsPerSecond = 50
|
||||||
|
val totalStreams = durationSec.toLong() * streamsPerSecond
|
||||||
|
val perPayload = 32 // ~Opus frame envelope
|
||||||
|
val sampleCount = 6
|
||||||
|
val streamsPerSample = (totalStreams / sampleCount).coerceAtLeast(1L)
|
||||||
|
|
||||||
|
val samples = LongArray(sampleCount)
|
||||||
|
val streamCountAtSample = LongArray(sampleCount)
|
||||||
|
var nextStreamId = StreamId.build(StreamId.Kind.SERVER_UNI, 0L)
|
||||||
|
var streamsDelivered = 0L
|
||||||
|
var sampleIdx = 0
|
||||||
|
|
||||||
|
while (sampleIdx < sampleCount) {
|
||||||
|
val target = (sampleIdx + 1) * streamsPerSample
|
||||||
|
while (streamsDelivered < target) {
|
||||||
|
val payload = ByteArray(perPayload) { (streamsDelivered.toInt() and 0xFF).toByte() }
|
||||||
|
val packet =
|
||||||
|
pipe.buildServerApplicationDatagram(
|
||||||
|
listOf(
|
||||||
|
StreamFrame(
|
||||||
|
streamId = nextStreamId,
|
||||||
|
offset = 0L,
|
||||||
|
data = payload,
|
||||||
|
fin = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
feedDatagram(client, packet, nowMillis = 0L)
|
||||||
|
client.streamById(nextStreamId)?.incoming?.toList()
|
||||||
|
nextStreamId += 4L
|
||||||
|
streamsDelivered += 1
|
||||||
|
// Periodically drain so retireFullyDoneStreamsLocked
|
||||||
|
// runs at moq-lite-realistic intervals (per ~50
|
||||||
|
// streams). Without periodic drains the working set
|
||||||
|
// grows to streamsPerSample which is misleading vs
|
||||||
|
// the production rate.
|
||||||
|
if (streamsDelivered % streamsPerSecond == 0L) {
|
||||||
|
drainAll(client, pipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drainAll(client, pipe)
|
||||||
|
samples[sampleIdx] = sampleHeapBytes()
|
||||||
|
streamCountAtSample[sampleIdx] = client.streamsListLocked().size.toLong()
|
||||||
|
sampleIdx += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: every stream we *actually* minted (streamsDelivered;
|
||||||
|
// streamsPerSample integer-truncates so it can be < totalStreams)
|
||||||
|
// must have been retired by the last sample. drainAll between
|
||||||
|
// batches drives retirement. If retirement regressed, the gap
|
||||||
|
// here would surface immediately.
|
||||||
|
assertEquals(
|
||||||
|
streamsDelivered,
|
||||||
|
client.retiredStreamsCount,
|
||||||
|
"every minted peer-uni stream must have been retired by end of soak",
|
||||||
|
)
|
||||||
|
// Working set must have stayed bounded throughout — never
|
||||||
|
// larger than the per-sample batch size (drainAll runs at
|
||||||
|
// every streamsPerSecond boundary, so the steady-state
|
||||||
|
// bound is ≤ streamsPerSecond entries).
|
||||||
|
val maxWorkingSet = streamCountAtSample.max()
|
||||||
|
assertTrue(
|
||||||
|
maxWorkingSet <= streamsPerSecond.toLong(),
|
||||||
|
"tracker working set must stay ≤ $streamsPerSecond entries; observed max=$maxWorkingSet " +
|
||||||
|
"(samples=${streamCountAtSample.toList()})",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heap acceptance — direct from the audio-rooms prompt:
|
||||||
|
// "no monotonic growth past handshake-stable (10 MB)".
|
||||||
|
// Use sample 1 (post-warmup) as baseline, sample 5 (final)
|
||||||
|
// as the steady-state probe.
|
||||||
|
val warmup = samples[1]
|
||||||
|
val finalSample = samples[sampleCount - 1]
|
||||||
|
val growthMb = (finalSample - warmup).toDouble() / (1024.0 * 1024.0)
|
||||||
|
// Print the sample profile so a CI failure has actionable
|
||||||
|
// forensic data (which sample first crossed the threshold,
|
||||||
|
// roughly when in the run).
|
||||||
|
val profile =
|
||||||
|
samples.indices.joinToString(prefix = "[", postfix = "]") { i ->
|
||||||
|
val mb = samples[i].toDouble() / (1024.0 * 1024.0)
|
||||||
|
"%.1fMB".format(mb)
|
||||||
|
}
|
||||||
|
System.err.println(
|
||||||
|
"[QuicHeapSoakTest] duration=${durationSec}s totalStreams=$totalStreams " +
|
||||||
|
"samples=$profile retired=${client.retiredStreamsCount}",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
growthMb <= 10.0,
|
||||||
|
"heap grew by %.1f MB past warmup (sample 1 = %.1f MB → final = %.1f MB); ".format(
|
||||||
|
growthMb,
|
||||||
|
warmup.toDouble() / 1024.0 / 1024.0,
|
||||||
|
finalSample.toDouble() / 1024.0 / 1024.0,
|
||||||
|
) + "samples=$profile. Acceptance: ≤ 10 MB.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleHeapBytes(): Long {
|
||||||
|
// Best-effort GC: a single System.gc() is a hint, not a
|
||||||
|
// guarantee. Three passes with a small sleep between gives
|
||||||
|
// G1/CMS/ZGC enough time to actually settle. (We avoid
|
||||||
|
// `System.runFinalization()` — deprecated since Java 18 and
|
||||||
|
// unreliable on modern collectors.)
|
||||||
|
repeat(3) {
|
||||||
|
System.gc()
|
||||||
|
Thread.sleep(20)
|
||||||
|
}
|
||||||
|
val rt = Runtime.getRuntime()
|
||||||
|
return rt.totalMemory() - rt.freeMemory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drainAll(
|
||||||
|
client: QuicConnection,
|
||||||
|
pipe: InMemoryQuicPipe,
|
||||||
|
) {
|
||||||
|
while (true) {
|
||||||
|
val out = drainOutbound(client, nowMillis = 0L) ?: break
|
||||||
|
pipe.decryptClientApplicationFrames(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newConnectedClient(): Pair<QuicConnection, InMemoryQuicPipe> =
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "soak.test",
|
||||||
|
config =
|
||||||
|
QuicConnectionConfig(
|
||||||
|
initialMaxStreamsBidi = 4096,
|
||||||
|
initialMaxStreamsUni = 65_536,
|
||||||
|
initialMaxData = 16L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
),
|
||||||
|
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val tlsServer =
|
||||||
|
InProcessTlsServer(
|
||||||
|
transportParameters =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 16L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
initialMaxStreamsBidi = 4096,
|
||||||
|
initialMaxStreamsUni = 65_536,
|
||||||
|
initialSourceConnectionId = serverScid.bytes,
|
||||||
|
originalDestinationConnectionId = client.destinationConnectionId.bytes,
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
val pipe =
|
||||||
|
InMemoryQuicPipe(
|
||||||
|
client = client,
|
||||||
|
initialDcid = client.destinationConnectionId.bytes,
|
||||||
|
serverScid = serverScid,
|
||||||
|
tlsServer = tlsServer,
|
||||||
|
)
|
||||||
|
client.start()
|
||||||
|
pipe.drive(maxRounds = 16)
|
||||||
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
|
client to pipe
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user