diag(nests): explain why a WebTransport CONNECT failed instead of bare :status=0
QuicWebTransportFactory threw `:status=0` for every distinct CONNECT failure mode — relay never answered, relay FIN'd the request bidi with no H3 response, relay sent a HEADERS frame lacking :status, or the whole connection was torn down by a CONNECTION_CLOSE. The four cases need different fixes but produced identical, undiagnosable errors. readConnectResponse now records bytes/H3-frames seen on the request stream and reports which of those cases occurred; the thrown exception also carries a connection-state snapshot (conn.status, closeReason, closeErrorCode, requestStreamClosed) captured before driver.close(), so a relay CONNECTION_CLOSE (e.g. H3_SETTINGS_ERROR from a rejected SETTINGS frame) is distinguishable from a request-stream-only close. https://claude.ai/code/session_01PoQupfdoKU3ryQwLwTeXeM
This commit is contained in:
+62
-5
@@ -180,17 +180,27 @@ class QuicWebTransportFactory(
|
||||
readConnectResponse(requestStream)
|
||||
}
|
||||
if (response == null) {
|
||||
val connDiag = describeConnectionState(conn, requestStream)
|
||||
driver.close()
|
||||
throw WebTransportException(
|
||||
kind = WebTransportException.Kind.HandshakeFailed,
|
||||
message = "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms",
|
||||
message =
|
||||
"WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms — $connDiag",
|
||||
)
|
||||
}
|
||||
if (response.status !in 200..299) {
|
||||
// Capture connection-level state BEFORE driver.close() — if the
|
||||
// relay tore the connection down, closeReason/closeErrorCode
|
||||
// are already set and tell us whether this was a CONNECTION_CLOSE
|
||||
// (e.g. H3_SETTINGS_ERROR from a rejected SETTINGS frame) or the
|
||||
// relay simply FIN'd the request bidi without an H3 response.
|
||||
val connDiag = describeConnectionState(conn, requestStream)
|
||||
driver.close()
|
||||
throw WebTransportException(
|
||||
kind = WebTransportException.Kind.ConnectRejected,
|
||||
message = "WebTransport CONNECT returned :status=${response.status}",
|
||||
message =
|
||||
"WebTransport CONNECT returned :status=${response.status} — " +
|
||||
"${response.diagnostic}; $connDiag",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,11 +240,19 @@ class QuicWebTransportFactory(
|
||||
private suspend fun readConnectResponse(requestStream: QuicStream): ConnectResponse {
|
||||
val reader = Http3FrameReader()
|
||||
val incoming = requestStream.incoming
|
||||
// Diagnostic accounting — when the CONNECT fails, :status=0 alone
|
||||
// can't tell us whether the relay never answered, FIN'd us with
|
||||
// garbage, or sent a HEADERS frame that lacked :status. Track
|
||||
// enough to disambiguate in the thrown exception message.
|
||||
var bytesSeen = 0
|
||||
val h3FramesSeen = mutableListOf<String>()
|
||||
try {
|
||||
incoming.collect { chunk ->
|
||||
bytesSeen += chunk.size
|
||||
reader.push(chunk)
|
||||
while (true) {
|
||||
val frame = reader.next() ?: break
|
||||
h3FramesSeen += frame::class.simpleName ?: "?"
|
||||
if (frame is Http3Frame.Headers) {
|
||||
val pairs = QpackDecoder().decodeFieldSection(frame.qpackPayload)
|
||||
val status = pairs.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0
|
||||
@@ -243,24 +261,63 @@ class QuicWebTransportFactory(
|
||||
.firstOrNull { it.first.equals("wt-protocol", ignoreCase = true) }
|
||||
?.second
|
||||
?.let { parseFirstSfString(it) }
|
||||
throw HeadersReceived(status, subProtocol)
|
||||
val diag =
|
||||
if (status == 0) {
|
||||
"HEADERS frame carried no parseable :status — fields=[${pairs.joinToString { it.first }}]"
|
||||
} else {
|
||||
"HEADERS received, status=$status"
|
||||
}
|
||||
throw HeadersReceived(status, subProtocol, diag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: HeadersReceived) {
|
||||
return ConnectResponse(e.status, e.subProtocol)
|
||||
return ConnectResponse(e.status, e.subProtocol, e.diagnostic)
|
||||
}
|
||||
return ConnectResponse(0, null)
|
||||
// The incoming flow completed WITHOUT a HEADERS frame — the relay
|
||||
// ended (FIN'd) the request bidi without an H3 response. Note this
|
||||
// is a *clean* close: a RESET_STREAM would have thrown out of
|
||||
// `incoming.collect` and been wrapped as HandshakeFailed instead.
|
||||
return ConnectResponse(
|
||||
status = 0,
|
||||
subProtocol = null,
|
||||
diagnostic =
|
||||
"request stream ended with no HEADERS frame " +
|
||||
"(bytesSeen=$bytesSeen, h3FramesSeen=$h3FramesSeen, requestStreamClosed=${requestStream.isClosed})",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line snapshot of the QUIC connection + request-stream state for
|
||||
* a failed CONNECT. Distinguishes "the relay sent a CONNECTION_CLOSE"
|
||||
* (status != CONNECTED, closeErrorCode/closeReason populated — e.g.
|
||||
* H3_SETTINGS_ERROR if it rejected our SETTINGS frame) from "the relay
|
||||
* left the connection up but closed just the request stream".
|
||||
*/
|
||||
private fun describeConnectionState(
|
||||
conn: QuicConnection,
|
||||
requestStream: QuicStream,
|
||||
): String =
|
||||
buildString {
|
||||
append("conn.status=").append(conn.status)
|
||||
conn.closeReason?.let { append(", closeReason='").append(it).append('\'') }
|
||||
if (conn.closeErrorCode != 0L) {
|
||||
append(", closeErrorCode=0x").append(conn.closeErrorCode.toString(16))
|
||||
}
|
||||
append(", requestStreamClosed=").append(requestStream.isClosed)
|
||||
}
|
||||
|
||||
private data class ConnectResponse(
|
||||
val status: Int,
|
||||
val subProtocol: String?,
|
||||
/** Human-readable account of how the response was (or wasn't) received. */
|
||||
val diagnostic: String,
|
||||
)
|
||||
|
||||
private class HeadersReceived(
|
||||
val status: Int,
|
||||
val subProtocol: String?,
|
||||
val diagnostic: String,
|
||||
) : RuntimeException()
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user