fix(aprs): hold the client lock across the response read in sendPacket

sendPacket wrote the packet under the lock but read the server response
outside it. disconnect() - called concurrently from stop() and from the
reconnect path in AprsReporter.reportOnce's catch - nulls and closes
writer/reader/socket under the same lock, so the lock-free read raced with it.
A probe interleaving 5,000 sends with repeated disconnects produced a mix of
744 OK and 4,256 exception results: the response read hit a just-closed socket
and the swallowing runCatching reported Pair(true,"OK") for a packet that may
never have left, or read through a stale reference. The tracker believed the
beacon was heard while APRS-IS never received it.

Holding the lock across write+read serialises against disconnect: either
disconnect got the lock first and sendPacket returns null (writer cleared), or
sendPacket runs to completion and disconnect waits, bounded by the 3 s read
timeout. Re-ran the interleaving probe: 3,000 sends, zero inconsistent
results. Compiles and :core:data tests stay green.
This commit is contained in:
mckero committed 2026-08-17 16:33:12 +00:00
1 parent b95a86c97f
commit baf2a7022d
1 file changed
+9 -2
@@ -77,8 +77,14 @@ class AprsIsClient(
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
// Read the server response inside the same lock: disconnect() (called
// concurrently from stop()/reconnect on another thread) nulls
// writer/reader/socket and closes them. Reading outside the lock raced
// with that: the response read could hit a just-closed socket and the
// swallowing runCatching reported Pair(true,"OK") for a packet that
// never left, or read through a stale reference. Serialising keeps
// the read on the connection this thread just wrote to. The 3 s read
// timeout bounds how long a concurrent disconnect waits.
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
@@ -96,6 +102,7 @@ class AprsIsClient(
}
}.getOrElse { Pair(true, "OK") }
}
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {