engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate: mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only because the server sets DF), mtu.frag_delivery (whether larger ones arrive once fragmentation is allowed), and train.udp_downstream (loss, reordering and arrival spacing in the download direction, which a round trip cannot separate from upstream loss). ServerMeasurement now runs them on the same ProbeSession as the echo train. It had to: a fresh session restarts client-side sequence numbers and the server's anti-replay window discards the lot, so the re-primed source is never recorded and every granted send goes to a socket that has already closed. That produced four confidently-wrong FAILED tests and a RED verdict on a healthy network. Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce1aaa332a
commit
14e5fad1b2
@@ -0,0 +1,344 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.*
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.ProbeSession
|
||||
import app.echo_lot.protocol.Wire
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
|
||||
/**
|
||||
* The measurements only the far end can make: what the *downstream* path does to traffic the
|
||||
* client never asked for packet-by-packet.
|
||||
*
|
||||
* A client alone can measure a round trip, and it can find the largest packet it can *send*. It
|
||||
* cannot find the largest packet it can *receive*, or whether the network drops downstream
|
||||
* packets independently of upstream ones — those need a server willing to push, which is why the
|
||||
* protocol gates them behind an asymmetric grant (probe-protocol.md §3.4).
|
||||
*
|
||||
* Three separate facts come out, and keeping them separate is the point:
|
||||
* - `mtu.pmtud_down` — the largest datagram that arrives *unfragmented*. This is the number
|
||||
* that matters for anything setting DF, and it is only meaningful because the server sets DF.
|
||||
* - `mtu.frag_delivery` — whether larger datagrams arrive once the network is allowed to
|
||||
* fragment them. A path can be fine for one and broken for the other; conflating them is how
|
||||
* you get "MTU is 4000" on a link that drops every DF packet over 1400.
|
||||
* - `train.udp_downstream` — loss, reordering and arrival spacing in the download direction.
|
||||
*/
|
||||
class DownstreamMeasurement(private val ids: IdSource) {
|
||||
|
||||
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||
|
||||
/** How long to wait for a granted burst after the server accepts the action. */
|
||||
private val collectWindowMs = 4_000L
|
||||
|
||||
/**
|
||||
* Runs all three against an already-primed session.
|
||||
*
|
||||
* [session] must already have sent at least one ECHO: the grant is bound to the source the
|
||||
* server has actually observed, so an unprimed session gets a 409 rather than a grant. That
|
||||
* is the anti-amplification rule doing its job, not an error to work around.
|
||||
*/
|
||||
fun run(
|
||||
credential: String,
|
||||
sessionId: String,
|
||||
control: ControlClient,
|
||||
probe: ProbeSession,
|
||||
sessionRef: String,
|
||||
sizes: List<Int> = DEFAULT_SIZES,
|
||||
trainCount: Int = 100,
|
||||
trainSizeBytes: Int = 300,
|
||||
trainIntervalUs: Int = 3_000,
|
||||
): Pair<List<Test>, List<Finding>> {
|
||||
val tests = ArrayList<Test>()
|
||||
val findings = ArrayList<Finding>()
|
||||
|
||||
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
|
||||
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
|
||||
val train = downTrain(
|
||||
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs,
|
||||
)
|
||||
|
||||
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
|
||||
|
||||
// A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud:
|
||||
// it is the usual cause of "small requests work, large responses hang".
|
||||
val pathMtu = df.largestDelivered
|
||||
if (pathMtu != null && pathMtu > 0) {
|
||||
val ipMtu = pathMtu + IP_UDP_OVERHEAD4
|
||||
if (ipMtu < 1500) {
|
||||
findings.add(
|
||||
finding(
|
||||
"mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id,
|
||||
"Downstream path MTU is $ipMtu bytes, below 1500",
|
||||
"The largest datagram that reached this device without fragmenting was " +
|
||||
"$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " +
|
||||
"IPv6-in-IPv4) commonly do this; it is only a fault when something " +
|
||||
"on the path also blocks the ICMP messages that let senders discover it.",
|
||||
),
|
||||
)
|
||||
}
|
||||
// The dangerous combination: unfragmented large packets vanish AND fragments do too,
|
||||
// so a sender that never gets told will retransmit into a black hole.
|
||||
val fragLargest = frag.largestDelivered ?: 0
|
||||
if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) {
|
||||
findings.add(
|
||||
finding(
|
||||
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id,
|
||||
"Datagrams above $pathMtu bytes are dropped downstream, fragmented or not",
|
||||
"Nothing larger than $pathMtu bytes arrived, even when the network was " +
|
||||
"free to fragment it. Traffic that relies on large responses will " +
|
||||
"stall rather than fail cleanly.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (train.received == 0) {
|
||||
findings.add(
|
||||
finding(
|
||||
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id,
|
||||
"No server-initiated packets arrived",
|
||||
"The server sent ${train.sent} packets toward this device and none arrived, " +
|
||||
"while the round-trip echo worked. Something on the path forwards replies " +
|
||||
"but drops traffic the device did not individually solicit.",
|
||||
),
|
||||
)
|
||||
} else if (train.lossPct >= 5.0) {
|
||||
findings.add(
|
||||
finding(
|
||||
"connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id,
|
||||
"Downstream loss of ${round1(train.lossPct)}%",
|
||||
"${train.sent - train.received} of ${train.sent} packets sent toward this " +
|
||||
"device were lost. Downstream loss is invisible to a round-trip test, " +
|
||||
"which reports only that *something* was lost somewhere.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (train.reordered > 0) {
|
||||
findings.add(
|
||||
finding(
|
||||
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id,
|
||||
"${train.reordered} downstream packet(s) arrived out of order",
|
||||
"Packets arrived in a different order than they were sent. Usually per-packet " +
|
||||
"load balancing across links; harmless for most traffic, not for all of it.",
|
||||
),
|
||||
)
|
||||
}
|
||||
return tests to findings
|
||||
}
|
||||
|
||||
// ---- big_send ---------------------------------------------------------------------
|
||||
|
||||
private class SizeResult(val test: Test, val largestDelivered: Int?)
|
||||
|
||||
private fun bigSend(
|
||||
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
||||
sessionRef: String, sizes: List<Int>, df: Boolean,
|
||||
): SizeResult {
|
||||
val testId = ids.uuid()
|
||||
val started = ids.monoNs()
|
||||
val requested = sizes.joinToString(",")
|
||||
|
||||
val reply = runCatching {
|
||||
control.action(
|
||||
credential, sessionId,
|
||||
"""{"action":"big_send","df":$df,"sizes_bytes":[$requested]}""",
|
||||
)
|
||||
}
|
||||
if (reply.isFailure) {
|
||||
return SizeResult(
|
||||
Test(
|
||||
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
|
||||
sessionRef = sessionRef, tier = Tier.APP,
|
||||
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||
status = TestStatus.UNSUPPORTED,
|
||||
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "big_send refused"),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
// The server tells us which sizes it actually put on the wire. With DF it refuses
|
||||
// anything above its own egress MTU, and treating those as "lost downstream" would
|
||||
// blame the client's network for our own limit.
|
||||
val accepted = parseIntArray(reply.getOrNull(), "sizes_bytes").ifEmpty { sizes }
|
||||
val serverMaxDf = parseInt(reply.getOrNull(), "max_df_bytes")
|
||||
|
||||
val arrived = probe.collectGranted(collectWindowMs)
|
||||
.filter { it.type == Wire.TYPE_BIG_SEND }
|
||||
.map { it.sizeBytes }
|
||||
.distinct()
|
||||
.sorted()
|
||||
val largest = arrived.maxOrNull()
|
||||
|
||||
val metrics = json.encodeToJsonElement(
|
||||
BigSendMetrics(
|
||||
requestedBytes = sizes,
|
||||
sentBytes = accepted,
|
||||
deliveredBytes = arrived,
|
||||
largestDeliveredBytes = largest,
|
||||
dontFragment = df,
|
||||
serverMaxDfBytes = serverMaxDf,
|
||||
// Only meaningful for the DF run; the IP-level MTU is the payload plus headers.
|
||||
pathMtuBytes = if (df && largest != null) largest + IP_UDP_OVERHEAD4 else null,
|
||||
),
|
||||
) as JsonObject
|
||||
|
||||
val status = when {
|
||||
arrived.isEmpty() -> TestStatus.FAILED
|
||||
arrived.size < accepted.size -> TestStatus.PARTIAL
|
||||
else -> TestStatus.OK
|
||||
}
|
||||
return SizeResult(
|
||||
Test(
|
||||
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
|
||||
sessionRef = sessionRef, tier = Tier.APP,
|
||||
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||
status = status, metrics = metrics,
|
||||
),
|
||||
largest,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- downtrain --------------------------------------------------------------------
|
||||
|
||||
private class TrainResult(
|
||||
val test: Test, val sent: Int, val received: Int, val lossPct: Double, val reordered: Int,
|
||||
)
|
||||
|
||||
private fun downTrain(
|
||||
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
||||
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int,
|
||||
): TrainResult {
|
||||
val testId = ids.uuid()
|
||||
val started = ids.monoNs()
|
||||
|
||||
val reply = runCatching {
|
||||
control.action(
|
||||
credential, sessionId,
|
||||
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""",
|
||||
)
|
||||
}
|
||||
if (reply.isFailure) {
|
||||
return TrainResult(
|
||||
Test(
|
||||
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef,
|
||||
tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||
status = TestStatus.UNSUPPORTED,
|
||||
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "downtrain refused"),
|
||||
),
|
||||
0, 0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
val sent = parseInt(reply.getOrNull(), "count") ?: count
|
||||
|
||||
val got = probe.collectGranted(collectWindowMs).filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
|
||||
val received = got.size
|
||||
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||
|
||||
// Reordering: a packet whose sequence is below the highest already seen. Counting
|
||||
// inversions rather than "not sorted" keeps one late packet from being reported as
|
||||
// dozens of reorder events.
|
||||
var highest = -1
|
||||
var reordered = 0
|
||||
for (p in got) {
|
||||
if (p.seq < highest) reordered++ else highest = p.seq
|
||||
}
|
||||
|
||||
// Columnar evidence per the schema: what arrived, when, and how big — so every metric
|
||||
// above is recomputable by a reader who does not trust our arithmetic.
|
||||
val evidence = TrainEvidence(
|
||||
epochMonoNs = started,
|
||||
seq = got.map { it.seq },
|
||||
tTxNs = got.map { null },
|
||||
tRxNs = got.map { it.tRxNs },
|
||||
sizeBytes = got.map { it.sizeBytes },
|
||||
).toEvidence()
|
||||
|
||||
val interArrival = got.zipWithNext { a, b -> (b.tRxNs - a.tRxNs) / 1_000_000.0 }
|
||||
val metrics = json.encodeToJsonElement(
|
||||
DownTrainMetrics(
|
||||
sent = sent, received = received, lossPct = round1(lossPct),
|
||||
reorderedPackets = reordered,
|
||||
sizeBytes = sizeBytes,
|
||||
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
|
||||
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
|
||||
sendIntervalUs = intervalUs,
|
||||
),
|
||||
) as JsonObject
|
||||
|
||||
val status = when {
|
||||
received == 0 -> TestStatus.FAILED
|
||||
received < sent -> TestStatus.PARTIAL
|
||||
else -> TestStatus.OK
|
||||
}
|
||||
return TrainResult(
|
||||
Test(
|
||||
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, tier = Tier.APP,
|
||||
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||
status = status, evidence = evidence, metrics = metrics,
|
||||
),
|
||||
sent, received, lossPct, reordered,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------------
|
||||
|
||||
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
|
||||
Finding(
|
||||
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||
)
|
||||
|
||||
/** Minimal scalar extraction from the action reply; the shape is small and server-owned. */
|
||||
private fun parseInt(body: String?, key: String): Int? =
|
||||
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
||||
|
||||
private fun parseIntArray(body: String?, key: String): List<Int> =
|
||||
body?.let { b ->
|
||||
Regex("\"$key\"\\s*:\\s*\\[([^\\]]*)\\]").find(b)?.groupValues?.get(1)
|
||||
?.split(",")?.mapNotNull { it.trim().toIntOrNull() }
|
||||
} ?: emptyList()
|
||||
|
||||
private companion object {
|
||||
/** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */
|
||||
const val IP_UDP_OVERHEAD4 = 28
|
||||
|
||||
/** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */
|
||||
val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000)
|
||||
|
||||
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||
}
|
||||
}
|
||||
|
||||
/** Metrics for mtu.pmtud_down / mtu.frag_delivery. */
|
||||
@Serializable
|
||||
data class BigSendMetrics(
|
||||
@SerialName("requested_bytes") val requestedBytes: List<Int>,
|
||||
@SerialName("sent_bytes") val sentBytes: List<Int>,
|
||||
@SerialName("delivered_bytes") val deliveredBytes: List<Int>,
|
||||
@SerialName("largest_delivered_bytes") val largestDeliveredBytes: Int? = null,
|
||||
@SerialName("dont_fragment") val dontFragment: Boolean,
|
||||
/** The server's own DF ceiling; sizes above it were never sent and are not path evidence. */
|
||||
@SerialName("server_max_df_bytes") val serverMaxDfBytes: Int? = null,
|
||||
@SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null,
|
||||
)
|
||||
|
||||
/** Metrics for train.udp_downstream. */
|
||||
@Serializable
|
||||
data class DownTrainMetrics(
|
||||
val sent: Int,
|
||||
val received: Int,
|
||||
@SerialName("loss_pct") val lossPct: Double,
|
||||
@SerialName("reordered_packets") val reorderedPackets: Int,
|
||||
@SerialName("size_bytes") val sizeBytes: Int,
|
||||
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
|
||||
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
|
||||
@SerialName("send_interval_us") val sendIntervalUs: Int,
|
||||
)
|
||||
@@ -36,6 +36,12 @@ class ServerMeasurement(
|
||||
val udpPort: Int,
|
||||
val echoCount: Int = 20,
|
||||
val echoPaddingBytes: Int = 64,
|
||||
/**
|
||||
* Whether to ask the server to push traffic back (downstream MTU and downstream train).
|
||||
* Costs a few hundred kB of download and needs a server that advertises the grants, so
|
||||
* it is a flag rather than an assumption.
|
||||
*/
|
||||
val downstream: Boolean = true,
|
||||
)
|
||||
|
||||
fun run(cfg: Config): MeasurementDocument {
|
||||
@@ -57,11 +63,32 @@ class ServerMeasurement(
|
||||
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
|
||||
)
|
||||
|
||||
val (test, findings) = echoTrain(cfg, control, session, startMono)
|
||||
val tests = ArrayList<Test>()
|
||||
val allFindings = ArrayList<Finding>()
|
||||
|
||||
// One ProbeSession for the whole run. A second one would open a new socket and restart
|
||||
// the sequence counter, which the server's anti-replay window correctly rejects — so the
|
||||
// re-primed source is never recorded and every granted send goes to the old, closed port.
|
||||
// Session identity lives on the server; the socket must live as long as it does.
|
||||
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
|
||||
val (test, findings) = echoTrain(cfg, ps, startMono)
|
||||
tests.add(test)
|
||||
allFindings.addAll(findings)
|
||||
|
||||
// Downstream needs a session the server has already seen traffic from — the echo
|
||||
// train just provided that — and a server that advertises the grants. Skipped
|
||||
// quietly against an older server rather than reported as a failure of the network.
|
||||
if (cfg.downstream && profile.supports("downtrain") && profile.supports("big-send")) {
|
||||
val (dsTests, dsFindings) = DownstreamMeasurement(ids)
|
||||
.run(cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1")
|
||||
tests.addAll(dsTests)
|
||||
allFindings.addAll(dsFindings)
|
||||
}
|
||||
}
|
||||
|
||||
control.deleteSession(cfg.credential, session.sessionId)
|
||||
|
||||
val summary = Verdicts.derive(listOf(test), findings)
|
||||
val summary = Verdicts.derive(tests, allFindings)
|
||||
return MeasurementDocument(
|
||||
run = Run(
|
||||
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
|
||||
@@ -70,14 +97,14 @@ class ServerMeasurement(
|
||||
tiers = Tiers(app = true),
|
||||
),
|
||||
serverSessions = listOf(serverSession),
|
||||
tests = listOf(test),
|
||||
findings = findings,
|
||||
tests = tests,
|
||||
findings = allFindings,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun echoTrain(
|
||||
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
|
||||
cfg: Config, ps: ProbeSession, startMono: Long,
|
||||
): Pair<Test, List<Finding>> {
|
||||
val testId = ids.uuid()
|
||||
val seqs = ArrayList<Int>()
|
||||
@@ -87,20 +114,18 @@ class ServerMeasurement(
|
||||
val rtts = ArrayList<Double>()
|
||||
val observedPorts = LinkedHashSet<Int>()
|
||||
|
||||
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
|
||||
for (i in 0 until cfg.echoCount) {
|
||||
val txMono = ids.monoNs() - startMono
|
||||
val r = ps.echo(cfg.echoPaddingBytes)
|
||||
seqs.add(i)
|
||||
tTx.add(txMono)
|
||||
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
|
||||
if (r != null) {
|
||||
tRx.add(ids.monoNs() - startMono)
|
||||
rtts.add(r.rttMs)
|
||||
r.observation?.observedPort?.let { observedPorts.add(it) }
|
||||
} else {
|
||||
tRx.add(null)
|
||||
}
|
||||
for (i in 0 until cfg.echoCount) {
|
||||
val txMono = ids.monoNs() - startMono
|
||||
val r = ps.echo(cfg.echoPaddingBytes)
|
||||
seqs.add(i)
|
||||
tTx.add(txMono)
|
||||
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
|
||||
if (r != null) {
|
||||
tRx.add(ids.monoNs() - startMono)
|
||||
rtts.add(r.rttMs)
|
||||
r.observation?.observedPort?.let { observedPorts.add(it) }
|
||||
} else {
|
||||
tRx.add(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.TestStatus
|
||||
import app.echo_lot.measurement.TestType
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.ProbeSession
|
||||
import kotlin.test.Test as JTest
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not
|
||||
* just that packets moved: the tests must carry recomputable metrics and land on the right test
|
||||
* types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*.
|
||||
*/
|
||||
class LiveDownstreamTest {
|
||||
|
||||
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||
|
||||
@JTest
|
||||
fun producesDownstreamTestsAndFindings() {
|
||||
if (url == null || pin == null || cred == null || udp == null) {
|
||||
println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||
}
|
||||
val control = ControlClient(url, setOf(pin))
|
||||
val session = control.createSession(cred, target)
|
||||
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||
|
||||
val (tests, findings) = ProbeSession(cred, session, host, port).use { ps ->
|
||||
ps.echo() // prime: the grant binds to the source the server has actually observed
|
||||
DownstreamMeasurement(SystemIdSource())
|
||||
.run(cred, session.sessionId, control, ps, sessionRef = "sess-1")
|
||||
}
|
||||
control.deleteSession(cred, session.sessionId)
|
||||
|
||||
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
|
||||
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
|
||||
|
||||
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train")
|
||||
val byType = tests.associateBy { it.type }
|
||||
|
||||
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
|
||||
assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL,
|
||||
"DF probe did not deliver anything: ${pmtud.status}")
|
||||
val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull()
|
||||
assertNotNull(pathMtu, "pmtud_down must report a path MTU")
|
||||
assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu")
|
||||
println("downstream path MTU = $pathMtu bytes")
|
||||
|
||||
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
|
||||
assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
|
||||
|
||||
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
|
||||
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
|
||||
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
|
||||
assertTrue(received > 0, "no downstream train packets arrived")
|
||||
println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " +
|
||||
"reordered=${train.metrics?.get("reordered_packets")}")
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,11 @@ class LiveMeasurementTest {
|
||||
|
||||
assertEquals(1, doc.serverSessions.size)
|
||||
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
|
||||
val test = doc.tests.single()
|
||||
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
|
||||
// A full run is the echo train plus the three downstream tests; assert on the one this
|
||||
// test is about rather than on the count, so adding a measurement is not a test edit.
|
||||
for (t in doc.tests) println(" ${t.type} → ${t.status}")
|
||||
for (f in doc.findings) println(" finding ${f.code} [${f.severity}] ${f.title}")
|
||||
val test = doc.tests.first { it.type == TestType.TRAIN_UDP_UPDOWN }
|
||||
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
|
||||
"expected replies from live server, got ${test.status}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user