app: speak train (0x03-0x05) and mark downtrains with DSCP

ProbeSession sends paced upstream trains and fetches the server's columnar
received view; UpstreamTrainMeasurement lines both ledgers up per sequence
number into TrainEvidence - the directional loss attribution a round trip
cannot make. Loss is computed against the server's total count, not its
row list, so a buffer-capped report can never invent loss on big trains.
A missing report stays ambiguous by name (report lost, or server predates
trains) instead of being blamed on the train.

downtrain actions can now request a DSCP marking, recording dscp_applied
so a survival comparison never blames the path for a marking the sender
skipped.

Also logged: fmr runs v0.11.2 built from commits this repo's remote never
saw, so --self-update on fmr is OFF-LIMITS until that lineage is repaired
- the fresh server-v0.9.2 release is newest-created but semantically
older, and the updater compares strings, not SemVer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 13:34:52 +02:00
co-authored by Claude Opus 5
parent d574c76630
commit 515a6aef04
7 changed files with 424 additions and 3 deletions
@@ -152,6 +152,149 @@ class ProbeSession(
/** What one upstream run put on the wire locally. */
data class Sent(val packets: Int, val bytes: Long, val durationMs: Long, val kbps: Int)
// ---- upstream trains (spec §3.2, types 0x03-0x05) --------------------------------------
/** One TRAIN_DATA packet as sent: its wire seq, local tx time and size. */
data class TrainPacket(val seq: Int, val tTxNs: Long, val sizeBytes: Int)
/** One row of the server's received view. 255 in ttl/dscp/ecn means "not observed". */
data class TrainRow(
val seq: Int, val tRxNs: Long, val sizeBytes: Int,
val ttl: Int, val dscp: Int, val ecn: Int,
)
/**
* The server's account of one train. [received] counts every packet that arrived, buffered
* or not; [truncated] mirrors the wire flag (rows beyond the server's cap were counted but
* not kept). [partsExpected]/[partsReceived] make a lossy report path visible instead of
* letting missing rows masquerade as train loss.
*/
data class TrainReport(
val trainId: Int,
val received: Int,
val truncated: Boolean,
val rows: List<TrainRow>,
val partsExpected: Int,
val partsReceived: Int,
)
/**
* Sends one paced upstream train. Nothing comes back per packet by design; pair with
* [trainReport] to learn what arrived. The absolute schedule (not sleep-per-packet) is the
* same anti-drift choice as [sendThroughput].
*/
fun sendTrain(trainId: Int, count: Int, sizeBytes: Int = 200, interPacketMs: Long = 5): List<TrainPacket> {
val size = sizeBytes.coerceIn(Wire.HEADER_SIZE + 4, 1472)
val out = ArrayList<TrainPacket>(count)
val start = System.nanoTime()
var next = start
for (i in 0 until count) {
val payload = ByteArray(size - Wire.HEADER_SIZE)
payload[0] = (trainId ushr 24).toByte(); payload[1] = (trainId ushr 16).toByte()
payload[2] = (trainId ushr 8).toByte(); payload[3] = trainId.toByte()
val tTx = nowNs()
val pkt = Wire.build(Wire.TYPE_TRAIN_DATA, prefix, ++seq, tTx, key, payload)
try {
socket.send(DatagramPacket(pkt, pkt.size, server))
} catch (e: java.io.IOException) {
// A local send failure is our condition, not the path's: report what actually
// left rather than letting the server's report read as loss.
break
}
out.add(TrainPacket(seq, tTx, pkt.size))
next += interPacketMs * 1_000_000
val sleepNs = next - System.nanoTime()
if (sleepNs > 0) Thread.sleep(sleepNs / 1_000_000, (sleepNs % 1_000_000).toInt())
}
return out
}
/**
* Fetches the server's received view of a train (one REPORT_REQ, N REPORT datagrams).
*
* Returns null when no report arrives at all — indistinguishable between "report lost" and
* "server predates trains", and the caller must say so rather than choose. Missing parts of
* a multi-part report are tolerated and visible via partsReceived < partsExpected.
*/
fun trainReport(trainId: Int, timeoutMs: Long = 3_000): TrainReport? {
val req = ByteArray(4)
req[0] = (trainId ushr 24).toByte(); req[1] = (trainId ushr 16).toByte()
req[2] = (trainId ushr 8).toByte(); req[3] = trainId.toByte()
val pkt = Wire.build(Wire.TYPE_TRAIN_REPORT_REQ, prefix, ++seq, nowNs(), key, req)
socket.send(DatagramPacket(pkt, pkt.size, server))
var received = 0
var truncated = false
var partsExpected = -1
val seenParts = HashSet<Int>()
val rows = ArrayList<TrainRow>()
val deadline = System.nanoTime() + timeoutMs * 1_000_000
val buf = ByteArray(2048)
val prevTimeout = socket.soTimeout
try {
while (partsExpected < 0 || seenParts.size < partsExpected) {
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
if (remainMs <= 0) break
socket.soTimeout = remainMs.coerceAtMost(1000)
val dp = DatagramPacket(buf, buf.size)
try {
socket.receive(dp)
} catch (e: java.net.SocketTimeoutException) {
continue
}
val p = Wire.parseVerified(buf, dp.length, key) ?: continue
if (p.type != Wire.TYPE_TRAIN_REPORT) continue
val part = parseReportPart(p.payload, trainId) ?: continue
if (!seenParts.add(part.part)) continue
received = part.received
truncated = truncated || part.truncated
partsExpected = part.parts
rows.addAll(part.rows)
}
} finally {
socket.soTimeout = prevTimeout
}
if (seenParts.isEmpty()) return null
rows.sortBy { it.seq }
return TrainReport(trainId, received, truncated, rows, partsExpected, seenParts.size)
}
private class ReportPart(
val part: Int, val parts: Int, val received: Int,
val truncated: Boolean, val rows: List<TrainRow>,
)
/** Mirrors the server's columnar layout (dataplane/train.go buildTrainReport). */
private fun parseReportPart(b: ByteArray, wantId: Int): ReportPart? {
if (b.size < 16) return null
fun u16(off: Int) = ((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
fun u32(off: Int) = ((b[off].toLong() and 0xFF) shl 24) or ((b[off + 1].toLong() and 0xFF) shl 16) or
((b[off + 2].toLong() and 0xFF) shl 8) or (b[off + 3].toLong() and 0xFF)
if (u32(0).toInt() != wantId) return null
val received = u32(4).toInt()
val part = u16(8)
val parts = u16(10)
val truncated = (b[12].toInt() and 0x01) != 0
val n = u16(14)
if (b.size < 16 + n * 17) return null
val rows = ArrayList<TrainRow>(n)
var off = 16
val seqs = IntArray(n) { u32(off + it * 4).toInt() }; off += n * 4
val tRx = LongArray(n) {
var v = 0L
for (j in 0 until 8) v = (v shl 8) or (b[off + it * 8 + j].toLong() and 0xFF)
v
}; off += n * 8
val sizes = IntArray(n) { u16(off + it * 2) }; off += n * 2
val ttls = IntArray(n) { b[off + it].toInt() and 0xFF }; off += n
val dscps = IntArray(n) { b[off + it].toInt() and 0xFF }; off += n
val ecns = IntArray(n) { b[off + it].toInt() and 0xFF }
for (i in 0 until n) {
rows.add(TrainRow(seqs[i], tRx[i], sizes[i], ttls[i], dscps[i], ecns[i]))
}
return ReportPart(part, parts, received, truncated, rows)
}
/** One packet received from the server, with the wire size actually delivered. */
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
@@ -23,6 +23,14 @@ object Wire {
const val TYPE_ECHO_REQ: Int = 0x01
const val TYPE_ECHO_RESP: Int = 0x02
/**
* Upstream train (spec §3.2): DATA is deliberately unanswered — a per-packet reply would
* double the traffic and drag the return path into a measurement of the outbound one. The
* server's received view comes back afterwards via REPORT_REQ → one or more REPORTs.
*/
const val TYPE_TRAIN_DATA: Int = 0x03
const val TYPE_TRAIN_REPORT_REQ: Int = 0x04
const val TYPE_TRAIN_REPORT: Int = 0x05
const val TYPE_TIMESYNC_REQ: Int = 0x07
const val TYPE_TIMESYNC_RSP: Int = 0x08
const val TYPE_MTU_PROBE: Int = 0x09