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
+61
View File
@@ -41,6 +41,7 @@ type Session struct {
udpObs []UDPObservation // ring, newest last, cap obsCap
connectBack []ConnectBackResult
throughput []ThroughputReport
upstream UpstreamCounter
}
const obsCap = 4096
@@ -67,6 +68,36 @@ type ThroughputReport struct {
LimitedBy string `json:"limited_by"`
}
// UpstreamCounter is the server's tally of a client-driven throughput run.
//
// Deliberately a counter and not a list. A five-second upstream run at 20 Mbps is around ten
// thousand packets; one observation struct each would turn a measurement into an allocation
// storm on a shared server, and nothing downstream needs the per-packet detail - the client
// already has its own send record. The gap between the two counts IS the loss.
type UpstreamCounter struct {
Packets int `json:"packets"`
Bytes int64 `json:"bytes"`
FirstRxNs int64 `json:"first_rx_ns"`
LastRxNs int64 `json:"last_rx_ns"`
}
// SpanMs is the time between the first and last packet, which is the interval the rate should be
// computed over - not the client's requested duration, which includes ramp-up and the tail.
func (u UpstreamCounter) SpanMs() int64 {
if u.Packets < 2 || u.LastRxNs <= u.FirstRxNs {
return 0
}
return (u.LastRxNs - u.FirstRxNs) / 1_000_000
}
// Kbps is bits per millisecond, which is kilobits per second - no scaling constant to get wrong.
func (u UpstreamCounter) Kbps() int {
if ms := u.SpanMs(); ms > 0 {
return int(u.Bytes * 8 / ms)
}
return 0
}
// ConnectBackResult records one connect-back action outcome.
type ConnectBackResult struct {
ActionID string `json:"action_id"`
@@ -100,6 +131,36 @@ func (s *Session) Observations() (packetsSeen uint64, udp []UDPObservation, cb [
append([]ConnectBackResult(nil), s.connectBack...)
}
// CountUpstream tallies one client-sent throughput packet.
//
// Called on the hot path for every packet of an upstream run, so it does exactly two additions
// and two comparisons under the lock and allocates nothing.
func (s *Session) CountUpstream(sizeBytes int, tRxNs int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.upstream.Packets == 0 {
s.upstream.FirstRxNs = tRxNs
}
s.upstream.Packets++
s.upstream.Bytes += int64(sizeBytes)
s.upstream.LastRxNs = tRxNs
}
// Upstream returns the tally so far.
func (s *Session) Upstream() UpstreamCounter {
s.mu.Lock()
defer s.mu.Unlock()
return s.upstream
}
// ResetUpstream clears the tally, so a second run in one session measures itself rather than
// inheriting the first one's packets.
func (s *Session) ResetUpstream() {
s.mu.Lock()
defer s.mu.Unlock()
s.upstream = UpstreamCounter{}
}
// RecordThroughput stores the server's account of one sustained send.
//
// Kept as a per-action summary rather than per-packet records: a ten-second run at 50 Mbps is