Skip to main content

seyd_core/
engine.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Session engine: binds the packer and control vocabulary to
4//! `seyd-transport` sessions. Owns nothing network-discovery related and
5//! nothing vendor related; the host (seydd, or an SDK wrapper) feeds frames
6//! and messages in and consumes `Event`s out.
7
8use crate::channels::{ChannelKind, ChannelSpec};
9use crate::control::{encode_line, FromPilot, ToPilot};
10use crate::packer::{self, FrameParams};
11use bytes::Bytes;
12use seyd_qos::abr::{AbrController, Sample as AbrSample};
13use seyd_qos::simulcast::LayerSelector;
14use seyd_qos::Profile;
15use seyd_transport::{Endpoint, SendError, Session};
16use seyd_wire::v2::{ChunkHeader, FrameMeta};
17use std::collections::{HashMap, VecDeque};
18use std::sync::atomic::{AtomicU16, AtomicU64, Ordering};
19use std::sync::{Arc, Mutex, RwLock};
20use std::time::{Duration, Instant};
21use tokio::sync::{mpsc, Notify};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Role {
25    Driver,
26    Observer,
27}
28
29impl Role {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Role::Driver => "driver",
33            Role::Observer => "observer",
34        }
35    }
36}
37
38/// What the engine tells its host.
39#[derive(Debug, Clone)]
40pub enum Event {
41    SessionStarted {
42        signal_id: String,
43        role: Role,
44        path_label: String,
45    },
46    SessionEnded {
47        signal_id: String,
48        reason: String,
49    },
50    /// A driver sent a message on a command channel.
51    Command {
52        channel: u8,
53        payload: Bytes,
54    },
55    /// Seyd's request to the publisher (`video-config`).
56    RequestedConfig(serde_json::Value),
57    /// A pilot needs a recovery point on this channel (`recovery-request`).
58    RecoveryRequest {
59        channel: u8,
60        kind: &'static str,
61        reason: &'static str,
62    },
63    /// The relayed simulcast layer changed (ADR 0008). The host may use it to
64    /// force an IDR on the incoming layer so the switch lands sooner; the engine
65    /// switches on that layer's next keyframe either way.
66    LayerChanged {
67        channel: u8,
68        layer: u8,
69        name: String,
70        reason: &'static str,
71    },
72}
73
74/// One encoded picture from the host.
75pub struct VideoFrame {
76    pub data: Bytes,
77    pub keyframe: bool,
78    pub capture_ts_us: u64,
79}
80
81#[derive(Clone)]
82pub struct EngineConfig {
83    pub channels: Vec<ChannelSpec>,
84    pub profile: &'static Profile,
85    pub max_sessions: usize,
86    /// Ask for the cheapest useful recovery point (LTR, then intra-refresh,
87    /// then IDR) rather than always demanding a keyframe. Set false for a
88    /// publisher that mishandles anything but `idr`.
89    pub recovery_ladder: bool,
90    pub chunk_len: u16,
91}
92
93#[derive(Default, Debug)]
94pub struct Counters {
95    pub frames_in: AtomicU64,
96    pub frames_sent: AtomicU64,
97    pub frames_dropped_backlog: AtomicU64,
98    pub frames_skipped_stale: AtomicU64,
99    /// Frames arriving on a simulcast layer we are not relaying. Expected and
100    /// large whenever more than one layer is declared — it is the cost of
101    /// having the alternative already encoded and ready to switch to.
102    pub frames_dropped_inactive_layer: AtomicU64,
103    pub keyframes_requested: AtomicU64,
104    pub chunks_sent: AtomicU64,
105    pub parity_sent: AtomicU64,
106    pub bytes_sent: AtomicU64,
107}
108
109struct SessionState {
110    transport: Arc<Session>,
111    signal_id: String,
112    role: Role,
113    lines: mpsc::UnboundedSender<String>,
114    /// Set after a delta frame was dropped for this session; every following
115    /// delta is useless to its decoder until a keyframe arrives.
116    skip_until_key: std::sync::atomic::AtomicBool,
117    /// Epoch-relative millisecond deadline after which delta frames resume
118    /// even without a keyframe. 0 while not waiting. See `FrameQueue::resume_due`.
119    skip_deadline_ms: std::sync::atomic::AtomicU64,
120    /// What this pilot last reported, paired with our own counters, for ABR.
121    pilot: Mutex<PilotSample>,
122    /// When the last keyframe was queued to this session and its size on the
123    /// wire, so admission control can tell that keyframe draining from a
124    /// congested link (`keyframe_allowance`).
125    last_keyframe: Mutex<Option<(Instant, usize)>>,
126    /// Since when this session's backlog has been continuously over the
127    /// threshold; `None` while it is under. See `backlog_sustained`.
128    backlog_since: Mutex<Option<Instant>>,
129}
130
131#[derive(Default)]
132struct PilotSample {
133    /// (arrival time, agent `chunks_sent`, pilot `chunks_rx`) as the pilot read
134    /// them together; loss is measured across a sliding window of them (≥ 3 s),
135    /// so a keyframe burst in flight at one boundary cannot read as 10 % loss.
136    pairs: VecDeque<(Instant, u64, u64)>,
137    loss_pct: Option<f64>,
138    /// The pilot's own `chunks_missing`-based estimate, for cross-checking.
139    loss_pilot_pct: Option<f64>,
140    last_incomplete: Option<(u64, u64)>,
141    /// Genuinely incomplete frames (timed-out ones excluded) since last tick.
142    incomplete_delta: u64,
143    /// Lifetime QUIC lost packets at the last ABR tick (for the delta).
144    prev_lost_packets: u64,
145}
146
147struct AbrView {
148    bitrate_kbps: u32,
149    fec: (u32, u32),
150    reason: &'static str,
151    loss_pct: Option<f64>,
152    loss_pilot_pct: Option<f64>,
153}
154
155/// ABR state shared between the 1 Hz loop, the sender and the stats task.
156struct AbrState {
157    controllers: HashMap<u8, AbrController>,
158    /// FEC rates in force (delta %, key %) — the profile's until ABR moves them.
159    fec: (u32, u32),
160    bitrate_kbps: u32,
161    reason: &'static str,
162    prev_backlog: u64,
163    loss_pct: Option<f64>,
164    loss_pilot_pct: Option<f64>,
165    last_keyframe_sent: Option<Instant>,
166}
167
168struct Pending {
169    role: Role,
170    since: Instant,
171}
172
173/// Which simulcast layer a video channel is relaying (ADR 0008).
174struct ChannelLayers {
175    selector: LayerSelector,
176    /// The layer whose frames reach the queue.
177    active: u8,
178    /// Set when the selector moved; the switch completes on this layer's next
179    /// keyframe, so the pilot's decoder never meets a mid-GOP change of SPS.
180    pending: Option<u8>,
181}
182
183/// Chunk loss across one window of pilot-paired counters, or `None` when the
184/// window is too short or too small to divide meaningfully. Both deltas must
185/// come from counters the pilot read at the same instant (see
186/// `note_pilot_stats`); differencing counters sampled at different moments
187/// measures rate variation, not loss.
188///
189/// `d_rx` can exceed `d_sent` by the chunks in flight when the pilot sampled,
190/// which is why the result is clamped rather than allowed to go negative.
191fn windowed_loss_pct(span: Duration, d_sent: u64, d_rx: u64) -> Option<f64> {
192    if span < Duration::from_secs(3) || d_sent < 50 {
193        return None;
194    }
195    Some(((1.0 - d_rx as f64 / d_sent as f64) * 100.0).clamp(0.0, 100.0))
196}
197
198/// Bounded, in-order frame queue between the input and the sender.
199///
200/// The prototype used a single slot ("latest frame wins"). That is wrong for
201/// H.264: a skipped delta frame breaks the reference chain and every later
202/// frame decodes against the wrong picture until the next IDR — measured as
203/// 10 % of frames skipped on the demo camera (bursty RTSP delivery), i.e. a
204/// visible warp/"replay" every few hundred milliseconds. Frames are therefore
205/// sent in order; when the queue is full the *new* delta is dropped and every
206/// following delta too, until a keyframe (which clears the queue — an IDR
207/// makes the queued deltas that lead nowhere irrelevant — see
208/// `keyframe_flushes_queue`) and a recovery request goes out.
209struct FrameQueue {
210    frames: VecDeque<(u8, VideoFrame)>,
211    skip_until_key: bool,
212    /// When to resume sending deltas even though no keyframe arrived. Set
213    /// when recovery was asked for in a form that never produces one.
214    resume_at: Option<Instant>,
215}
216
217const MAX_QUEUED_FRAMES: usize = 6;
218
219/// Queue depth from which an arriving keyframe discards what is queued. Half
220/// capacity: shallow enough that the common case keeps its frames, deep enough
221/// that a keyframe still leaves the sender room to recover.
222const KEYFRAME_FLUSH_FRAMES: usize = MAX_QUEUED_FRAMES / 2;
223
224/// Whether an arriving keyframe should discard the frames already queued.
225///
226/// An IDR makes queued deltas unnecessary for *decoding*, which is not the same
227/// as worthless. When the queue is contiguous with the keyframe — the ordinary
228/// case, the sender simply being a few frames behind — those deltas are the
229/// motion between the last frame sent and this recovery point. Dropping them
230/// hands the pilot a jump of exactly the kind ADR 0005 exists to remove, and
231/// buys almost nothing: the queue drains at link speed, not capture rate, and
232/// they are small deltas (~2.3 KB on the wire against a keyframe's 14.6 KB).
233///
234/// They are genuinely stale in two cases. With `skip_until_key` set the chain
235/// is already broken, so a gap sits between them and the keyframe either way
236/// and sending them only delays the recovery point the pilot is waiting for.
237/// And from `KEYFRAME_FLUSH_FRAMES` up the sender is far enough behind that
238/// draining the queue matters more than the motion in it.
239impl FrameQueue {
240    /// Whether the wait for a recovery point has run out.
241    ///
242    /// A publisher answering with intra-refresh or an LTR reference never
243    /// sends a keyframe, so a latch that only a keyframe can clear would hold
244    /// the stream silent for the rest of the session. This is the release
245    /// valve, and the grace period is the QoS profile's (`recovery_grace_ms`).
246    fn resume_due(&self) -> bool {
247        self.resume_at.is_some_and(|t| Instant::now() >= t)
248    }
249}
250
251fn keyframe_flushes_queue(queued: usize, skip_until_key: bool) -> bool {
252    skip_until_key || queued >= KEYFRAME_FLUSH_FRAMES
253}
254
255/// Whether the transport backlog for one session has exceeded the profile's
256/// byte budget, so the next delta frame should be dropped rather than queued.
257///
258/// `queued` is what sits in quinn's datagram buffer, not yet packetised
259/// (`Session::send_buffer_queued`). The threshold is
260/// `Profile::drop_threshold_bytes`: a few frames' worth at the ceiling rate.
261///
262/// The check used to be `space < threshold` against the buffer's *free* space,
263/// which with a 750 KB buffer meant nothing was dropped until ~725 KB — about
264/// two seconds of video — had queued up. That hid the latency inside the
265/// stack and silenced the ABR's primary congestion signal, which is this drop.
266fn backlog_exceeds(queued: usize, threshold: usize, keyframe_allowance: usize) -> bool {
267    queued.saturating_sub(keyframe_allowance) > threshold
268}
269
270/// How long the backlog must stay over the threshold before a delta is
271/// dropped for it.
272///
273/// This exists because of BBR's ProbeRTT. Every 10 s quinn's controller
274/// shrinks the congestion window to 0.75 × its bandwidth-delay estimate for
275/// 200 ms (`quinn-proto` `congestion/bbr`, `PROBE_RTT_BASED_ON_BDP`), so the
276/// link is throttled to three quarters of the video rate for a fifth of a
277/// second and the queue crosses a two-frame threshold every time — on a
278/// loopback RTT it does not matter, at 46 ms RTT it did (measured 2026-09-08
279/// through a 4.5 Mbps shaped link: 21–28 delta frames dropped once every 10 s,
280/// each followed by a 700–900 ms freeze while the sender waited for a
281/// keyframe). A backlog that persists longer than the probe is congestion; one
282/// that does not is the controller measuring the path, and dropping for it
283/// costs the operator a freeze to save 200 ms of queue that drains by itself.
284///
285/// Not a QoS-profile knob: it tracks the congestion controller's behaviour,
286/// not a latency/quality preference, and is the same for every profile.
287const BACKLOG_SUSTAIN: Duration = Duration::from_millis(300);
288
289/// Whether a backlog reading should drop the frame: `over` must have held
290/// continuously for `sustain`. `since` is the session's record of when the
291/// backlog was first seen over the threshold, cleared whenever it is under.
292fn backlog_sustained(
293    over: bool,
294    since: &mut Option<Instant>,
295    now: Instant,
296    sustain: Duration,
297) -> bool {
298    if !over {
299        *since = None;
300        return false;
301    }
302    let start = *since.get_or_insert(now);
303    now.duration_since(start) >= sustain
304}
305
306/// How much of the queue a recently sent keyframe accounts for.
307///
308/// A keyframe is several times a delta and is queued in one go, so for the
309/// time it takes to drain at the requested bitrate the queue is legitimately
310/// deeper than the threshold. Counting that as backlog would drop the delta
311/// after every keyframe and ask the publisher for *another* keyframe — an IDR
312/// storm on a link that is merely full, not congested. While the keyframe is
313/// still plausibly draining (twice its serialisation time at the current
314/// bitrate request, to allow for pacing), its size is discounted; after that a
315/// deep queue is real congestion and the drop is right.
316fn keyframe_allowance(last_keyframe: Option<(Duration, usize)>, bitrate_kbps: u32) -> usize {
317    let Some((since, wire_bytes)) = last_keyframe else {
318        return 0;
319    };
320    let drain_ms = wire_bytes as u64 * 8 / u64::from(bitrate_kbps.max(1));
321    if since <= Duration::from_millis(drain_ms * 2) {
322        wire_bytes
323    } else {
324        0
325    }
326}
327
328/// What the publisher is asked to produce to make the stream decodable again,
329/// cheapest first.
330///
331/// Seyd states intent and the robot-side publisher decides how to meet it: a
332/// publisher that cannot honour `Ltr` is free to send an IDR instead, and one
333/// that ignores `kind` entirely behaves exactly as before. Asking for the
334/// cheapest rung matters because an IDR is a bandwidth spike — measured at
335/// 30-60 KB against a ~2 KB delta — and on a ~2 Mbps uplink that spike is
336/// 120-240 ms of serialisation, which is what field run A recorded as p95.
337#[derive(Clone, Copy, PartialEq, Eq, Debug)]
338pub enum Recovery {
339    /// Re-encode one frame against a long-term reference the pilot still has.
340    /// Cheapest: a single ordinary-sized frame restores decodability.
341    Ltr,
342    /// Refresh the picture progressively across many frames. No single frame
343    /// is large, so the bitrate stays flat, but decodability returns gradually.
344    IntraRefresh,
345    /// A full keyframe. The only rung that lets a decoder start from nothing,
346    /// and the only one a pilot's `request-keyframe` can be answered with.
347    Idr,
348}
349
350impl Recovery {
351    fn as_str(self) -> &'static str {
352        match self {
353            Recovery::Ltr => "ltr",
354            Recovery::IntraRefresh => "intra_refresh",
355            Recovery::Idr => "idr",
356        }
357    }
358
359    /// The next rung up, for when the previous request did not restore the
360    /// stream. Saturates at `Idr`.
361    fn escalate(self) -> Self {
362        match self {
363            Recovery::Ltr => Recovery::IntraRefresh,
364            Recovery::IntraRefresh | Recovery::Idr => Recovery::Idr,
365        }
366    }
367
368    /// Whether answering this request must produce a keyframe. Only `Idr`
369    /// does; the others recover without one, which is why a sender waiting
370    /// for a keyframe has to time out rather than wait forever.
371    fn yields_keyframe(self) -> bool {
372        matches!(self, Recovery::Idr)
373    }
374}
375
376/// Quiet for this long and the ladder resets to its cheapest rung: an
377/// isolated loss should not permanently escalate a link.
378const RECOVERY_ESCALATE_WINDOW: Duration = Duration::from_millis(2_000);
379
380#[derive(Clone, Copy)]
381struct RecoveryState {
382    /// When recovery was last asked for, for the rate limit and the reset.
383    last: Instant,
384    rung: Recovery,
385    /// When the ladder arrived at this rung. A rung is given the profile's
386    /// `recovery_grace_ms` to take effect before the ladder climbs — losses
387    /// arrive faster than any publisher can answer, and escalating on arrival
388    /// rate rather than on elapsed time reaches IDR within two requests and
389    /// makes the cheaper rungs unreachable.
390    rung_since: Instant,
391}
392
393struct Inner {
394    cfg: EngineConfig,
395    profile: RwLock<&'static Profile>,
396    sessions: Mutex<HashMap<u64, Arc<SessionState>>>,
397    pending: Mutex<HashMap<String, Pending>>,
398    frame_ids: HashMap<u8, AtomicU16>,
399    seqs: HashMap<u8, AtomicU16>,
400    counters: Counters,
401    events: mpsc::Sender<Event>,
402    /// Per channel: the state of the recovery ladder.
403    last_recovery: Mutex<HashMap<u8, RecoveryState>>,
404    queue: Mutex<FrameQueue>,
405    slot_notify: Notify,
406    epoch: Instant,
407    abr: Mutex<AbrState>,
408    /// Video channels that declared more than one layer. Absent for every
409    /// single-stream channel, which therefore pays nothing for this.
410    simulcast: Mutex<HashMap<u8, ChannelLayers>>,
411}
412
413impl Inner {
414    /// What the publisher of `channel` declared it can encode at most, in
415    /// kbps; 0 = no limit. Channel 0 asks for the tightest limit across all
416    /// video channels, for the agent-wide figures.
417    fn publisher_max_kbps(&self, channel: u8) -> u32 {
418        self.cfg
419            .channels
420            .iter()
421            .filter(|c| c.kind == ChannelKind::Video && (channel == 0 || c.id == channel))
422            .map(|c| c.max_bitrate_kbps)
423            .filter(|&k| k > 0)
424            .min()
425            .unwrap_or(0)
426    }
427
428    fn ceiling_kbps(&self, profile: &Profile, channel: u8) -> u32 {
429        seyd_qos::abr::effective_ceiling_kbps(profile, self.publisher_max_kbps(channel))
430    }
431
432    /// The profile's `video-config`, never asking for more than the channel's
433    /// publisher can produce.
434    fn publisher_config(&self, profile: &Profile, channel: u8, reason: &str) -> serde_json::Value {
435        let mut cfg = profile.publisher_config(channel, reason);
436        cfg["maxBitrateKbps"] = serde_json::json!(self.ceiling_kbps(profile, channel));
437        cfg
438    }
439}
440
441#[derive(Clone)]
442pub struct Engine {
443    inner: Arc<Inner>,
444}
445
446impl Engine {
447    pub fn new(cfg: EngineConfig) -> (Engine, mpsc::Receiver<Event>) {
448        let (events, rx) = mpsc::channel(256);
449        let frame_ids = cfg
450            .channels
451            .iter()
452            .map(|c| (c.id, AtomicU16::new(0)))
453            .collect();
454        let seqs = cfg
455            .channels
456            .iter()
457            .map(|c| (c.id, AtomicU16::new(0)))
458            .collect();
459        let profile = cfg.profile;
460        // Only channels that actually declared a ladder get an entry, so a
461        // single-stream robot pays nothing for simulcast existing.
462        let simulcast: HashMap<u8, ChannelLayers> = cfg
463            .channels
464            .iter()
465            .filter(|c| c.kind == ChannelKind::Video && c.layers.len() > 1)
466            .filter_map(|c| {
467                LayerSelector::new(
468                    c.layers.clone(),
469                    seyd_qos::abr::effective_ceiling_kbps(profile, c.max_bitrate_kbps),
470                )
471                .map(|selector| {
472                    let active = selector.current();
473                    (
474                        c.id,
475                        ChannelLayers {
476                            selector,
477                            active,
478                            pending: None,
479                        },
480                    )
481                })
482            })
483            .collect();
484        let initial_ceiling_kbps = seyd_qos::abr::effective_ceiling_kbps(
485            profile,
486            cfg.channels
487                .iter()
488                .filter(|c| c.kind == ChannelKind::Video)
489                .map(|c| c.max_bitrate_kbps)
490                .filter(|&k| k > 0)
491                .min()
492                .unwrap_or(0),
493        );
494        let inner = Arc::new(Inner {
495            profile: RwLock::new(cfg.profile),
496            cfg,
497            sessions: Mutex::new(HashMap::new()),
498            pending: Mutex::new(HashMap::new()),
499            frame_ids,
500            seqs,
501            counters: Counters::default(),
502            events,
503            last_recovery: Mutex::new(HashMap::new()),
504            queue: Mutex::new(FrameQueue {
505                frames: VecDeque::new(),
506                skip_until_key: false,
507                resume_at: None,
508            }),
509            slot_notify: Notify::new(),
510            epoch: Instant::now(),
511            simulcast: Mutex::new(simulcast),
512            abr: Mutex::new(AbrState {
513                controllers: HashMap::new(),
514                fec: (profile.fec_delta_pct, profile.fec_key_pct),
515                bitrate_kbps: initial_ceiling_kbps,
516                reason: "steady",
517                prev_backlog: 0,
518                loss_pct: None,
519                loss_pilot_pct: None,
520                last_keyframe_sent: None,
521            }),
522        });
523        let engine = Engine { inner };
524        tokio::spawn(engine.clone().frame_sender());
525        tokio::spawn(engine.clone().abr_loop());
526        (engine, rx)
527    }
528
529    pub fn channels(&self) -> &[ChannelSpec] {
530        &self.inner.cfg.channels
531    }
532    pub fn channel_by_name(&self, name: &str) -> Option<&ChannelSpec> {
533        self.inner.cfg.channels.iter().find(|c| c.name == name)
534    }
535    pub fn counters(&self) -> &Counters {
536        &self.inner.counters
537    }
538    pub fn profile(&self) -> &'static Profile {
539        *self.inner.profile.read().unwrap()
540    }
541    /// The bitrate ceiling in force for a video channel: the profile's,
542    /// lowered to the publisher's declared maximum (`ChannelSpec::max_bitrate_kbps`).
543    pub fn ceiling_kbps(&self, channel: u8) -> u32 {
544        self.inner.ceiling_kbps(self.profile(), channel)
545    }
546    /// The `video-config` a publisher should receive for `channel` under the
547    /// current profile, with the bitrate clamped to the channel's ceiling.
548    pub fn publisher_config(&self, channel: u8, reason: &str) -> serde_json::Value {
549        self.inner.publisher_config(self.profile(), channel, reason)
550    }
551    pub fn now_us(&self) -> u64 {
552        self.inner.epoch.elapsed().as_micros() as u64
553    }
554    pub fn session_count(&self) -> usize {
555        self.inner.sessions.lock().unwrap().len()
556    }
557    pub fn signal_session_ids(&self) -> Vec<String> {
558        self.inner
559            .sessions
560            .lock()
561            .unwrap()
562            .values()
563            .map(|s| s.signal_id.clone())
564            .collect()
565    }
566
567    /// The cloud told us a pilot is coming (`pilot-connecting`). Remembered
568    /// for 30 s so its `hello` can be matched to a role.
569    pub fn expect_pilot(&self, signal_id: &str, role: Role) {
570        let mut p = self.inner.pending.lock().unwrap();
571        p.retain(|_, v| v.since.elapsed() < Duration::from_secs(30));
572        p.insert(
573            signal_id.to_string(),
574            Pending {
575                role,
576                since: Instant::now(),
577            },
578        );
579    }
580
581    /// The cloud revoked a session (pilot left / abort / revoke).
582    pub fn revoke(&self, signal_id: &str, reason: &str) {
583        let victim = self
584            .inner
585            .sessions
586            .lock()
587            .unwrap()
588            .values()
589            .find(|s| s.signal_id == signal_id)
590            .cloned();
591        if let Some(s) = victim {
592            s.transport.close(0, reason);
593        }
594    }
595
596    /// Change the QoS ceiling live (also reachable by pilots via `set-qos`).
597    pub fn set_profile(&self, profile: &'static Profile, reason: &str) {
598        *self.inner.profile.write().unwrap() = profile;
599        {
600            // A new ceiling resets the controller; the profile's own rates
601            // apply until the loop measures otherwise.
602            let mut abr = self.inner.abr.lock().unwrap();
603            abr.controllers.clear();
604            abr.fec = (profile.fec_delta_pct, profile.fec_key_pct);
605            abr.bitrate_kbps = self.inner.ceiling_kbps(profile, 0);
606            abr.reason = "steady";
607        }
608        for c in self
609            .inner
610            .cfg
611            .channels
612            .iter()
613            .filter(|c| c.kind == ChannelKind::Video)
614        {
615            let _ = self.inner.events.try_send(Event::RequestedConfig(
616                self.inner.publisher_config(profile, c.id, reason),
617            ));
618        }
619    }
620
621    /// Offer a new frame. In-order bounded queue (see `FrameQueue`): a keyframe
622    /// flushes deltas that lead nowhere (`keyframe_flushes_queue`); an
623    /// overflowing delta is dropped together with every delta after it until
624    /// the next keyframe, and a recovery request is raised so that keyframe
625    /// arrives in ~1 RTT.
626    pub fn push_video(&self, channel: u8, frame: VideoFrame) {
627        self.push_video_layer(channel, 0, frame)
628    }
629
630    /// As `push_video`, for a channel that publishes several simulcast layers
631    /// (ADR 0008). Frames of every declared layer are offered; the engine
632    /// relays one and drops the rest, so the alternative is always encoded and
633    /// one keyframe away. A channel that declared no ladder ignores `layer`.
634    pub fn push_video_layer(&self, channel: u8, layer: u8, frame: VideoFrame) {
635        {
636            let mut sim = self.inner.simulcast.lock().unwrap();
637            if let Some(st) = sim.get_mut(&channel) {
638                // The switch lands here rather than at selection time: a
639                // keyframe is the only point where a decoder can start on the
640                // new layer's parameter sets.
641                if st.pending == Some(layer) && frame.keyframe {
642                    st.active = layer;
643                    st.pending = None;
644                }
645                if st.active != layer {
646                    self.inner
647                        .counters
648                        .frames_dropped_inactive_layer
649                        .fetch_add(1, Ordering::Relaxed);
650                    return;
651                }
652            }
653        }
654        self.inner
655            .counters
656            .frames_in
657            .fetch_add(1, Ordering::Relaxed);
658        let mut q = self.inner.queue.lock().unwrap();
659        if frame.keyframe {
660            if keyframe_flushes_queue(q.frames.len(), q.skip_until_key) {
661                let flushed = q.frames.len();
662                q.frames.clear();
663                if flushed > 0 {
664                    self.inner
665                        .counters
666                        .frames_skipped_stale
667                        .fetch_add(flushed as u64, Ordering::Relaxed);
668                }
669            }
670            q.skip_until_key = false;
671            q.resume_at = None;
672        } else if (q.skip_until_key && !q.resume_due()) || q.frames.len() >= MAX_QUEUED_FRAMES {
673            let first = !q.skip_until_key;
674            q.skip_until_key = true;
675            self.inner
676                .counters
677                .frames_skipped_stale
678                .fetch_add(1, Ordering::Relaxed);
679            drop(q);
680            if first {
681                let asked = self.request_recovery(channel, "sender-backlog");
682                self.arm_resume(asked);
683            }
684            return;
685        }
686        q.frames.push_back((channel, frame));
687        drop(q);
688        self.inner.slot_notify.notify_one();
689    }
690
691    /// Send one sensor message to every session.
692    pub fn push_message(&self, channel: u8, payload: &[u8]) {
693        let seq = match self.inner.seqs.get(&channel) {
694            Some(s) => s.fetch_add(1, Ordering::Relaxed),
695            None => return,
696        };
697        let dg = packer::pack_message(channel, seq, self.now_us() as u32, payload);
698        // Counted once (not per session): the pilot pairs its own datagram
699        // count with `chunks_sent` to measure true loss, and it receives
700        // sensor datagrams on the same socket as video chunks.
701        self.inner
702            .counters
703            .chunks_sent
704            .fetch_add(1, Ordering::Relaxed);
705        self.inner
706            .counters
707            .bytes_sent
708            .fetch_add(dg.len() as u64, Ordering::Relaxed);
709        for s in self.inner.sessions.lock().unwrap().values() {
710            let _ = s.transport.send_datagram(dg.clone());
711        }
712    }
713
714    /// Accept sessions from the endpoint forever.
715    pub async fn accept_loop(self, endpoint: Arc<Endpoint>) {
716        while let Some(session) = endpoint.accept().await {
717            tokio::spawn(self.clone().run_session(Arc::new(session)));
718        }
719    }
720
721    /// Serve a session that did not come from the endpoint — a relayed one
722    /// (ADR 0010). It is treated exactly like an accepted WebTransport session;
723    /// only its path label differs.
724    pub fn serve(&self, session: Session) {
725        tokio::spawn(self.clone().run_session(Arc::new(session)));
726    }
727
728    async fn frame_sender(self) {
729        loop {
730            let next = self.inner.queue.lock().unwrap().frames.pop_front();
731            match next {
732                Some((channel, frame)) => self.send_frame(channel, frame).await,
733                None => self.inner.slot_notify.notified().await,
734            }
735        }
736    }
737
738    async fn send_frame(&self, channel: u8, frame: VideoFrame) {
739        let sessions: Vec<Arc<SessionState>> = self
740            .inner
741            .sessions
742            .lock()
743            .unwrap()
744            .values()
745            .cloned()
746            .collect();
747        let frame_id = match self.inner.frame_ids.get(&channel) {
748            Some(f) => f.fetch_add(1, Ordering::Relaxed),
749            None => return,
750        };
751        if sessions.is_empty() {
752            return;
753        }
754        let profile = self.profile();
755        // FEC rates in force: the profile's, or what ABR moved them to; and
756        // the bitrate currently asked of the publisher, for the keyframe
757        // allowance in admission control.
758        let ((fec_delta, fec_key), bitrate_kbps) = {
759            let abr = self.inner.abr.lock().unwrap();
760            (abr.fec, abr.bitrate_kbps)
761        };
762        let fec_pct = if frame.keyframe { fec_key } else { fec_delta };
763        let fps = self
764            .inner
765            .cfg
766            .channels
767            .iter()
768            .find(|c| c.id == channel)
769            .map(|c| c.fps)
770            .filter(|f| *f > 0)
771            .unwrap_or(30);
772        let pack = packer::pack_video(
773            FrameParams {
774                channel_id: channel,
775                frame_id,
776                keyframe: frame.keyframe,
777                fec_pct,
778                chunk_len: self.inner.cfg.chunk_len,
779                send_ts: self.now_us() as u32,
780                meta: Some(FrameMeta {
781                    capture_ts_us: frame.capture_ts_us,
782                    seq_in_gop: 0,
783                }),
784            },
785            &frame.data,
786        );
787        let wire_bytes: usize = pack.chunks.iter().map(|c| c.len()).sum();
788        let threshold =
789            profile.drop_threshold_bytes_at(self.inner.ceiling_kbps(profile, channel), fps);
790        let mut sent_any = false;
791        tracing::trace!(
792            frame_id,
793            keyframe = frame.keyframe,
794            bytes = frame.data.len(),
795            wire_bytes,
796            chunks = pack.chunks.len(),
797            "frame"
798        );
799
800        for s in &sessions {
801            // Admission control before the first chunk leaves: whole frame or
802            // nothing. A delta frame that would not fit the remaining send
803            // buffer — or arrives while the bytes already queued exceed the
804            // profile's backlog budget — is skipped cleanly. Keyframes always go.
805            let space = s.transport.send_buffer_space();
806            let queued = s.transport.send_buffer_queued();
807            if frame.keyframe {
808                s.skip_until_key.store(false, Ordering::Relaxed);
809                s.skip_deadline_ms.store(0, Ordering::Relaxed);
810            } else if s.skip_until_key.load(Ordering::Relaxed)
811                && self.now_ms() < s.skip_deadline_ms.load(Ordering::Relaxed)
812            {
813                self.inner
814                    .counters
815                    .frames_dropped_backlog
816                    .fetch_add(1, Ordering::Relaxed);
817                continue;
818            } else {
819                let allowance = keyframe_allowance(
820                    s.last_keyframe
821                        .lock()
822                        .unwrap()
823                        .map(|(at, bytes)| (at.elapsed(), bytes)),
824                    bitrate_kbps,
825                );
826                let over = backlog_exceeds(queued, threshold, allowance);
827                let sustained = backlog_sustained(
828                    over,
829                    &mut s.backlog_since.lock().unwrap(),
830                    Instant::now(),
831                    BACKLOG_SUSTAIN,
832                );
833                if wire_bytes > space || sustained {
834                    self.inner
835                        .counters
836                        .frames_dropped_backlog
837                        .fetch_add(1, Ordering::Relaxed);
838                    s.skip_until_key.store(true, Ordering::Relaxed);
839                    s.skip_deadline_ms.store(
840                        self.now_ms() + self.profile().recovery_grace_ms as u64,
841                        Ordering::Relaxed,
842                    );
843                    tracing::debug!(
844                        frame_id,
845                        wire_bytes,
846                        queued,
847                        allowance,
848                        threshold,
849                        "delta frame dropped: backlog; skipping until keyframe"
850                    );
851                    let asked = self.request_recovery(channel, "backlog");
852                    self.arm_resume(asked);
853                    continue;
854                }
855            }
856            let mut ok = true;
857            for c in &pack.chunks {
858                let mut tries = 0;
859                loop {
860                    match s.transport.send_datagram(c.clone()) {
861                        Ok(()) => break,
862                        Err(SendError::Blocked) if frame.keyframe && tries < 50 => {
863                            tries += 1;
864                            tokio::time::sleep(Duration::from_millis(1)).await;
865                        }
866                        Err(_) => {
867                            ok = false;
868                            break;
869                        }
870                    }
871                }
872                if !ok {
873                    break;
874                }
875            }
876            if ok {
877                sent_any = true;
878                if frame.keyframe {
879                    *s.last_keyframe.lock().unwrap() = Some((Instant::now(), wire_bytes));
880                }
881            }
882        }
883        if sent_any {
884            let c = &self.inner.counters;
885            c.frames_sent.fetch_add(1, Ordering::Relaxed);
886            c.chunks_sent
887                .fetch_add(pack.chunks.len() as u64, Ordering::Relaxed);
888            c.parity_sent
889                .fetch_add(pack.parity_chunks as u64, Ordering::Relaxed);
890            c.bytes_sent.fetch_add(wire_bytes as u64, Ordering::Relaxed);
891            if frame.keyframe {
892                self.inner.abr.lock().unwrap().last_keyframe_sent = Some(Instant::now());
893            }
894        }
895    }
896
897    async fn run_session(self, transport: Arc<Session>) {
898        let tid = transport.session_id();
899        let remote = transport
900            .remote_addr()
901            .map(|a| a.to_string())
902            .unwrap_or_else(|| "relay".to_string());
903        let Ok(Some((mut reader, mut writer))) =
904            tokio::time::timeout(Duration::from_secs(5), transport.control()).await
905        else {
906            tracing::warn!(%remote, "session opened no control stream within 5 s");
907            transport.close(1, "no control stream");
908            return;
909        };
910        // The pilot speaks first.
911        let Some(first) = seyd_transport::control::read_line(&mut reader).await else {
912            transport.close(1, "control stream closed");
913            return;
914        };
915        let (signal_id, _client) = match serde_json::from_str::<FromPilot>(&first) {
916            Ok(FromPilot::Hello {
917                proto: 2,
918                session_id,
919                client,
920                ..
921            }) => (session_id, client),
922            Ok(FromPilot::Hello { proto, .. }) => {
923                let _ = seyd_transport::control::write_line(
924                    &mut writer,
925                    &encode_line(&ToPilot::Denied {
926                        reason: "unsupported proto",
927                    }),
928                )
929                .await;
930                transport.close(1, &format!("proto {proto}"));
931                return;
932            }
933            _ => {
934                transport.close(1, "expected hello");
935                return;
936            }
937        };
938        let role = self
939            .inner
940            .pending
941            .lock()
942            .unwrap()
943            .remove(&signal_id)
944            .map(|p| p.role)
945            .unwrap_or(Role::Observer);
946        if self.session_count() >= self.inner.cfg.max_sessions {
947            let _ = seyd_transport::control::write_line(
948                &mut writer,
949                &encode_line(&ToPilot::Denied {
950                    reason: "robot-busy",
951                }),
952            )
953            .await;
954            transport.close(1, "robot-busy");
955            return;
956        }
957
958        let (lines_tx, mut lines_rx) = mpsc::unbounded_channel::<String>();
959        let state = Arc::new(SessionState {
960            transport: transport.clone(),
961            signal_id: signal_id.clone(),
962            role,
963            lines: lines_tx,
964            skip_until_key: std::sync::atomic::AtomicBool::new(false),
965            skip_deadline_ms: std::sync::atomic::AtomicU64::new(0),
966            pilot: Mutex::new(PilotSample::default()),
967            last_keyframe: Mutex::new(None),
968            backlog_since: Mutex::new(None),
969        });
970        self.inner
971            .sessions
972            .lock()
973            .unwrap()
974            .insert(tid, state.clone());
975        // What the pilot is told it is on. A relayed session is never
976        // dressed up as a direct one: the label reaches the HUD (ADR 0010).
977        let path_label = match transport.kind() {
978            seyd_transport::SessionKind::Relay => "relay",
979            seyd_transport::SessionKind::Direct => match transport.remote_addr() {
980                Some(a) if a.ip().is_ipv6() => "host6",
981                _ => "host",
982            },
983        }
984        .to_string();
985        let _ = state.lines.send(encode_line(&ToPilot::Welcome {
986            session_id: &signal_id,
987            role: role.as_str(),
988            channels: &self.inner.cfg.channels,
989            qos: self.profile().pilot_config(),
990            t_agent_us: self.now_us(),
991        }));
992        let _ = self
993            .inner
994            .events
995            .send(Event::SessionStarted {
996                signal_id: signal_id.clone(),
997                role,
998                path_label: path_label.clone(),
999            })
1000            .await;
1001        tracing::info!(session = %signal_id, %remote, role = role.as_str(), "session started");
1002
1003        // Writer task: serialises every line to the control stream.
1004        let writer_task = tokio::spawn(async move {
1005            while let Some(line) = lines_rx.recv().await {
1006                if seyd_transport::control::write_line(&mut writer, &line)
1007                    .await
1008                    .is_err()
1009                {
1010                    break;
1011                }
1012            }
1013        });
1014
1015        // Datagram task: command channels from the driver.
1016        let engine = self.clone();
1017        let st = state.clone();
1018        let dg_task = tokio::spawn(async move {
1019            let Some(mut rx) = st.transport.take_datagrams() else {
1020                return;
1021            };
1022            while let Some(dg) = rx.recv().await {
1023                let Some((h, payload)) = ChunkHeader::parse(&dg) else {
1024                    continue;
1025                };
1026                let Some(ch) = engine
1027                    .inner
1028                    .cfg
1029                    .channels
1030                    .iter()
1031                    .find(|c| c.id == h.channel_id)
1032                else {
1033                    continue;
1034                };
1035                if ch.kind != ChannelKind::Command || st.role != Role::Driver {
1036                    continue;
1037                }
1038                let _ = engine
1039                    .inner
1040                    .events
1041                    .send(Event::Command {
1042                        channel: h.channel_id,
1043                        payload: Bytes::copy_from_slice(payload),
1044                    })
1045                    .await;
1046            }
1047        });
1048
1049        // Stats task: 1 Hz agent-stats.
1050        let engine = self.clone();
1051        let st = state.clone();
1052        let stats_task = tokio::spawn(async move {
1053            let mut tick = tokio::time::interval(Duration::from_secs(1));
1054            loop {
1055                tick.tick().await;
1056                let c = &engine.inner.counters;
1057                let p = st.transport.stats();
1058                let abr = {
1059                    let a = engine.inner.abr.lock().unwrap();
1060                    AbrView {
1061                        bitrate_kbps: a.bitrate_kbps,
1062                        fec: a.fec,
1063                        reason: a.reason,
1064                        loss_pct: a.loss_pct,
1065                        loss_pilot_pct: a.loss_pilot_pct,
1066                    }
1067                };
1068                let v = serde_json::json!({
1069                    "frames_in": c.frames_in.load(Ordering::Relaxed),
1070                    "frames_sent": c.frames_sent.load(Ordering::Relaxed),
1071                    "frames_dropped_backlog": c.frames_dropped_backlog.load(Ordering::Relaxed),
1072                    "frames_skipped_stale": c.frames_skipped_stale.load(Ordering::Relaxed),
1073                    "keyframes_requested": c.keyframes_requested.load(Ordering::Relaxed),
1074                    "chunks_sent": c.chunks_sent.load(Ordering::Relaxed),
1075                    "parity_sent": c.parity_sent.load(Ordering::Relaxed),
1076                    "bytes_sent": c.bytes_sent.load(Ordering::Relaxed),
1077                    "rtt_ms": p.rtt_ms, "min_rtt_ms": p.min_rtt_ms, "cwnd": p.cwnd,
1078                    "delivery_kbps": p.delivery_kbps, "lost_packets": p.lost_packets, "mtu": p.current_mtu,
1079                    "abr_bitrate_kbps": abr.bitrate_kbps, "abr_ceiling_kbps": engine.ceiling_kbps(0),
1080                    "abr_fec_delta": abr.fec.0, "abr_fec_key": abr.fec.1, "abr_reason": abr.reason,
1081                    "abr_loss_pct": abr.loss_pct.map(|x| (x * 10.0).round() / 10.0),
1082                    "abr_loss_pilot_pct": abr.loss_pilot_pct.map(|x| (x * 10.0).round() / 10.0),
1083                });
1084                if st.lines.send(encode_line(&ToPilot::AgentStats(v))).is_err() {
1085                    break;
1086                }
1087            }
1088        });
1089
1090        // Control reader (this task).
1091        let mut reason = "pilot-closed".to_string();
1092        loop {
1093            tokio::select! {
1094                line = seyd_transport::control::read_line(&mut reader) => {
1095                    let Some(line) = line else { break };
1096                    match serde_json::from_str::<FromPilot>(&line) {
1097                        Ok(FromPilot::Ping { t1 }) => {
1098                            let _ = state.lines.send(encode_line(&ToPilot::Pong { t1, t2: self.now_us() }));
1099                        }
1100                        Ok(FromPilot::Loss { ch, .. }) => {
1101                            let asked = self.request_recovery(ch, "pilot-loss");
1102                            self.arm_resume(asked);
1103                        }
1104                        Ok(FromPilot::RequestKeyframe { ch }) => {
1105                            // Always answered with an IDR, so no resume timer.
1106                            let _ = self.request_recovery(ch, "pilot-request");
1107                        }
1108                        Ok(FromPilot::SetQos { profile }) => {
1109                            match seyd_qos::get(&profile) {
1110                                Some(p) => {
1111                                    self.set_profile(p, "pilot-request");
1112                                    let _ = state.lines.send(encode_line(&ToPilot::QosAck { profile: p.name, qos: p.pilot_config(), publisher: "requested" }));
1113                                }
1114                                None => tracing::debug!(%profile, "unknown qos profile"),
1115                            }
1116                        }
1117                        Ok(FromPilot::PilotStats(v)) => {
1118                            tracing::trace!(stats = %v, "pilot-stats");
1119                            self.note_pilot_stats(&state, &v);
1120                        }
1121                        Ok(FromPilot::Bye) => { reason = "bye".into(); break; }
1122                        Ok(FromPilot::Hello { .. }) => {}
1123                        Ok(FromPilot::Unknown) => {}
1124                        Err(e) => tracing::debug!(error = %e, "bad control line"),
1125                    }
1126                }
1127                _ = transport.closed() => { reason = "transport-closed".into(); break; }
1128            }
1129        }
1130        self.inner.sessions.lock().unwrap().remove(&tid);
1131        transport.close(0, &reason);
1132        writer_task.abort();
1133        dg_task.abort();
1134        stats_task.abort();
1135        tracing::info!(session = %signal_id, %reason, "session ended");
1136        let _ = self
1137            .inner
1138            .events
1139            .send(Event::SessionEnded { signal_id, reason })
1140            .await;
1141    }
1142
1143    /// Pair the pilot's counters with ours: `chunks_sent` vs `chunks_rx`
1144    /// between two reports is the true chunk loss over that window (a frame
1145    /// whose chunks were *all* lost is invisible to the pilot alone).
1146    ///
1147    /// Both halves of the pair are read by the *pilot*, at the instant our
1148    /// `agent-stats` reached it (`chunks_sent_seen` / `chunks_rx_seen`).
1149    /// Aligning them on one clock is the whole point. This used to difference
1150    /// our own send log as of (now − rtt − 100 ms) against a `chunks_rx` taken
1151    /// at a different moment, so the two ends of the ratio covered windows
1152    /// offset by ~100 ms. Whenever the chunk rate varied across that offset —
1153    /// a keyframe, a pan changing frame sizes — the mismatch surfaced as loss,
1154    /// and because the result was clamped at zero the negative excursions were
1155    /// discarded while the positive ones survived. That rectified ordinary
1156    /// rate variation into a steady 0.25–2.7 % phantom loss, comfortably above
1157    /// the controller's 0.2 % FEC threshold, and the bitrate flapped
1158    /// 3000↔2760↔2500 kbps against a link that was losing nothing at all.
1159    /// Chunks in flight at the sampling instant still bias both endpoints, but
1160    /// equally, so they cancel in the difference.
1161    fn note_pilot_stats(&self, st: &SessionState, v: &serde_json::Value) {
1162        let get = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
1163        let opt = |k: &str| v.get(k).and_then(|x| x.as_u64());
1164        let rx = get("chunks_rx");
1165        let missing = get("chunks_missing");
1166        let incomplete = get("frames_incomplete");
1167        // Timed-out frames whose chunks then arrived late are jitter, not loss.
1168        let timed_out = get("frames_timed_out_late");
1169        let now = Instant::now();
1170        // A pilot too old to send the pair leaves `loss_pct` at None: the ABR
1171        // then runs on residual loss and latency alone, which is strictly
1172        // better than steering on a number we cannot compute correctly.
1173        let pair = opt("chunks_sent_seen").zip(opt("chunks_rx_seen"));
1174        let mut ps = st.pilot.lock().unwrap();
1175        if let Some((sent_seen, rx_seen)) = pair {
1176            ps.pairs.push_back((now, sent_seen, rx_seen));
1177            while ps
1178                .pairs
1179                .front()
1180                .is_some_and(|(t, _, _)| now.duration_since(*t) > Duration::from_secs(5))
1181            {
1182                ps.pairs.pop_front();
1183            }
1184            if let (Some(first), Some(last)) = (ps.pairs.front(), ps.pairs.back()) {
1185                if let Some(pct) = windowed_loss_pct(
1186                    last.0.duration_since(first.0),
1187                    last.1.saturating_sub(first.1),
1188                    last.2.saturating_sub(first.2),
1189                ) {
1190                    ps.loss_pct = Some(pct);
1191                }
1192            }
1193        }
1194        if rx + missing > 0 {
1195            ps.loss_pilot_pct = Some(missing as f64 / (rx + missing) as f64 * 100.0);
1196        }
1197        if let Some((i0, t0)) = ps.last_incomplete {
1198            let genuine = incomplete
1199                .saturating_sub(i0)
1200                .saturating_sub(timed_out.saturating_sub(t0));
1201            ps.incomplete_delta += genuine;
1202        }
1203        ps.last_incomplete = Some((incomplete, timed_out));
1204    }
1205
1206    /// 1 Hz: feed the controller the worst session's view and apply.
1207    async fn abr_loop(self) {
1208        let mut tick = tokio::time::interval(Duration::from_secs(1));
1209        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1210        loop {
1211            tick.tick().await;
1212            let sessions: Vec<Arc<SessionState>> = self
1213                .inner
1214                .sessions
1215                .lock()
1216                .unwrap()
1217                .values()
1218                .cloned()
1219                .collect();
1220            if sessions.is_empty() {
1221                continue;
1222            }
1223            let profile = self.profile();
1224            let backlog_total = self
1225                .inner
1226                .counters
1227                .frames_dropped_backlog
1228                .load(Ordering::Relaxed);
1229            let mut sample = AbrSample::default();
1230            let mut worst_inflation = f64::MIN;
1231            let mut loss_pilot = 0.0f64;
1232            for st in &sessions {
1233                let p = st.transport.stats();
1234                let mut ps = st.pilot.lock().unwrap();
1235                let lost_delta = p.lost_packets.saturating_sub(ps.prev_lost_packets);
1236                ps.prev_lost_packets = p.lost_packets;
1237                let inflation = p.rtt_ms - p.min_rtt_ms;
1238                if inflation > worst_inflation {
1239                    worst_inflation = inflation;
1240                    sample.rtt_ms = p.rtt_ms;
1241                    sample.min_rtt_ms = p.min_rtt_ms;
1242                }
1243                sample.delivery_kbps = sample.delivery_kbps.max(p.delivery_kbps);
1244                sample.send_kbps = sample.send_kbps.max(p.delivery_kbps);
1245                sample.lost_packets_delta += lost_delta;
1246                sample.pilot_frames_incomplete_delta += std::mem::take(&mut ps.incomplete_delta);
1247                if let Some(l) = ps.loss_pct {
1248                    sample.pilot_chunk_loss_pct =
1249                        Some(sample.pilot_chunk_loss_pct.map_or(l, |x: f64| x.max(l)));
1250                }
1251                loss_pilot = loss_pilot.max(ps.loss_pilot_pct.unwrap_or(0.0));
1252            }
1253            let mut abr = self.inner.abr.lock().unwrap();
1254            sample.keyframe_just_sent = abr
1255                .last_keyframe_sent
1256                .is_some_and(|t| t.elapsed() < Duration::from_millis(1200));
1257            abr.loss_pct = sample.pilot_chunk_loss_pct;
1258            abr.loss_pilot_pct = Some(loss_pilot);
1259            sample.frames_dropped_backlog_delta = backlog_total.saturating_sub(abr.prev_backlog);
1260            abr.prev_backlog = backlog_total;
1261            let video_channels: Vec<u8> = self
1262                .inner
1263                .cfg
1264                .channels
1265                .iter()
1266                .filter(|c| c.kind == ChannelKind::Video)
1267                .map(|c| c.id)
1268                .collect();
1269            for ch in video_channels.iter().copied() {
1270                let ctl = abr.controllers.entry(ch).or_insert_with(|| {
1271                    AbrController::with_ceiling(profile, self.inner.publisher_max_kbps(ch))
1272                });
1273                let Some(d) = ctl.step(&sample) else { continue };
1274                abr.fec = (d.fec_delta_pct, d.fec_key_pct);
1275                abr.reason = d.reason;
1276                if d.bitrate_changed {
1277                    let up = d.max_bitrate_kbps > abr.bitrate_kbps;
1278                    abr.bitrate_kbps = d.max_bitrate_kbps;
1279                    let mut cfg = self.inner.publisher_config(
1280                        profile,
1281                        ch,
1282                        if up { "abr-up" } else { "abr-down" },
1283                    );
1284                    cfg["maxBitrateKbps"] = serde_json::json!(d.max_bitrate_kbps);
1285                    cfg["suggestedFps"] = serde_json::json!(d.suggested_fps);
1286                    let _ = self.inner.events.try_send(Event::RequestedConfig(cfg));
1287                }
1288                tracing::info!(
1289                    channel = ch,
1290                    bitrate = d.max_bitrate_kbps,
1291                    fec_delta = d.fec_delta_pct,
1292                    fec_key = d.fec_key_pct,
1293                    reason = d.reason,
1294                    loss = ?sample.pilot_chunk_loss_pct,
1295                    loss_pilot_est = loss_pilot,
1296                    residual = sample.pilot_frames_incomplete_delta,
1297                    kf_recent = sample.keyframe_just_sent,
1298                    rtt = sample.rtt_ms,
1299                    min_rtt = sample.min_rtt_ms,
1300                    "abr"
1301                );
1302            }
1303
1304            // Which encoding those bits buy (ADR 0008). Stepped every tick and
1305            // not only when the bitrate moved, so a link that recovers while
1306            // sitting at a steady target still climbs back up the ladder.
1307            let target = abr.bitrate_kbps;
1308            let mut sim = self.inner.simulcast.lock().unwrap();
1309            for ch in video_channels {
1310                let Some(st) = sim.get_mut(&ch) else { continue };
1311                let Some(sel) = st.selector.step(target) else {
1312                    continue;
1313                };
1314                // Armed, not applied: `push_video_layer` completes the switch on
1315                // the target layer's next keyframe.
1316                st.pending = Some(sel.layer);
1317                let _ = self.inner.events.try_send(Event::LayerChanged {
1318                    channel: ch,
1319                    layer: sel.layer,
1320                    name: st.selector.current_name().to_string(),
1321                    reason: sel.reason,
1322                });
1323                tracing::info!(
1324                    channel = ch,
1325                    layer = sel.layer,
1326                    name = st.selector.current_name(),
1327                    reason = sel.reason,
1328                    target_kbps = target,
1329                    "simulcast"
1330                );
1331            }
1332        }
1333    }
1334
1335    fn now_ms(&self) -> u64 {
1336        self.inner.epoch.elapsed().as_millis() as u64
1337    }
1338
1339    /// After asking for a recovery point that will not produce a keyframe,
1340    /// set the time at which the sender gives up waiting and resumes deltas.
1341    ///
1342    /// Without this a stream recovered by intra-refresh or an LTR reference
1343    /// latches silent: `skip_until_key` is cleared only by a keyframe, and one
1344    /// never comes.
1345    fn arm_resume(&self, asked: Option<Recovery>) {
1346        let Some(kind) = asked else { return };
1347        if kind.yields_keyframe() {
1348            return;
1349        }
1350        let grace = Duration::from_millis(self.profile().recovery_grace_ms as u64);
1351        let mut q = self.inner.queue.lock().unwrap();
1352        q.resume_at = Some(Instant::now() + grace);
1353    }
1354
1355    /// Ask the publisher for a recovery point, climbing the ladder only as far
1356    /// as the situation needs.
1357    ///
1358    /// Returns the rung asked for, so the caller can decide how long to wait:
1359    /// only `Idr` promises a keyframe.
1360    fn request_recovery(&self, channel: u8, reason: &'static str) -> Option<Recovery> {
1361        // One request per 250 ms: a lost delta frame smears the picture until
1362        // a recovery point arrives, so waiting longer costs the operator more
1363        // than the keyframe costs the link. Spurious requests are avoided at
1364        // the source (adaptive close-out in the pilot), not by waiting here.
1365        let min_gap = Duration::from_millis(250);
1366        let now = Instant::now();
1367        let mut last = self.inner.last_recovery.lock().unwrap();
1368        let previous = last.get(&channel).copied();
1369        if let Some(p) = previous {
1370            if now.duration_since(p.last) < min_gap {
1371                return None;
1372            }
1373        }
1374
1375        let grace = Duration::from_millis(self.profile().recovery_grace_ms as u64);
1376        let kind = if reason == "pilot-request" {
1377            // The pilot's decoder cannot start or resync without a key frame;
1378            // no cheaper rung can answer this one.
1379            Recovery::Idr
1380        } else if !self.inner.cfg.recovery_ladder {
1381            Recovery::Idr
1382        } else {
1383            match previous {
1384                // Quiet for a while: an isolated loss, start cheap again.
1385                Some(p) if now.duration_since(p.last) >= RECOVERY_ESCALATE_WINDOW => Recovery::Ltr,
1386                // This rung has had its grace period and loss continues, so it
1387                // did not work — climb.
1388                Some(p) if now.duration_since(p.rung_since) >= grace => p.rung.escalate(),
1389                // Still inside the grace: the publisher has not had time yet.
1390                Some(p) => p.rung,
1391                None => Recovery::Ltr,
1392            }
1393        };
1394
1395        // `pilot-request` is an out-of-band demand for a key chunk, not a rung.
1396        // Recording it as one pins the ladder at IDR for every later loss,
1397        // because IDR escalates to itself — which is how the cheaper rungs
1398        // became unreachable the first time this ran against a real pilot.
1399        let ladder_rung = reason != "pilot-request";
1400        let (rung, rung_since) = match previous {
1401            _ if !ladder_rung => previous
1402                .map(|p| (p.rung, p.rung_since))
1403                .unwrap_or((Recovery::Ltr, now)),
1404            Some(p) if p.rung == kind => (kind, p.rung_since),
1405            _ => (kind, now),
1406        };
1407        last.insert(
1408            channel,
1409            RecoveryState {
1410                last: now,
1411                rung,
1412                rung_since,
1413            },
1414        );
1415        drop(last);
1416
1417        self.inner
1418            .counters
1419            .keyframes_requested
1420            .fetch_add(1, Ordering::Relaxed);
1421        let _ = self.inner.events.try_send(Event::RecoveryRequest {
1422            channel,
1423            kind: kind.as_str(),
1424            reason,
1425        });
1426        Some(kind)
1427    }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::*;
1433    use seyd_qos::BALANCED;
1434
1435    const W: Duration = Duration::from_secs(5);
1436
1437    #[test]
1438    fn clean_link_reads_zero() {
1439        assert_eq!(windowed_loss_pct(W, 1000, 1000), Some(0.0));
1440    }
1441
1442    fn near(got: Option<f64>, want: f64) {
1443        let g = got.expect("expected a measurement");
1444        assert!((g - want).abs() < 1e-9, "got {g}, want {want}");
1445    }
1446
1447    #[test]
1448    fn loss_is_the_shortfall_against_what_was_sent() {
1449        near(windowed_loss_pct(W, 1000, 950), 5.0);
1450        near(windowed_loss_pct(W, 200, 100), 50.0);
1451    }
1452
1453    #[test]
1454    fn in_flight_skew_reads_zero_not_negative() {
1455        // The pilot can have received a few chunks the agent had not yet
1456        // counted when it composed agent-stats; that is skew, not gain.
1457        assert_eq!(windowed_loss_pct(W, 1000, 1008), Some(0.0));
1458    }
1459
1460    #[test]
1461    fn a_window_too_short_or_too_small_yields_nothing() {
1462        assert_eq!(windowed_loss_pct(Duration::from_secs(2), 1000, 900), None);
1463        assert_eq!(windowed_loss_pct(W, 49, 0), None);
1464    }
1465
1466    #[test]
1467    fn a_keyframe_keeps_a_shallow_contiguous_queue() {
1468        // The ordinary case: the sender is a frame or two behind and the queued
1469        // deltas run straight into this keyframe. Discarding them would skip
1470        // that motion and show the operator a jump instead.
1471        assert!(!keyframe_flushes_queue(0, false));
1472        assert!(!keyframe_flushes_queue(1, false));
1473        assert!(!keyframe_flushes_queue(KEYFRAME_FLUSH_FRAMES - 1, false));
1474    }
1475
1476    #[test]
1477    fn a_keyframe_flushes_deltas_that_lead_nowhere() {
1478        // Chain already broken: a gap sits between these and the keyframe
1479        // whatever we do, so sending them only delays the recovery point.
1480        assert!(keyframe_flushes_queue(1, true));
1481        assert!(keyframe_flushes_queue(0, true));
1482        // Sender far enough behind that draining beats the motion.
1483        assert!(keyframe_flushes_queue(KEYFRAME_FLUSH_FRAMES, false));
1484        assert!(keyframe_flushes_queue(MAX_QUEUED_FRAMES, false));
1485    }
1486
1487    #[test]
1488    fn backlog_is_measured_on_queued_bytes_not_free_space() {
1489        // balanced at 30 fps: 2 frames = 25 000 B. Nothing queued → admit.
1490        let threshold = BALANCED.drop_threshold_bytes(30);
1491        assert!(!backlog_exceeds(0, threshold, 0));
1492        assert!(
1493            !backlog_exceeds(threshold, threshold, 0),
1494            "at the budget still admits"
1495        );
1496        // One byte over the budget drops, whatever the buffer's total size —
1497        // the old free-space check needed ~725 KB queued to reach this point.
1498        assert!(backlog_exceeds(threshold + 1, threshold, 0));
1499    }
1500
1501    #[test]
1502    fn a_backlog_shorter_than_a_probe_rtt_does_not_drop() {
1503        // BBR's ProbeRTT throttles the link for 200 ms every 10 s; the queue
1504        // it builds must not cost a frame, because a dropped delta costs a
1505        // freeze until the next keyframe.
1506        let t0 = Instant::now();
1507        let mut since = None;
1508        assert!(!backlog_sustained(true, &mut since, t0, BACKLOG_SUSTAIN));
1509        assert!(!backlog_sustained(
1510            true,
1511            &mut since,
1512            t0 + Duration::from_millis(200),
1513            BACKLOG_SUSTAIN
1514        ));
1515        // Back under: the clock resets.
1516        assert!(!backlog_sustained(
1517            false,
1518            &mut since,
1519            t0 + Duration::from_millis(250),
1520            BACKLOG_SUSTAIN
1521        ));
1522        assert!(since.is_none());
1523        // Over again and staying over: drops once the sustain has elapsed.
1524        let t1 = t0 + Duration::from_secs(1);
1525        assert!(!backlog_sustained(true, &mut since, t1, BACKLOG_SUSTAIN));
1526        assert!(backlog_sustained(
1527            true,
1528            &mut since,
1529            t1 + BACKLOG_SUSTAIN,
1530            BACKLOG_SUSTAIN
1531        ));
1532        assert!(backlog_sustained(
1533            true,
1534            &mut since,
1535            t1 + Duration::from_secs(2),
1536            BACKLOG_SUSTAIN
1537        ));
1538    }
1539
1540    #[test]
1541    fn a_draining_keyframe_is_not_backlog() {
1542        let threshold = BALANCED.drop_threshold_bytes(30);
1543        let kf = 30_000; // wire bytes of a keyframe, ~80 ms at 3 Mbps
1544                         // Just after the keyframe the queue is keyframe + a delta: legitimately
1545                         // deep, and the allowance discounts the keyframe.
1546        let a = keyframe_allowance(Some((Duration::from_millis(30), kf)), 3000);
1547        assert_eq!(a, kf);
1548        assert!(!backlog_exceeds(kf + 12_500, threshold, a));
1549        // Still over the budget *beyond* the keyframe: real backlog, drop.
1550        assert!(backlog_exceeds(kf + threshold + 1, threshold, a));
1551        // Long after its drain time the keyframe is not an excuse any more.
1552        let late = keyframe_allowance(Some((Duration::from_millis(500), kf)), 3000);
1553        assert_eq!(late, 0);
1554        assert!(backlog_exceeds(kf + 12_500, threshold, late));
1555        // No keyframe yet: no allowance.
1556        assert_eq!(keyframe_allowance(None, 3000), 0);
1557    }
1558
1559    #[test]
1560    fn a_slower_bitrate_request_gives_the_keyframe_longer_to_drain() {
1561        let kf = 60_000; // 320 ms at 1500 kbps, so the window is 640 ms
1562        assert_eq!(
1563            keyframe_allowance(Some((Duration::from_millis(600), kf)), 1500),
1564            kf
1565        );
1566        assert_eq!(
1567            keyframe_allowance(Some((Duration::from_millis(700), kf)), 1500),
1568            0
1569        );
1570    }
1571
1572    #[test]
1573    fn keeping_a_queue_still_leaves_the_sender_room() {
1574        // A kept queue plus the keyframe itself must stay within capacity, or
1575        // the very next delta would overflow and set skip_until_key.
1576        let kept = KEYFRAME_FLUSH_FRAMES - 1;
1577        assert!(kept + 1 < MAX_QUEUED_FRAMES);
1578    }
1579
1580    /// The regression this pairing exists to prevent. The link is clean — the
1581    /// pilot received every chunk — but the send rate ramps hard across the
1582    /// window (a pan enlarging frames, a keyframe at one boundary). Paired on
1583    /// one clock the deltas match exactly and loss reads 0. The old code
1584    /// differenced a send count shifted ~100 ms against an unshifted rx count,
1585    /// so a rate ramp of this shape leaked in as a percent or two of loss —
1586    /// over the controller's 0.2 % FEC threshold, flapping the bitrate.
1587    #[test]
1588    fn a_changing_send_rate_is_not_loss() {
1589        let mut sent = 0u64;
1590        let mut rx = 0u64;
1591        // 5 s of 1 Hz samples, chunk rate climbing 60 → 140 per second.
1592        let mut pairs = Vec::new();
1593        for i in 0..6u64 {
1594            pairs.push((sent, rx));
1595            let rate = 60 + i * 16;
1596            sent += rate;
1597            rx += rate; // nothing is actually lost
1598        }
1599        let (s0, r0) = pairs[0];
1600        let (s1, r1) = *pairs.last().unwrap();
1601        assert_eq!(windowed_loss_pct(W, s1 - s0, r1 - r0), Some(0.0));
1602    }
1603}
1604
1605#[cfg(test)]
1606mod recovery_tests {
1607    use super::*;
1608
1609    #[test]
1610    fn the_ladder_climbs_on_repeat_and_resets_when_quiet() {
1611        assert_eq!(Recovery::Ltr.escalate(), Recovery::IntraRefresh);
1612        assert_eq!(Recovery::IntraRefresh.escalate(), Recovery::Idr);
1613        // Saturates: there is nothing more expensive than a keyframe.
1614        assert_eq!(Recovery::Idr.escalate(), Recovery::Idr);
1615    }
1616
1617    #[test]
1618    fn only_an_idr_promises_a_keyframe() {
1619        // This is what decides whether the sender may wait for one, or must
1620        // arm a timer instead. Getting it wrong latches a stream silent.
1621        assert!(Recovery::Idr.yields_keyframe());
1622        assert!(!Recovery::Ltr.yields_keyframe());
1623        assert!(!Recovery::IntraRefresh.yields_keyframe());
1624    }
1625
1626    #[test]
1627    fn the_wire_names_match_the_documented_publisher_control_vocabulary() {
1628        // docs/protocol/seydd.md publishes these strings; a robot-side
1629        // publisher matches on them.
1630        assert_eq!(Recovery::Ltr.as_str(), "ltr");
1631        assert_eq!(Recovery::IntraRefresh.as_str(), "intra_refresh");
1632        assert_eq!(Recovery::Idr.as_str(), "idr");
1633    }
1634
1635    #[test]
1636    fn a_queue_waiting_on_a_keyframe_releases_itself_when_the_grace_expires() {
1637        // The no-IDR hazard: a publisher recovering by intra-refresh never
1638        // sends a keyframe, so a latch only a keyframe can clear would hold
1639        // the stream silent for the rest of the session.
1640        let mut q = FrameQueue {
1641            frames: VecDeque::new(),
1642            skip_until_key: true,
1643            resume_at: Some(Instant::now() - Duration::from_millis(1)),
1644        };
1645        assert!(q.resume_due(), "an expired grace must release the latch");
1646
1647        q.resume_at = Some(Instant::now() + Duration::from_secs(5));
1648        assert!(!q.resume_due(), "an unexpired grace must still hold it");
1649
1650        // No deadline armed means an IDR was asked for: wait for it.
1651        q.resume_at = None;
1652        assert!(!q.resume_due());
1653    }
1654}