app: long runs that watch, and a relay for the port that keeps moving
Long mode starts listeners at t=0 and keeps them running past the battery: a network-change watcher that finally fills networks[].changes[] (defined since the schema's first draft, never populated), an RSSI log, a ping series giving loss and jitter over minutes, and mDNS listening for the whole window. This is the class of fault a short run cannot see - a link that drops for four seconds between two probes is reported healthy by both of them. run.mode records which question was asked, because silence means different things in the two modes. The adb relay replaces the retired beacon: AdbRelay watches adbd's own mDNS with the resolve-once discipline the beacon learned the hard way (resolving re-arms adbd and pops a notification), a foreground service keeps it alive with the screen off, and the heartbeat re-posts the cached endpoint rather than re-resolving. It exists because mDNS does not cross subnets and the wireless-debug port rotates every few minutes. Also records why LLDP/CDP cannot follow SSDP into long mode: both are raw L2 frames, so they need CAP_NET_RAW - root tier, not app, and Shizuku's shell user does not have it either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae63bd7c7f
commit
0071e00003
@@ -28,6 +28,7 @@ data class MeasurementDocument(
|
||||
data class Run(
|
||||
val id: String, // UUIDv7
|
||||
val trigger: Trigger,
|
||||
val mode: RunMode = RunMode.SHORT,
|
||||
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||
@SerialName("ended_at") val endedAt: String? = null,
|
||||
val clock: Clock,
|
||||
@@ -66,6 +67,27 @@ data class Constraints(
|
||||
val constrained: Boolean get() = vpnActive || perNetworkBlocked
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the run watched the network — and therefore what its silence is worth.
|
||||
*
|
||||
* A [SHORT] run is a sequence of one-shot probes: each looks at the network for a second or two and
|
||||
* moves on. That is enough to characterise a network's *configuration*, and it is structurally
|
||||
* incapable of seeing anything intermittent. A wifi link that drops for four seconds every two
|
||||
* minutes, a resolver that stalls under load, an AP that roams — none of these leave a trace in
|
||||
* thirty seconds of probing unless the run happened to coincide with one.
|
||||
*
|
||||
* A [LONG] run starts continuous listeners at t=0, runs the same battery beside them, and keeps
|
||||
* sampling until the window closes. It answers a different question, so a reader must not treat the
|
||||
* two alike: **the mode is what licenses an argument from absence**. "No drops were observed" means
|
||||
* something after five minutes of watching and nothing at all after a thirty-second run, and
|
||||
* without this field the two documents are indistinguishable.
|
||||
*/
|
||||
@Serializable
|
||||
enum class RunMode {
|
||||
@SerialName("short") SHORT,
|
||||
@SerialName("long") LONG,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Trigger {
|
||||
@SerialName("manual") MANUAL,
|
||||
|
||||
+21
-1
@@ -103,6 +103,26 @@ object FindingRegistry {
|
||||
"Android's own connectivity checks fail on this network.",
|
||||
)
|
||||
|
||||
/**
|
||||
* The finding a short run cannot make.
|
||||
*
|
||||
* Every one-shot probe describes the network during its own two seconds. A link that drops and
|
||||
* returns between two of them leaves no trace anywhere in the document — the probes before and
|
||||
* after both succeed, and the run reports a healthy network. Only a listener that watches the
|
||||
* whole window sees the gap, which is why this is emitted from `networks[].changes[]` (§4)
|
||||
* rather than from any test's evidence.
|
||||
*
|
||||
* MEDIUM by default and escalated by the emitter on repeat: one drop in five minutes is worth
|
||||
* knowing about, three is the difference between "the wifi hiccuped" and "this link is why
|
||||
* calls keep dropping". Deliberately claims a *completed* cycle — lost and then regained — so
|
||||
* it never fires for a network that was simply turned off partway through the run.
|
||||
*/
|
||||
val LINK_FLAPPING = FindingSpec(
|
||||
"connectivity.link_flapping", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||
"A network dropped and came back one or more times during the run.",
|
||||
rulesOut = "A momentary probe failure: the drop was watched happening, not inferred from silence.",
|
||||
)
|
||||
|
||||
// ---- mtu -------------------------------------------------------------------------
|
||||
|
||||
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
||||
@@ -305,7 +325,7 @@ object FindingRegistry {
|
||||
/** Every registered finding, in declaration order. */
|
||||
val all: List<FindingSpec> = listOf(
|
||||
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
||||
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
|
||||
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET, LINK_FLAPPING,
|
||||
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
||||
FRAGMENT_REORDER_SENSITIVE,
|
||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||
|
||||
@@ -144,3 +144,38 @@ data class NetworkChange(
|
||||
val kind: String, // lost | gained | link_changed
|
||||
val detail: JsonObject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a network's `changes[]` add up to.
|
||||
*
|
||||
* Lives beside the type rather than in the collector that produces it because two independent
|
||||
* consumers ask the same question — the watcher, computing its metrics, and the run engine,
|
||||
* deciding whether to emit `connectivity.link_flapping` — and a document whose metric and finding
|
||||
* disagreed about how many times the link dropped would be worse than one reporting neither.
|
||||
*/
|
||||
object NetworkChanges {
|
||||
|
||||
const val LOST = "lost"
|
||||
const val GAINED = "gained"
|
||||
const val LINK_CHANGED = "link_changed"
|
||||
|
||||
/**
|
||||
* Completed drop-and-return cycles: a `lost` with a later `gained` on the same network.
|
||||
*
|
||||
* A cycle has to *complete*. A link that goes away at minute four and is still gone when the
|
||||
* window closes was not flapping — it was switched off, or the device was carried out of
|
||||
* range, and calling that the same fault would put a phone in a lift beside a failing access
|
||||
* point.
|
||||
*/
|
||||
fun flapCycles(kinds: List<String>): Int {
|
||||
var cycles = 0
|
||||
var down = false
|
||||
for (k in kinds) {
|
||||
if (k == LOST) down = true
|
||||
else if (k == GAINED && down) { cycles++; down = false }
|
||||
}
|
||||
return cycles
|
||||
}
|
||||
|
||||
fun flapCyclesOf(changes: List<NetworkChange>): Int = flapCycles(changes.map { it.kind })
|
||||
}
|
||||
|
||||
@@ -133,6 +133,16 @@ object TestType {
|
||||
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||
/**
|
||||
* WS-Discovery (UDP 3702) and NetBIOS name service (UDP 137). Registry additions, v1.2.
|
||||
*
|
||||
* Both are passive: the traffic is broadcast to the segment whether or not anyone asks, so
|
||||
* listening is the whole measurement. They earn their own ids rather than folding into
|
||||
* [LOCAL_SSDP_INVENTORY] because what they imply differs — WS-Discovery inventories printers
|
||||
* and cameras, while NetBIOS/LLMNR chatter is a security finding in its own right.
|
||||
*/
|
||||
const val LOCAL_WSD_INVENTORY = "local.wsd_inventory"
|
||||
const val LOCAL_NETBIOS_INVENTORY = "local.netbios_inventory"
|
||||
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||
const val LOCAL_NTP = "local.ntp"
|
||||
// peer
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.measurement
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Pins the counting behind `connectivity.link_flapping`.
|
||||
*
|
||||
* The finding claims a link went away and came back, and its severity escalates on repetition, so
|
||||
* this is arithmetic a person reading a report will act on. The cases that matter are the ones
|
||||
* where the naive count is wrong: a link still down when the window closed, and a run that started
|
||||
* while the link was already gone.
|
||||
*/
|
||||
class NetworkChangesTest {
|
||||
|
||||
@Test
|
||||
fun aQuietWindowHasNoCycles() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(emptyList()))
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("link_changed", "link_changed")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneDropAndReturnIsOneCycle() {
|
||||
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained")))
|
||||
assertEquals(
|
||||
1,
|
||||
NetworkChanges.flapCycles(listOf("link_changed", "lost", "link_changed", "gained")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedDropsCountSeparately() {
|
||||
assertEquals(3, NetworkChanges.flapCycles(listOf("lost", "gained", "lost", "gained", "lost", "gained")))
|
||||
}
|
||||
|
||||
// A link that is still down when the run ends was not flapping — it was switched off, or the
|
||||
// device left its range. Counting that as a cycle would put a phone in a lift beside a failing
|
||||
// access point.
|
||||
@Test
|
||||
fun aDropThatNeverReturnsIsNotACycle() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("lost")))
|
||||
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained", "lost")))
|
||||
}
|
||||
|
||||
// The mirror case: the window opened while the network was already gone, so its return is the
|
||||
// first thing seen. Nothing was watched dropping, so nothing is claimed.
|
||||
@Test
|
||||
fun aReturnWithNoObservedDropIsNotACycle() {
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("gained")))
|
||||
assertEquals(0, NetworkChanges.flapCycles(listOf("gained", "link_changed")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theChangeOverloadAgreesWithTheKindsOverload() {
|
||||
val changes = listOf(
|
||||
NetworkChange(atMonoNs = 1, kind = NetworkChanges.LOST),
|
||||
NetworkChange(atMonoNs = 2, kind = NetworkChanges.GAINED),
|
||||
NetworkChange(atMonoNs = 3, kind = NetworkChanges.LINK_CHANGED),
|
||||
)
|
||||
assertEquals(1, NetworkChanges.flapCyclesOf(changes))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user