feat(quic-interop): wire qlog observer into the production endpoint
Agent B's QlogWriter previously lived only in :quic's jvmTest scope,
hooked into the standalone InteropRunner. Brings it into :quic-interop
proper:
- QlogWriter.kt + QlogWriterTest.kt copied into :quic-interop with
package com.vitorpamplona.quic.interop.runner.
- Jackson dep added to :quic-interop's build.gradle.kts.
- InteropClient reads $QLOGDIR (the runner sets it inside the
container per docker-compose.yml). When set, opens a QlogWriter
at <QLOGDIR>/client.sqlog, passes as QuicConnection.qlogObserver,
closes on every exit path (handshake_failed / udp_failed /
transfer_timeout / ok).
- QUIC_INTEROP_DEBUG=1 now prints qlogdir alongside the other env
fields.
Net effect: any future runner-driven test failure dumps a structured
qlog file the user can drag straight into qvis (qvis.quictools.info)
to see frame-by-frame what the client decided to do. The retry test
failure on aioquic + picoquic that we still need to debug now produces
a usable artifact.
NOTE: the QlogWriter copy in :quic's jvmTest stays in place as the
helper for the standalone InteropRunner main(). Slight duplication;
acceptable while :quic-interop is its own module.
https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
@@ -71,6 +71,7 @@ fun main() {
|
|||||||
// does NOT export a DOWNLOADS env var. Hard-code the mount path.
|
// does NOT export a DOWNLOADS env var. Hard-code the mount path.
|
||||||
val downloadsDir = File("/downloads")
|
val downloadsDir = File("/downloads")
|
||||||
val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
|
val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
|
||||||
|
val qlogDir = System.getenv("QLOGDIR")?.takeIf { it.isNotBlank() }?.let { File(it) }
|
||||||
|
|
||||||
// One-line context header. Verbose per-field dump deferred to debug
|
// One-line context header. Verbose per-field dump deferred to debug
|
||||||
// mode (env var QUIC_INTEROP_DEBUG=1) so the runner's aggregated output
|
// mode (env var QUIC_INTEROP_DEBUG=1) so the runner's aggregated output
|
||||||
@@ -81,6 +82,7 @@ fun main() {
|
|||||||
System.err.println("requests: $requests")
|
System.err.println("requests: $requests")
|
||||||
System.err.println("downloads dir: ${downloadsDir.absolutePath} (exists=${downloadsDir.isDirectory})")
|
System.err.println("downloads dir: ${downloadsDir.absolutePath} (exists=${downloadsDir.isDirectory})")
|
||||||
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
|
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
|
||||||
|
System.err.println("qlogdir: ${qlogDir?.absolutePath ?: "(unset)"}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val cipherSuites =
|
val cipherSuites =
|
||||||
@@ -155,6 +157,7 @@ fun main() {
|
|||||||
offeredAlpns = offeredAlpns,
|
offeredAlpns = offeredAlpns,
|
||||||
initialVersion = initialVersion,
|
initialVersion = initialVersion,
|
||||||
keyLogPath = keyLogPath,
|
keyLogPath = keyLogPath,
|
||||||
|
qlogDir = qlogDir,
|
||||||
parallel = (testcase == "multiplexing"),
|
parallel = (testcase == "multiplexing"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,6 +189,7 @@ private fun runTransferTest(
|
|||||||
offeredAlpns: List<Alpn>,
|
offeredAlpns: List<Alpn>,
|
||||||
initialVersion: Int,
|
initialVersion: Int,
|
||||||
keyLogPath: String?,
|
keyLogPath: String?,
|
||||||
|
qlogDir: File?,
|
||||||
parallel: Boolean,
|
parallel: Boolean,
|
||||||
): Int {
|
): Int {
|
||||||
val urls =
|
val urls =
|
||||||
@@ -215,6 +219,15 @@ private fun runTransferTest(
|
|||||||
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
|
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
|
||||||
}
|
}
|
||||||
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
|
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
|
||||||
|
val qlogWriter =
|
||||||
|
qlogDir?.let { dir ->
|
||||||
|
dir.mkdirs()
|
||||||
|
// ODCID is unknown until the connection generates one in
|
||||||
|
// its init block; we'd need to plumb through, but for the
|
||||||
|
// header it's fine to start with a placeholder and the
|
||||||
|
// packet-sent events will carry SCID/DCID anyway.
|
||||||
|
QlogWriter(file = File(dir, "client.sqlog"), odcidHex = "client")
|
||||||
|
}
|
||||||
val conn =
|
val conn =
|
||||||
QuicConnection(
|
QuicConnection(
|
||||||
serverName = host,
|
serverName = host,
|
||||||
@@ -229,6 +242,7 @@ private fun runTransferTest(
|
|||||||
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
|
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
|
||||||
),
|
),
|
||||||
extraSecretsListener = keyLogger?.listener,
|
extraSecretsListener = keyLogger?.listener,
|
||||||
|
qlogObserver = qlogWriter ?: com.vitorpamplona.quic.observability.QlogObserver.NoOp,
|
||||||
)
|
)
|
||||||
val driver = QuicConnectionDriver(conn, socket, scope)
|
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||||
driver.start()
|
driver.start()
|
||||||
@@ -240,6 +254,7 @@ private fun runTransferTest(
|
|||||||
if (handshake == null || handshake.isFailure) {
|
if (handshake == null || handshake.isFailure) {
|
||||||
runCatching { driver.close() }
|
runCatching { driver.close() }
|
||||||
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||||
|
runCatching { qlogWriter?.close() }
|
||||||
return@runBlocking "handshake_failed"
|
return@runBlocking "handshake_failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +312,7 @@ private fun runTransferTest(
|
|||||||
|
|
||||||
runCatching { driver.close() }
|
runCatching { driver.close() }
|
||||||
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||||
|
runCatching { qlogWriter?.close() }
|
||||||
delay(50)
|
delay(50)
|
||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/*
|
||||||
|
* 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.interop.runner
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.vitorpamplona.quic.connection.EncryptionLevel
|
||||||
|
import com.vitorpamplona.quic.observability.QlogObserver
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-NDJSON qlog writer (qlog 0.3 / JSON-SEQ format) used by the
|
||||||
|
* `:quic` interop runner. One JSON object per line; first line is the
|
||||||
|
* qlog header, subsequent lines are events.
|
||||||
|
*
|
||||||
|
* Tools like qvis (https://qvis.quictools.info/) consume the resulting
|
||||||
|
* `.sqlog` file to render sequence diagrams + RTT graphs + recovery
|
||||||
|
* timelines.
|
||||||
|
*
|
||||||
|
* **Goal: every interop-runner test failure produces a qlog file the
|
||||||
|
* caller can drop into qvis to see exactly what we did differently
|
||||||
|
* from the spec.**
|
||||||
|
*
|
||||||
|
* Threading: [java.io.BufferedWriter] is not safe for concurrent
|
||||||
|
* writers; we hold a [ReentrantLock] around each line emit so the
|
||||||
|
* read + send loops can fire events concurrently without interleaving
|
||||||
|
* partial JSON.
|
||||||
|
*/
|
||||||
|
class QlogWriter(
|
||||||
|
file: File,
|
||||||
|
private val odcidHex: String,
|
||||||
|
private val mapper: ObjectMapper = DEFAULT_MAPPER,
|
||||||
|
/** Wall-clock provider; tests inject a deterministic source. */
|
||||||
|
private val nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||||
|
) : QlogObserver,
|
||||||
|
Closeable {
|
||||||
|
// append=false → truncate any prior trace at this path so a reused
|
||||||
|
// QLOGDIR doesn't accumulate stale events from a previous run.
|
||||||
|
private val writer: BufferedWriter = BufferedWriter(FileWriter(file, false))
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
private val startMillis: Long = nowMillis()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// qlog 0.3 JSON-SEQ header. qvis tolerates both `qlog_format`
|
||||||
|
// values "JSON-SEQ" and "NDJSON"; we use JSON-SEQ to match the
|
||||||
|
// most-common production qlog files (Chromium, mvfst).
|
||||||
|
val header =
|
||||||
|
mapOf(
|
||||||
|
"qlog_version" to "0.3",
|
||||||
|
"qlog_format" to "JSON-SEQ",
|
||||||
|
"title" to "amethyst :quic client trace",
|
||||||
|
"trace" to
|
||||||
|
mapOf(
|
||||||
|
"vantage_point" to mapOf("type" to "client", "name" to "amethyst-quic"),
|
||||||
|
"common_fields" to
|
||||||
|
mapOf(
|
||||||
|
"ODCID" to odcidHex,
|
||||||
|
"reference_time" to startMillis,
|
||||||
|
"time_format" to "relative",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writeLineLocked(mapper.writeValueAsString(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionStarted(
|
||||||
|
serverName: String,
|
||||||
|
dcid: ByteArray,
|
||||||
|
scid: ByteArray,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:connection_started",
|
||||||
|
mapOf(
|
||||||
|
"ip_version" to "v4_or_v6",
|
||||||
|
"server_name" to serverName,
|
||||||
|
"dst_cid" to hex(dcid),
|
||||||
|
"src_cid" to hex(scid),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionClosed(
|
||||||
|
initiator: String,
|
||||||
|
errorCode: Long,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:connection_closed",
|
||||||
|
mapOf(
|
||||||
|
"owner" to initiator,
|
||||||
|
"application_code" to errorCode,
|
||||||
|
"reason" to reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPacketSent(
|
||||||
|
level: EncryptionLevel,
|
||||||
|
packetNumber: Long,
|
||||||
|
sizeBytes: Int,
|
||||||
|
frames: List<String>,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:packet_sent",
|
||||||
|
mapOf(
|
||||||
|
"header" to
|
||||||
|
mapOf(
|
||||||
|
"packet_type" to packetTypeFor(level),
|
||||||
|
"packet_number" to packetNumber,
|
||||||
|
),
|
||||||
|
"raw" to mapOf("length" to sizeBytes),
|
||||||
|
"frames" to frames.map { mapOf("frame_type" to it) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPacketReceived(
|
||||||
|
level: EncryptionLevel,
|
||||||
|
packetNumber: Long,
|
||||||
|
sizeBytes: Int,
|
||||||
|
frames: List<String>,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:packet_received",
|
||||||
|
mapOf(
|
||||||
|
"header" to
|
||||||
|
mapOf(
|
||||||
|
"packet_type" to packetTypeFor(level),
|
||||||
|
"packet_number" to packetNumber,
|
||||||
|
),
|
||||||
|
"raw" to mapOf("length" to sizeBytes),
|
||||||
|
"frames" to frames.map { mapOf("frame_type" to it) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPacketDropped(
|
||||||
|
reason: String,
|
||||||
|
sizeBytes: Int,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:packet_dropped",
|
||||||
|
mapOf(
|
||||||
|
"trigger" to reason,
|
||||||
|
"raw" to mapOf("length" to sizeBytes),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyUpdated(
|
||||||
|
keyType: String,
|
||||||
|
level: EncryptionLevel,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"security:key_updated",
|
||||||
|
mapOf(
|
||||||
|
"key_type" to "${keyType}_${packetTypeFor(level)}_secret",
|
||||||
|
"trigger" to "tls",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLossDetected(
|
||||||
|
level: EncryptionLevel,
|
||||||
|
lostPacketNumbers: List<Long>,
|
||||||
|
) {
|
||||||
|
for (pn in lostPacketNumbers) {
|
||||||
|
emit(
|
||||||
|
"recovery:packet_lost",
|
||||||
|
mapOf(
|
||||||
|
"header" to
|
||||||
|
mapOf(
|
||||||
|
"packet_type" to packetTypeFor(level),
|
||||||
|
"packet_number" to pn,
|
||||||
|
),
|
||||||
|
"trigger" to "reordering_threshold_or_time_threshold",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPtoFired(
|
||||||
|
consecutivePtoCount: Int,
|
||||||
|
ptoMillis: Long,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"recovery:loss_timer_updated",
|
||||||
|
mapOf(
|
||||||
|
"event_type" to "expired",
|
||||||
|
"timer_type" to "pto",
|
||||||
|
"pto_count" to consecutivePtoCount,
|
||||||
|
"delta" to ptoMillis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCongestionStateUpdated(newState: String) {
|
||||||
|
emit(
|
||||||
|
"recovery:congestion_state_updated",
|
||||||
|
mapOf("new" to newState),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransportParametersSet(
|
||||||
|
initiator: String,
|
||||||
|
params: Map<String, String>,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:parameters_set",
|
||||||
|
mapOf(
|
||||||
|
"owner" to initiator,
|
||||||
|
"params" to params,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAlpnNegotiated(alpn: String) {
|
||||||
|
emit(
|
||||||
|
"transport:alpn_information",
|
||||||
|
mapOf("chosen_alpn" to alpn),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVersionInformation(
|
||||||
|
chosenVersion: String,
|
||||||
|
otherVersionsOffered: List<String>,
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
"transport:version_information",
|
||||||
|
mapOf(
|
||||||
|
"chosen_version" to chosenVersion,
|
||||||
|
"client_versions" to otherVersionsOffered,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
lock.withLock {
|
||||||
|
try {
|
||||||
|
writer.flush()
|
||||||
|
} finally {
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emit(
|
||||||
|
name: String,
|
||||||
|
data: Map<String, Any?>,
|
||||||
|
) {
|
||||||
|
val event =
|
||||||
|
linkedMapOf<String, Any?>(
|
||||||
|
"time" to (nowMillis() - startMillis),
|
||||||
|
"name" to name,
|
||||||
|
"data" to data,
|
||||||
|
)
|
||||||
|
// Serialize OUTSIDE the lock so concurrent emitters don't
|
||||||
|
// serialize their JSON serially. The lock is only held while
|
||||||
|
// appending the line to the file.
|
||||||
|
val line = mapper.writeValueAsString(event)
|
||||||
|
writeLineLocked(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeLineLocked(line: String) {
|
||||||
|
lock.withLock {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
// Flush every line so a hard-killed process still leaves a
|
||||||
|
// partial-but-parseable trace.
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DEFAULT_MAPPER: ObjectMapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
private fun packetTypeFor(level: EncryptionLevel): String =
|
||||||
|
when (level) {
|
||||||
|
EncryptionLevel.INITIAL -> "initial"
|
||||||
|
EncryptionLevel.HANDSHAKE -> "handshake"
|
||||||
|
EncryptionLevel.APPLICATION -> "1RTT"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HEX_CHARS = "0123456789abcdef".toCharArray()
|
||||||
|
|
||||||
|
fun hex(bytes: ByteArray): String {
|
||||||
|
val sb = StringBuilder(bytes.size * 2)
|
||||||
|
for (b in bytes) {
|
||||||
|
val v = b.toInt() and 0xFF
|
||||||
|
sb.append(HEX_CHARS[v ushr 4])
|
||||||
|
sb.append(HEX_CHARS[v and 0x0F])
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
* 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.interop.runner
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.vitorpamplona.quic.connection.EncryptionLevel
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the JSON-NDJSON shape qvis (https://qvis.quictools.info/)
|
||||||
|
* expects: header line + one event per line, every line independently
|
||||||
|
* parseable as JSON.
|
||||||
|
*
|
||||||
|
* Drives [QlogWriter] through one event of each type to confirm we
|
||||||
|
* don't emit anything that breaks the format.
|
||||||
|
*/
|
||||||
|
class QlogWriterTest {
|
||||||
|
@Test
|
||||||
|
fun headerThenOneEventPerLine_allParseable() {
|
||||||
|
val tmp = Files.createTempFile("amethyst-qlog-test", ".sqlog").toFile()
|
||||||
|
tmp.deleteOnExit()
|
||||||
|
var clock = 0L
|
||||||
|
QlogWriter(tmp, odcidHex = "deadbeef", nowMillis = { clock }).use { w ->
|
||||||
|
clock = 5L
|
||||||
|
w.onConnectionStarted("example.test", byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6))
|
||||||
|
clock = 7L
|
||||||
|
w.onTransportParametersSet("local", mapOf("initial_max_data" to "1000000"))
|
||||||
|
clock = 10L
|
||||||
|
w.onPacketSent(EncryptionLevel.INITIAL, packetNumber = 0, sizeBytes = 1200, frames = listOf("crypto"))
|
||||||
|
clock = 15L
|
||||||
|
w.onPacketReceived(EncryptionLevel.INITIAL, packetNumber = 0, sizeBytes = 800, frames = listOf("crypto", "ack"))
|
||||||
|
clock = 20L
|
||||||
|
w.onPacketDropped("AEAD auth failed", sizeBytes = 80)
|
||||||
|
clock = 25L
|
||||||
|
w.onKeyUpdated("server", EncryptionLevel.HANDSHAKE)
|
||||||
|
clock = 30L
|
||||||
|
w.onLossDetected(EncryptionLevel.APPLICATION, lostPacketNumbers = listOf(3L, 5L))
|
||||||
|
clock = 35L
|
||||||
|
w.onPtoFired(consecutivePtoCount = 1, ptoMillis = 333)
|
||||||
|
clock = 40L
|
||||||
|
w.onCongestionStateUpdated("recovery")
|
||||||
|
clock = 45L
|
||||||
|
w.onAlpnNegotiated("h3")
|
||||||
|
clock = 50L
|
||||||
|
w.onVersionInformation("v1", emptyList())
|
||||||
|
clock = 55L
|
||||||
|
w.onConnectionClosed("local", errorCode = 0, reason = "done")
|
||||||
|
}
|
||||||
|
|
||||||
|
val lines = tmp.readLines().filter { it.isNotBlank() }
|
||||||
|
assertTrue(lines.size >= 12, "expected >= 12 lines (header + at least 11 events) but got ${lines.size}")
|
||||||
|
|
||||||
|
val mapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
// Line 1: qlog header.
|
||||||
|
val header = mapper.readTree(lines[0])
|
||||||
|
assertEquals("0.3", header.get("qlog_version").asText(), "qlog_version must be 0.3")
|
||||||
|
assertEquals("JSON-SEQ", header.get("qlog_format").asText(), "qlog_format must be JSON-SEQ")
|
||||||
|
val vp = header.get("trace").get("vantage_point")
|
||||||
|
assertEquals("client", vp.get("type").asText())
|
||||||
|
assertEquals(
|
||||||
|
"deadbeef",
|
||||||
|
header
|
||||||
|
.get("trace")
|
||||||
|
.get("common_fields")
|
||||||
|
.get("ODCID")
|
||||||
|
.asText(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lines 2..N: event objects with `time`, `name`, `data`.
|
||||||
|
for (i in 1 until lines.size) {
|
||||||
|
val node = mapper.readTree(lines[i])
|
||||||
|
assertNotNull(node.get("time"), "line $i missing 'time': ${lines[i]}")
|
||||||
|
val name = node.get("name")
|
||||||
|
assertNotNull(name, "line $i missing 'name': ${lines[i]}")
|
||||||
|
assertTrue(
|
||||||
|
name.asText().contains(":"),
|
||||||
|
"name '${name.asText()}' must be in '<category>:<event>' form",
|
||||||
|
)
|
||||||
|
assertNotNull(node.get("data"), "line $i missing 'data': ${lines[i]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check specific events made it through.
|
||||||
|
val names = lines.drop(1).map { mapper.readTree(it).get("name").asText() }
|
||||||
|
assertTrue(names.contains("transport:connection_started"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:packet_sent"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:packet_received"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:packet_dropped"), names.toString())
|
||||||
|
assertTrue(names.contains("security:key_updated"), names.toString())
|
||||||
|
assertTrue(names.contains("recovery:packet_lost"), names.toString())
|
||||||
|
assertTrue(names.contains("recovery:loss_timer_updated"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:parameters_set"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:alpn_information"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:version_information"), names.toString())
|
||||||
|
assertTrue(names.contains("transport:connection_closed"), names.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timesAreRelativeToConstructorTime() {
|
||||||
|
val tmp = Files.createTempFile("amethyst-qlog-rel", ".sqlog").toFile()
|
||||||
|
tmp.deleteOnExit()
|
||||||
|
var clock = 1_000L
|
||||||
|
QlogWriter(tmp, odcidHex = "00", nowMillis = { clock }).use { w ->
|
||||||
|
clock = 1_050L
|
||||||
|
w.onAlpnNegotiated("h3")
|
||||||
|
}
|
||||||
|
val lines = tmp.readLines().filter { it.isNotBlank() }
|
||||||
|
val mapper = jacksonObjectMapper()
|
||||||
|
val event = mapper.readTree(lines[1])
|
||||||
|
assertEquals(50L, event.get("time").asLong(), "time must be relative to constructor (1050 - 1000)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fileEndsWithNewline_qvisCompatible() {
|
||||||
|
val tmp = Files.createTempFile("amethyst-qlog-nl", ".sqlog").toFile()
|
||||||
|
tmp.deleteOnExit()
|
||||||
|
QlogWriter(tmp, odcidHex = "00").use { w ->
|
||||||
|
w.onAlpnNegotiated("h3")
|
||||||
|
}
|
||||||
|
val bytes = tmp.readBytes()
|
||||||
|
assertTrue(bytes.isNotEmpty(), "file must not be empty")
|
||||||
|
assertEquals('\n'.code.toByte(), bytes.last(), "file must end with '\\n' so trailing event parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesEmptyFramesList(): Unit =
|
||||||
|
File.createTempFile("amethyst-qlog-empty", ".sqlog").let { tmp ->
|
||||||
|
tmp.deleteOnExit()
|
||||||
|
QlogWriter(tmp, odcidHex = "00").use { w ->
|
||||||
|
w.onPacketSent(EncryptionLevel.INITIAL, 0, 1200, emptyList())
|
||||||
|
}
|
||||||
|
val mapper = jacksonObjectMapper()
|
||||||
|
val lines = tmp.readLines().filter { it.isNotBlank() }
|
||||||
|
val frames = mapper.readTree(lines[1]).get("data").get("frames")
|
||||||
|
assertTrue(frames.isArray, "frames must be an array even when empty")
|
||||||
|
assertEquals(0, frames.size())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user