From 32ccbd2b24fd03ef68255eda2e2deae46478a255 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:12:20 +0000 Subject: [PATCH] fix(quic-interop): per-stream timeout in parallel multiplexing path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous coroutineScope { urls.map { async { ... } }.map { it.await() } } pattern was vulnerable to a single hung stream blocking the whole await chain — even though the others completed, sequential .await() iteration would hang on the slowest forever. With multiplexing's hundreds of streams, the probability of at least one having an issue (lost FIN, slow consumer hitting channel-saturation thresholds, etc.) is high. Wrap each get() in withTimeoutOrNull(PER_STREAM_TIMEOUT_SEC). A timed-out stream surfaces as GetResponse(status=0); the caller's status != 200 check counts it as a failure but doesn't block the loop. Doesn't fix throughput — that needs separate work on per-stream backpressure and parser fairness. Just makes the failure mode visible (status=0 → "failed file") rather than hidden (whole test times out because of one bad stream). https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT --- .../quic/interop/runner/InteropClient.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index 56ed408ae..ec4d7535b 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -64,6 +64,13 @@ private const val HANDSHAKE_TIMEOUT_SEC = 10L // inflating turnaround for the cases that actually complete fast. private const val TRANSFER_TIMEOUT_SEC = 60L +// Per-stream timeout in the parallel-multiplexing path. If any single +// GET hangs past this (e.g. its FIN was lost in the shuffle of +// hundreds of concurrent streams), the await on that future returns +// a status=0 response and the others continue. Without this, a single +// stuck stream would block the whole .map { it.await() } chain. +private const val PER_STREAM_TIMEOUT_SEC = 20L + fun main() { val role = System.getenv("ROLE") ?: "client" if (role != "client") { @@ -295,11 +302,24 @@ private fun runTransferTest( if (parallel) { // Open every request stream up-front so they // genuinely overlap on the wire — what the - // multiplexing testcase verifies. + // multiplexing testcase verifies. Each get() + // is wrapped in a per-stream timeout: a single + // hung stream (e.g. its FIN got lost in the + // shuffle) shouldn't block the rest from + // completing. A timed-out stream surfaces as + // status=0, which the loop below counts as + // a failure but doesn't block on. coroutineScope { urls - .map { url -> async { url to client.get(authority, url.path) } } - .map { it.await() } + .map { url -> + async { + val resp = + withTimeoutOrNull(PER_STREAM_TIMEOUT_SEC * 1_000L) { + client.get(authority, url.path) + } + url to (resp ?: GetResponse(status = 0, body = ByteArray(0))) + } + }.map { it.await() } } } else { urls.map { url -> url to client.get(authority, url.path) }