fix(quic-interop): QlogWriter swallows post-close writes

aioquic full-matrix run came back 0/7 with stack traces:
  java.io.IOException: Stream closed
    at QlogWriter.writeLineLocked(QlogWriter.kt:289)
    at QlogWriter.onPacketSent(QlogWriter.kt:126)
    at QuicConnectionWriter.emitQlogSent(...)

The send loop runs concurrently with InteropClient's teardown sequence:
  1. driver.close() — launches CLOSING → CLOSED async
  2. qlogWriter?.close() — closes the file
  3. delay(50)
  4. scope.cancel() — kills coroutines

Between (2) and (4), the send loop is still alive and emits
packet_sent for the CONNECTION_CLOSE Initial. QlogWriter.writeLineLocked
threw IOException into the send loop, propagated up, took down the
connection — including connections that had completed transfer
successfully.

Fix: QlogWriter swallows post-close IOExceptions silently. An observer
must NOT break the connection it's observing — that's the whole NoOp
contract. Adds @Volatile closed flag, latches it both on explicit
close() and on the first IOException; subsequent emits short-circuit.

picoquic was 5/7 (predictable: M + S still open) so the regression was
specifically aioquic-timing-sensitive. With this fix aioquic should
return to 5/7 too.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 00:40:21 +00:00
parent 2ec995b1b6
commit bd9d717dff
@@ -60,6 +60,12 @@ class QlogWriter(
// 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))
/** Latched true once close() runs OR once a write throws IOException.
* Subsequent emits short-circuit instead of throwing into the
* send loop and tearing down an otherwise-healthy connection. */
@Volatile
private var closed: Boolean = false
private val lock = ReentrantLock()
private val startMillis: Long = nowMillis()
@@ -259,6 +265,7 @@ class QlogWriter(
override fun close() {
lock.withLock {
closed = true
try {
writer.flush()
} finally {
@@ -286,11 +293,22 @@ class QlogWriter(
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()
// The send loop may still emit packet_sent events for the
// CONNECTION_CLOSE packet after the application calls close()
// — observers MUST NOT break the connection. Silently swallow
// post-close IOExceptions; the trace is what we have.
if (closed) return
try {
writer.write(line)
writer.write("\n")
// Flush every line so a hard-killed process still leaves a
// partial-but-parseable trace.
writer.flush()
} catch (_: java.io.IOException) {
// Stream closed under us. Latch closed so subsequent emits
// skip the lock and return immediately.
closed = true
}
}
}