// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package session // Upstream trains (spec §3.2): TRAIN_DATA packets get no per-packet response, so the server's // received view is the only record of what survived the upstream path. It is kept per train — // NOT in the flat udpObs ring, whose 4096-entry cap would silently roll the head of a 5000-packet // train out from under the report that is about to be requested. Losing the head quietly turns // real early-train packets into phantom loss; a bounded buffer that says when it overflowed // (Truncated, mirroring the measurement schema's evidence_truncated) keeps the numbers honest. // TrainEntry is the server's received view of one train packet. TTL/DSCP/ECN use 0xFF for // "not observed" (spec §3.3), same sentinel as the echo observation block. type TrainEntry struct { Seq uint32 TRxNs int64 // server clock, session epoch Size uint16 TTL uint8 DSCP uint8 ECN uint8 } // Train is the received view of one upstream train, keyed by the id the client put in the // TRAIN_DATA payload. type Train struct { ID uint32 // Received counts every packet of the train, including any the entry buffer no longer holds; // the loss figure must come from this, not from len(Entries). Received int Truncated bool Entries []TrainEntry } const ( // trainCap comfortably holds the largest train the client-side action bounds allow (5000 // packets, matching the downtrain clamp). Overflow keeps the head and sets Truncated: the // early packets are the ones a ring would drop, and the tail's absence is at least declared. trainCap = 8192 // maxTrains bounds one session's train memory (~8×8k×24 B ≈ 1.5 MiB worst case). The oldest // train is evicted for a new one because reports are requested train-by-train, right after // each train — an id still being sent to is always the one worth keeping. maxTrains = 8 ) // RecordTrainPacket appends one received TRAIN_DATA packet to its train's buffer. func (s *Session) RecordTrainPacket(trainID uint32, e TrainEntry) { s.mu.Lock() defer s.mu.Unlock() var t *Train for _, c := range s.trains { if c.ID == trainID { t = c break } } if t == nil { if len(s.trains) >= maxTrains { s.trains = s.trains[1:] } t = &Train{ID: trainID} s.trains = append(s.trains, t) } t.Received++ if len(t.Entries) >= trainCap { t.Truncated = true return } t.Entries = append(t.Entries, e) } // TrainView returns a copy of one train. A missing id reports ok=false; the caller decides // whether "never saw it" is an error or (for a report request) the answer itself. func (s *Session) TrainView(trainID uint32) (Train, bool) { s.mu.Lock() defer s.mu.Unlock() for _, t := range s.trains { if t.ID == trainID { cp := *t cp.Entries = append([]TrainEntry(nil), t.Entries...) return cp, true } } return Train{}, false } // Trains returns copies of every train witnessed in this session, oldest first. func (s *Session) Trains() []Train { s.mu.Lock() defer s.mu.Unlock() out := make([]Train, 0, len(s.trains)) for _, t := range s.trains { cp := *t cp.Entries = append([]TrainEntry(nil), t.Entries...) out = append(out, cp) } return out }