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
@@ -543,3 +543,81 @@ Best available behavior, now implemented: the banner still opens Shizuku, but th
|
||||
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
|
||||
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
|
||||
debugging must be enabled first for Shizuku's wireless start to work at all.
|
||||
|
||||
### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
|
||||
The client can measure a round trip and the largest packet it can *send*. It cannot measure the
|
||||
largest packet it can *receive*, or downstream-only loss — those need the server to push, which is
|
||||
exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
|
||||
|
||||
- **`session.Grant`** — created per action, bound at creation to the session's *observed*
|
||||
data-plane source (no grant without a verified destination), clamped to server limits, with a
|
||||
byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.
|
||||
- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss,
|
||||
reordering and inter-arrival spacing.
|
||||
- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size
|
||||
that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only
|
||||
says whether fragments get through — a different fact, and the reason the schema has both
|
||||
`mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the
|
||||
startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by
|
||||
our kernel is never read as a limit of the client's path.
|
||||
|
||||
Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery
|
||||
up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a
|
||||
3000 µs send interval.
|
||||
|
||||
#### Two bugs this shook out, both invisible in a single-homed lab
|
||||
1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two
|
||||
IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind
|
||||
list. A train for a session established on `.150` left from `.151` and every packet was dropped
|
||||
by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the
|
||||
client saw none — reported as *100 % downstream loss*, a confident measurement of something
|
||||
that never happened. Sessions now record which of our own bound addresses received their
|
||||
traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the
|
||||
family fallback.
|
||||
2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at
|
||||
zero client-side while the server's anti-replay window keeps counting, so every packet is
|
||||
discarded as a replay — and because the server then never records the new source, the grant
|
||||
still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole
|
||||
run; `ProbeSession`'s doc comment states the constraint.
|
||||
|
||||
### Run archive, anonymizer and uploads (2026-08-01)
|
||||
Three pieces, deliberately separate:
|
||||
|
||||
- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can
|
||||
inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is
|
||||
enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three
|
||||
ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto
|
||||
all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.
|
||||
- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes
|
||||
nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a
|
||||
public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour
|
||||
inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics,
|
||||
statuses and finding codes. Pseudonyms are consistent within a document and — by default — not
|
||||
across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for
|
||||
people diffing their own history. Classification is one readable table, not annotations spread
|
||||
across modules. 14 tests, each pinning a property someone's privacy depends on.
|
||||
- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max
|
||||
runs per device, and the *least* anonymization accepted. The profile advertises all of it so the
|
||||
app presents the choice honestly instead of discovering the rules by being rejected. `account`
|
||||
refuses today rather than falling back to anonymous: picking the strict setting before OIDC
|
||||
lands must not silently mean the loose one.
|
||||
|
||||
**The archive holds the unredacted document; redaction happens on the way out, per upload.** The
|
||||
local archive is the user's own data on their own device, and redacting it would destroy exactly
|
||||
the detail that makes a week-old run worth keeping.
|
||||
|
||||
App-side: settings screen (archive limits, privacy level with a plain-language description of what
|
||||
each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether
|
||||
each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer
|
||||
the user cannot inspect is only a promise.
|
||||
|
||||
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the
|
||||
SSDP neighbour name and the free-text note are absent from what the server stores while the
|
||||
finding code and the metrics survive, then deleted it.
|
||||
|
||||
### Still open
|
||||
- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval.
|
||||
- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
|
||||
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
|
||||
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
|
||||
against the live server. On-device verification is the next step.
|
||||
|
||||
Reference in New Issue
Block a user