chore: ignore the VSCodium Java extension's bin/ output
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2521d39989
commit
7a94c9a3d7
@@ -1,166 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package engine composes core-protocol probes into core-measurement documents — the run engine
|
||||
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
|
||||
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.*
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.ProbeSession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
|
||||
/**
|
||||
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
|
||||
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
|
||||
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
|
||||
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
|
||||
* (no clocks/UUIDs of its own) and unit-testable.
|
||||
*/
|
||||
class ServerMeasurement(
|
||||
private val ids: IdSource,
|
||||
private val app: AppInfo,
|
||||
private val device: DeviceInfo,
|
||||
) {
|
||||
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||
|
||||
data class Config(
|
||||
val controlUrl: String,
|
||||
val pins: Set<String>,
|
||||
val credential: String,
|
||||
val target: String,
|
||||
val udpHost: String,
|
||||
val udpPort: Int,
|
||||
val echoCount: Int = 20,
|
||||
val echoPaddingBytes: Int = 64,
|
||||
)
|
||||
|
||||
fun run(cfg: Config): MeasurementDocument {
|
||||
val runId = ids.uuid()
|
||||
val startWall = ids.nowWall()
|
||||
val startMono = ids.monoNs()
|
||||
|
||||
val control = ControlClient(cfg.controlUrl, cfg.pins)
|
||||
val profile = control.profile(cfg.credential)
|
||||
val session = control.createSession(cfg.credential, cfg.target)
|
||||
|
||||
val serverSession = ServerSession(
|
||||
id = "sess-1",
|
||||
profileName = profile.name,
|
||||
controlUrl = cfg.controlUrl,
|
||||
serverVersion = profile.serverVersion,
|
||||
capabilities = profile.capabilities,
|
||||
sessionId = session.sessionId,
|
||||
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
|
||||
)
|
||||
|
||||
val (test, findings) = echoTrain(cfg, control, session, startMono)
|
||||
|
||||
control.deleteSession(cfg.credential, session.sessionId)
|
||||
|
||||
val summary = Verdicts.derive(listOf(test), findings)
|
||||
return MeasurementDocument(
|
||||
run = Run(
|
||||
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
|
||||
clock = Clock(monoOriginWall = startWall),
|
||||
app = app, device = device,
|
||||
tiers = Tiers(app = true),
|
||||
),
|
||||
serverSessions = listOf(serverSession),
|
||||
tests = listOf(test),
|
||||
findings = findings,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
private fun echoTrain(
|
||||
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
|
||||
): Pair<Test, List<Finding>> {
|
||||
val testId = ids.uuid()
|
||||
val seqs = ArrayList<Int>()
|
||||
val tTx = ArrayList<Long?>()
|
||||
val tRx = ArrayList<Long?>()
|
||||
val sizes = ArrayList<Int>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sent = cfg.echoCount
|
||||
val received = rtts.size
|
||||
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||
val natRebinding = observedPorts.size > 1
|
||||
|
||||
val evidence: JsonObject = TrainEvidence(
|
||||
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
|
||||
).toEvidence()
|
||||
|
||||
val metrics: JsonObject = json.encodeToJsonElement(
|
||||
EchoMetrics(
|
||||
sent = sent, received = received, lossPct = round1(lossPct),
|
||||
rttMsMin = rtts.minOrNull()?.let(::round1),
|
||||
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
|
||||
rttMsMax = rtts.maxOrNull()?.let(::round1),
|
||||
observedPorts = observedPorts.toList(),
|
||||
natRebindingDetected = natRebinding,
|
||||
)
|
||||
) as JsonObject
|
||||
|
||||
val status = when {
|
||||
received == 0 -> TestStatus.FAILED
|
||||
received < sent -> TestStatus.PARTIAL
|
||||
else -> TestStatus.OK
|
||||
}
|
||||
val test = Test(
|
||||
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
|
||||
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
|
||||
evidence = evidence, metrics = metrics,
|
||||
)
|
||||
|
||||
val findings = ArrayList<Finding>()
|
||||
if (received == 0) {
|
||||
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
|
||||
"No UDP echo replies from the server",
|
||||
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
|
||||
} else if (lossPct >= 20.0) {
|
||||
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
|
||||
"High UDP loss to the server (${round1(lossPct)}%)",
|
||||
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
|
||||
}
|
||||
if (natRebinding) {
|
||||
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
|
||||
"NAT remapped the UDP source port mid-flow",
|
||||
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
|
||||
}
|
||||
return test to findings
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val Wire_HEADER = 32
|
||||
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
|
||||
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
|
||||
*/
|
||||
interface IdSource {
|
||||
fun uuid(): String
|
||||
fun monoNs(): Long
|
||||
fun nowWall(): String
|
||||
}
|
||||
|
||||
class SystemIdSource : IdSource {
|
||||
override fun uuid(): String = UUID.randomUUID().toString()
|
||||
override fun monoNs(): Long = System.nanoTime()
|
||||
override fun nowWall(): String = Instant.now().toString()
|
||||
}
|
||||
|
||||
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
|
||||
@Serializable
|
||||
data class EchoMetrics(
|
||||
val sent: Int,
|
||||
val received: Int,
|
||||
@SerialName("loss_pct") val lossPct: Double,
|
||||
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
|
||||
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
|
||||
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
|
||||
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
|
||||
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.protocol.ControlClient
|
||||
import app.echo_lot.protocol.ProbeSession
|
||||
import app.echo_lot.protocol.Wire
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
|
||||
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
|
||||
*
|
||||
* This is the direction a client cannot measure alone — only the far end can push large or
|
||||
* numerous packets toward it — so it is also the direction that needs the anti-amplification
|
||||
* grant, and this test is the proof that the grant path works end to end.
|
||||
*/
|
||||
class LiveGrantedTest {
|
||||
|
||||
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"
|
||||
|
||||
@Test
|
||||
fun downstreamTrainAndBigSend() {
|
||||
if (url == null || pin == null || cred == null || udp == null) {
|
||||
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||
}
|
||||
val control = ControlClient(url, setOf(pin))
|
||||
val profile = control.profile(cred)
|
||||
println("capabilities: ${profile.capabilities}")
|
||||
val session = control.createSession(cred, target)
|
||||
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||
|
||||
ProbeSession(cred, session, host, port).use { ps ->
|
||||
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
|
||||
val echo = ps.echo()
|
||||
println("primed with echo rtt=${echo?.rttMs}")
|
||||
|
||||
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
|
||||
val dtResp = control.action(
|
||||
cred, session.sessionId,
|
||||
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
|
||||
)
|
||||
println("downtrain accepted: ${dtResp.take(160)}")
|
||||
val down = ps.collectGranted(windowMs = 4000)
|
||||
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
|
||||
val seqs = down.map { it.seq }.toSet()
|
||||
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
|
||||
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
||||
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
|
||||
|
||||
// --- big_send: which downstream sizes survive? ---
|
||||
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
|
||||
val bsResp = control.action(
|
||||
cred, session.sessionId,
|
||||
"""{"action":"big_send","sizes_bytes":${sizes}}""",
|
||||
)
|
||||
println("big_send accepted: ${bsResp.take(160)}")
|
||||
val big = ps.collectGranted(windowMs = 4000)
|
||||
.filter { it.type == Wire.TYPE_BIG_SEND }
|
||||
val arrived = big.map { it.sizeBytes }.sorted()
|
||||
println("big_send arrived sizes: $arrived (requested $sizes)")
|
||||
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
|
||||
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
|
||||
}
|
||||
control.deleteSession(cred, session.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.engine
|
||||
|
||||
import app.echo_lot.measurement.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Runs the full server-facing engine against a live server and validates the produced
|
||||
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
|
||||
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
|
||||
*/
|
||||
class LiveMeasurementTest {
|
||||
|
||||
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"
|
||||
|
||||
@Test
|
||||
fun producesValidDocumentFromLiveServer() {
|
||||
if (url == null || pin == null || cred == null || udp == null) {
|
||||
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
|
||||
return
|
||||
}
|
||||
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||
val engine = ServerMeasurement(
|
||||
ids = SystemIdSource(),
|
||||
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
|
||||
device = DeviceInfo("test", "jvm", 0, "n/a"),
|
||||
)
|
||||
val doc = engine.run(
|
||||
ServerMeasurement.Config(
|
||||
controlUrl = url, pins = setOf(pin), credential = cred,
|
||||
target = target, udpHost = host, udpPort = port, echoCount = 20,
|
||||
)
|
||||
)
|
||||
|
||||
// The document must round-trip and carry the expected structure.
|
||||
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
|
||||
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
|
||||
|
||||
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)
|
||||
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
|
||||
"expected replies from live server, got ${test.status}")
|
||||
|
||||
val metrics = Json.parseToJsonElement(test.metrics.toString())
|
||||
println("metrics: $metrics")
|
||||
assertTrue(metrics.toString().contains("rtt_ms_avg"))
|
||||
|
||||
assertTrue(doc.summary != null)
|
||||
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
|
||||
println("summary: ${doc.summary}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user