diff --git a/docs/build-status.md b/docs/build-status.md index 8b26023..1442030 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -941,3 +941,27 @@ Also: a `BackHandler` now returns from Settings/History to the run screen. The s state variable with nothing connecting it to the back stack, so the system Back gesture left the app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run screen. + +### Upstream throughput (server-v0.6.3, 2026-08-01) +The mirror of the downstream case: the client generates the traffic and the server counts it. No +grant is involved — the client is sending its own packets, so there is nothing to amplify — but it +does need the server's tally, because **only the far end knows how much arrived**. Without that +number a sender measures how fast it can *transmit*, which is usually just the speed of the local +NIC and is a different question from the one being asked. + +`TYPE_THROUGHPUT_UP` (0x0F) is counted and deliberately **never answered**: a reply would double +the traffic and drag the return path into a measurement that is specifically about the outbound +one. + +The tally is a counter, not a list, and short-circuits **before** the observation log. A +five-second run at 20 Mbps is around ten thousand packets; one struct each would turn a +measurement into an allocation storm on a shared server, and nothing needs the per-packet detail +since the client holds the send-side record. The gap between the two counts is the loss. + +`direction=up` on the throughput action sends nothing — it zeroes the counter, so a second run in +one session measures itself rather than inheriting the first one's packets. The live test asserts +`received <= sent`, which is what catches a counter that was never reset. + +Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with +`measures_network: false` — correct, since what arrived matched what was offered, so the path was +never the constraint. diff --git a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveThroughputTest.kt b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveThroughputTest.kt index 1d95479..8680abd 100644 --- a/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveThroughputTest.kt +++ b/echolot-app/core-engine/src/test/kotlin/app/echo_lot/engine/LiveThroughputTest.kt @@ -65,4 +65,41 @@ class LiveThroughputTest { assertTrue(m.contains(""""limited_by":"duration""""), "the run did not end on the clock, so the rate measures the server, not the path: $m") } -} + + // Upstream is the direction only the far end can measure. The assertion that matters is that + // the server's count is present and plausible against what we sent — a test that only checked + // "we transmitted some Mbps" would pass against a server that counted nothing at all. + @Test + fun measuresUpstreamAgainstTheServersCount() { + if (url == null || pin == null || cred == null || udp == null) { + println("LiveThroughputTest(up) skipped"); return + } + val control = ControlClient(url, setOf(pin), "0.2.0") + val session = control.createSession(cred, target) + val (host, port) = udp.split(":").let { it[0] to it[1].toInt() } + + val (test, findings) = ProbeSession(cred, session, host, port).use { ps -> + ps.echo() + ThroughputMeasurement(SystemIdSource()).runUpstream( + cred, session.sessionId, control, ps, sessionRef = "sess-1", + durationS = 3, kbps = 10_000, + ) + } + control.deleteSession(cred, session.sessionId) + + val m = assertNotNull(test.metrics).toString() + println("upstream: ${test.status} $m") + for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}") + + assertEquals(TestStatus.OK, test.status, "the server counted nothing: $m") + val recv = Regex(""""received_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt() + val sent = Regex(""""sent_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt() + assertNotNull(recv); assertNotNull(sent) + assertTrue(sent > 100, "barely anything was sent, so the rate means nothing: $m") + assertTrue(recv > 0, "the server received none of $sent packets: $m") + // The counts should be close on a healthy path; wildly different means the two sides are + // counting different things rather than the network losing packets. + assertTrue(recv <= sent, "the server counted MORE than we sent — the counter is not being reset") + println("sent $sent, server saw $recv") + } +} \ No newline at end of file