throughput: the upstream direction, counted by the only party that can
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 33s

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 not the question being asked.

A new wire type the server counts and deliberately never answers: 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 instead of inheriting the first.

Same honesty rule as downstream: measures_network is false when what arrived
matches what was offered, because then the path was never the constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 15:54:09 +02:00
co-authored by Claude Fable 5
parent 8646bab52d
commit 892e952a8e
6 changed files with 277 additions and 2 deletions
+25 -2
View File
@@ -225,7 +225,9 @@ func (s *Server) observations(w http.ResponseWriter, r *http.Request) {
"connect_back": cb,
// The sender's own count, which is what makes the receiver's count mean something.
"throughput": sess.ThroughputReports(),
"dns_canary": dnsCanary,
// The receiver's count for upstream runs — same idea, other direction.
"throughput_up": upstreamJSON(sess),
"dns_canary": dnsCanary,
// TODO(spec §6): http echo records
})
}
@@ -433,9 +435,20 @@ func (s *Server) actions(w http.ResponseWriter, r *http.Request) {
// Only the downstream direction needs the server to send. Upstream is the client
// sending and the server counting, which needs no action at all — so asking for it here
// is a client bug worth naming rather than silently doing the other thing.
if req.Direction == "up" {
// Upstream needs nothing sent from here — the client generates the traffic and the
// server counts it. The only thing an action can usefully do is zero the counter so
// the run measures itself rather than inheriting an earlier one.
sess.ResetUpstream()
writeJSON(w, http.StatusAccepted, map[string]any{
"action_id": actionID, "direction": "up", "reset": true,
"note": "send TYPE_THROUGHPUT_UP packets, then read observations.throughput_up",
})
return
}
if req.Direction != "" && req.Direction != "down" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "only direction=down is an action; upstream throughput is measured by sending and reading observations",
"error": "direction must be up or down",
})
return
}
@@ -785,3 +798,13 @@ func (s *Server) EnrollmentLink(token string) string {
"&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
"&t=" + url.QueryEscape(token)
}
// upstreamJSON renders the upstream tally with the derived figures already computed, so every
// consumer does not have to repeat (and risk fumbling) the same arithmetic.
func upstreamJSON(sess *session.Session) map[string]any {
u := sess.Upstream()
return map[string]any{
"packets": u.Packets, "bytes": u.Bytes,
"span_ms": u.SpanMs(), "kbps": u.Kbps(),
}
}