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:
co-authored by
Claude Opus 5
parent
d574c76630
commit
515a6aef04
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user