throughput: the upstream direction, counted by the only party that can
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:
co-authored by
Claude Fable 5
parent
8646bab52d
commit
892e952a8e
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ const (
|
||||
TypeFragData = 0x0D
|
||||
// TypeThroughputData is one packet of a sustained-rate downstream run.
|
||||
TypeThroughputData = 0x0E
|
||||
// TypeThroughputUp is one packet of a client-driven upstream run. The server counts it and
|
||||
// deliberately does not answer: a reply would double the traffic and measure the return
|
||||
// path at the same time, which is the one thing this test is trying not to do.
|
||||
TypeThroughputUp = 0x0F
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -152,6 +156,14 @@ func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRx
|
||||
if la, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
sess.NoteDataLocal(la.AddrPort())
|
||||
}
|
||||
|
||||
// Upstream throughput short-circuits before the observation log. Recording one struct per
|
||||
// packet here would mean tens of thousands of allocations for a single run; the counter is
|
||||
// all anyone needs, since the client holds the send-side record.
|
||||
if typ == TypeThroughputUp {
|
||||
sess.CountUpstream(len(pkt), tRxNs)
|
||||
return
|
||||
}
|
||||
sess.RecordUDP(session.UDPObservation{
|
||||
Seq: seq, TRxNs: tRxNs, TTxNs: time.Since(s.start).Nanoseconds(),
|
||||
Src: raddr.String(), Size: len(pkt), Type: typ,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user