fix(quic-interop): per-stream timeout in parallel multiplexing path

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
This commit is contained in:
Claude
2026-05-07 01:12:20 +00:00
parent 9a74d1d5df
commit 32ccbd2b24
@@ -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) }