Skip to main content

blit_server/
lib.rs

1use blit_alacritty::{SearchResult as AlacrittySearchResult, TerminalDriver as AlacrittyDriver};
2use blit_remote::{
3    build_update_msg, msg_hello, FrameState, C2S_ACK, C2S_CLIENT_METRICS, C2S_CLOSE, C2S_CREATE,
4    C2S_CREATE2, C2S_CREATE_AT, C2S_CREATE_N, C2S_DISPLAY_RATE, C2S_FOCUS, C2S_INPUT, C2S_MOUSE,
5    C2S_COPY_RANGE, C2S_KILL, C2S_READ, C2S_RESIZE, C2S_RESTART, C2S_SCROLL, C2S_SEARCH,
6    C2S_SUBSCRIBE, C2S_UNSUBSCRIBE,
7    CREATE2_HAS_COMMAND, CREATE2_HAS_SRC_PTY, FEATURE_COPY_RANGE, FEATURE_CREATE_NONCE,
8    FEATURE_RESIZE_BATCH, FEATURE_RESTART, READ_ANSI, READ_TAIL, S2C_CLOSED, S2C_CREATED,
9    S2C_CREATED_N, S2C_LIST,
10    S2C_READY, S2C_SEARCH_RESULTS, S2C_TEXT, S2C_TITLE,
11};
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::ffi::CString;
14use std::os::unix::fs::PermissionsExt;
15use std::os::unix::io::RawFd;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18use tokio::io::unix::AsyncFd;
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
20use tokio::net::UnixListener;
21use tokio::sync::{mpsc, Mutex, Notify};
22
23type PtyFds = Arc<std::sync::RwLock<HashMap<u16, RawFd>>>;
24pub struct Config {
25    pub shell: String,
26    pub shell_flags: String,
27    pub scrollback: usize,
28    pub socket_path: String,
29    pub fd_channel: Option<RawFd>,
30    pub verbose: bool,
31}
32
33fn pty_write_all(fd: libc::c_int, mut data: &[u8]) {
34    while !data.is_empty() {
35        let ret = unsafe { libc::write(fd, data.as_ptr().cast(), data.len()) };
36        if ret > 0 {
37            data = &data[ret as usize..];
38        } else if ret < 0 {
39            let err = std::io::Error::last_os_error();
40            if err.kind() == std::io::ErrorKind::Interrupted {
41                continue;
42            }
43            break;
44        } else {
45            break;
46        }
47    }
48}
49
50trait PtyDriver: Send {
51    fn size(&self) -> (u16, u16);
52    fn resize(&mut self, rows: u16, cols: u16);
53    fn process(&mut self, data: &[u8]);
54    fn title(&self) -> &str;
55    fn search_result(&self, query: &str) -> Option<PtySearchResult>;
56    fn take_title_dirty(&mut self) -> bool;
57    fn cursor_position(&self) -> (u16, u16);
58    fn synced_output(&self) -> bool;
59    fn snapshot(&mut self, echo: bool, icanon: bool) -> FrameState;
60    fn scrollback_frame(&mut self, offset: usize) -> FrameState;
61    fn reset_modes(&mut self);
62    fn mouse_event(
63        &self,
64        type_: u8,
65        button: u8,
66        col: u16,
67        row: u16,
68        echo: bool,
69        icanon: bool,
70    ) -> Option<Vec<u8>>;
71    fn get_text_range(
72        &self,
73        start_tail: u32,
74        start_col: u16,
75        end_tail: u32,
76        end_col: u16,
77    ) -> String;
78    fn total_lines(&self) -> u32;
79}
80
81struct PtySearchResult {
82    score: u32,
83    primary_source: u8,
84    matched_sources: u8,
85    context: String,
86    scroll_offset: Option<usize>,
87}
88
89impl PtyDriver for AlacrittyDriver {
90    fn size(&self) -> (u16, u16) {
91        AlacrittyDriver::size(self)
92    }
93
94    fn resize(&mut self, rows: u16, cols: u16) {
95        AlacrittyDriver::resize(self, rows, cols);
96    }
97
98    fn process(&mut self, data: &[u8]) {
99        AlacrittyDriver::process(self, data);
100    }
101
102    fn title(&self) -> &str {
103        AlacrittyDriver::title(self)
104    }
105
106    fn search_result(&self, query: &str) -> Option<PtySearchResult> {
107        AlacrittyDriver::search_result(self, query).map(|result: AlacrittySearchResult| {
108            PtySearchResult {
109                score: result.score,
110                primary_source: result.primary_source as u8,
111                matched_sources: result.matched_sources,
112                context: result.context,
113                scroll_offset: result.scroll_offset,
114            }
115        })
116    }
117
118    fn take_title_dirty(&mut self) -> bool {
119        AlacrittyDriver::take_title_dirty(self)
120    }
121
122    fn cursor_position(&self) -> (u16, u16) {
123        AlacrittyDriver::cursor_position(self)
124    }
125
126    fn synced_output(&self) -> bool {
127        AlacrittyDriver::synced_output(self)
128    }
129
130    fn snapshot(&mut self, echo: bool, icanon: bool) -> FrameState {
131        AlacrittyDriver::snapshot(self, echo, icanon)
132    }
133
134    fn scrollback_frame(&mut self, offset: usize) -> FrameState {
135        AlacrittyDriver::scrollback_frame(self, offset)
136    }
137
138    fn reset_modes(&mut self) {
139        AlacrittyDriver::reset_modes(self);
140    }
141
142    fn mouse_event(
143        &self,
144        type_: u8,
145        button: u8,
146        col: u16,
147        row: u16,
148        echo: bool,
149        icanon: bool,
150    ) -> Option<Vec<u8>> {
151        AlacrittyDriver::mouse_event(self, type_, button, col, row, echo, icanon)
152    }
153
154    fn get_text_range(
155        &self,
156        start_tail: u32,
157        start_col: u16,
158        end_tail: u32,
159        end_col: u16,
160    ) -> String {
161        AlacrittyDriver::get_text_range(self, start_tail, start_col, end_tail, end_col)
162    }
163
164    fn total_lines(&self) -> u32 {
165        AlacrittyDriver::total_lines(self)
166    }
167}
168
169// Keep small to limit bufferbloat on slow connections.  The soft queue limit
170// (OUTBOX_SOFT_QUEUE_LIMIT_FRAMES) prevents the tick from queuing more than
171// ~2 frames, so this just needs to be bigger than that with some headroom.
172const OUTBOX_CAPACITY: usize = 8;
173const OUTBOX_SOFT_QUEUE_LIMIT_FRAMES: usize = 2;
174const PREVIEW_FRAME_RESERVE: usize = 1;
175const READY_FRAME_QUEUE_CAP: usize = 4;
176const PTY_CHANNEL_CAPACITY: usize = 64;
177const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l";
178
179/// A chunk of data from the PTY reader, sent through a lock-free channel
180/// so the reader never contends with the delivery tick for the Session mutex.
181enum PtyInput {
182    /// Raw bytes from the PTY, with the reader's sync-scan tail for boundary
183    /// detection. The tick task calls `process()` + `respond_to_queries()`.
184    Data(Vec<u8>),
185    /// Data up to a sync-output-close boundary. `before` should be processed
186    /// and then a snapshot taken. `after` is remainder for the next chunk.
187    SyncBoundary { before: Vec<u8>, after: Vec<u8> },
188    /// The PTY fd hit EOF or an error — the child likely exited.
189    Eof,
190}
191
192const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
193
194async fn read_frame(reader: &mut (impl AsyncRead + Unpin)) -> Option<Vec<u8>> {
195    let mut len_buf = [0u8; 4];
196    reader.read_exact(&mut len_buf).await.ok()?;
197    let len = u32::from_le_bytes(len_buf) as usize;
198    if len == 0 {
199        return Some(vec![]);
200    }
201    if len > MAX_FRAME_SIZE {
202        return None;
203    }
204    let mut buf = vec![0u8; len];
205    reader.read_exact(&mut buf).await.ok()?;
206    Some(buf)
207}
208
209async fn write_frame(writer: &mut (impl AsyncWrite + Unpin), payload: &[u8]) -> bool {
210    if payload.len() > u32::MAX as usize {
211        return false;
212    }
213    let len = payload.len() as u32;
214    let mut buf = Vec::with_capacity(4 + payload.len());
215    buf.extend_from_slice(&len.to_le_bytes());
216    buf.extend_from_slice(payload);
217    writer.write_all(&buf).await.is_ok()
218}
219
220struct Pty {
221    master_fd: libc::c_int,
222    child_pid: libc::pid_t,
223    driver: Box<dyn PtyDriver>,
224    /// Client-chosen tag set at creation time.
225    tag: String,
226    dirty: bool,
227    ready_frames: VecDeque<FrameState>,
228    /// Receives raw byte chunks from the PTY reader task without mutex contention.
229    byte_rx: mpsc::Receiver<PtyInput>,
230    reader_handle: std::thread::JoinHandle<()>,
231    /// Cached (echo, icanon) from tcgetattr; refreshed every ~250ms.
232    lflag_cache: (bool, bool),
233    lflag_last: Instant,
234    /// When we last broadcast a title update for this PTY.
235    last_title_send: Instant,
236    /// Title changed but not yet sent (debounced).
237    title_pending: bool,
238    /// The subprocess has exited but the terminal state is retained for reading.
239    exited: bool,
240    /// Exit status: WEXITSTATUS if normal exit, negative signal number if signalled,
241    /// EXIT_STATUS_UNKNOWN if not yet collected.
242    exit_status: i32,
243    /// Command used to create this PTY (None = default shell).
244    command: Option<String>,
245}
246
247impl Pty {
248    fn mark_dirty(&mut self) {
249        self.dirty = true;
250    }
251
252    fn clear_dirty(&mut self) {
253        self.dirty = false;
254    }
255}
256
257struct ClientState {
258    tx: mpsc::Sender<Vec<u8>>,
259    lead: Option<u16>,
260    subscriptions: HashSet<u16>,
261    view_sizes: HashMap<u16, (u16, u16)>,
262    scroll_offsets: HashMap<u16, usize>,
263    scroll_caches: HashMap<u16, FrameState>,
264    last_sent: HashMap<u16, FrameState>,
265    preview_next_send_at: HashMap<u16, Instant>,
266    /// EWMA RTT estimate in milliseconds.
267    rtt_ms: f32,
268    /// Minimum-path RTT estimate in milliseconds, excluding queue growth.
269    min_rtt_ms: f32,
270    /// Client's measured display refresh rate (fps), reported via C2S_DISPLAY_RATE.
271    display_fps: f32,
272    /// EWMA of delivered payload rate in bytes/sec.
273    delivery_bps: f32,
274    /// EWMA of actual ACKed goodput in bytes/sec, based on ACK cadence rather than RTT.
275    goodput_bps: f32,
276    /// EWMA of absolute goodput sample-to-sample jitter in bytes/sec.
277    goodput_jitter_bps: f32,
278    /// Decaying peak goodput jitter in bytes/sec.
279    max_goodput_jitter_bps: f32,
280    /// Last sampled ACK goodput for jitter estimation.
281    last_goodput_sample_bps: f32,
282    /// EWMA of acknowledged frame payload size in bytes.
283    avg_frame_bytes: f32,
284    /// EWMA of acknowledged lead/paced frame payload size in bytes.
285    avg_paced_frame_bytes: f32,
286    /// EWMA of acknowledged preview/unpaced frame payload size in bytes.
287    avg_preview_frame_bytes: f32,
288    /// Payload bytes currently in flight (sent, not yet ACKed).
289    inflight_bytes: usize,
290    /// Oldest in-flight frame first; ACKs arrive in order.
291    inflight_frames: VecDeque<InFlightFrame>,
292    /// Earliest time the next visual update should be sent for smooth pacing.
293    next_send_at: Instant,
294    /// Temporary additive window growth used to probe for more throughput after
295    /// a conservative backoff. Decays when queue delay grows.
296    probe_frames: f32,
297    /// Diagnostics.
298    frames_sent: u32,
299    acks_recv: u32,
300    acked_bytes_since_log: usize,
301    browser_backlog_frames: u16,
302    browser_ack_ahead_frames: u16,
303    browser_apply_ms: f32,
304    last_metrics_update: Instant,
305    last_log: Instant,
306    goodput_window_bytes: usize,
307    goodput_window_start: Instant,
308}
309
310struct InFlightFrame {
311    sent_at: Instant,
312    bytes: usize,
313    paced: bool,
314}
315
316/// Frames to keep in flight: enough to cover one RTT at the client's reported
317/// display rate. High-latency links need many frames in flight to avoid
318/// devolving into stop-and-wait.
319fn frame_window(rtt_ms: f32, display_fps: f32) -> usize {
320    let frame_ms = 1_000.0 / display_fps.max(1.0);
321    let base_frames = (rtt_ms / frame_ms).ceil().max(0.0) as usize;
322    let slack_frames = ((base_frames as f32) * 0.125).ceil() as usize + 2;
323    base_frames.saturating_add(slack_frames).max(2)
324}
325
326fn path_rtt_ms(client: &ClientState) -> f32 {
327    if client.min_rtt_ms > 0.0 {
328        client.min_rtt_ms
329    } else {
330        client.rtt_ms
331    }
332}
333
334fn display_need_bps(client: &ClientState) -> f32 {
335    client.avg_paced_frame_bytes.max(256.0) * client.display_fps.max(1.0)
336}
337
338fn effective_rtt_ms(client: &ClientState) -> f32 {
339    let path_rtt = path_rtt_ms(client);
340    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
341    let queue_allowance = frame_ms
342        * if throughput_limited(client) {
343            4.0
344        } else {
345            12.0
346        };
347    client.rtt_ms.clamp(path_rtt, path_rtt + queue_allowance)
348}
349
350fn window_rtt_ms(client: &ClientState) -> f32 {
351    let effective = effective_rtt_ms(client);
352    if !throughput_limited(client) {
353        effective
354    } else {
355        client.rtt_ms.clamp(effective, effective * 2.0)
356    }
357}
358
359fn target_frame_window(client: &ClientState) -> usize {
360    let window_fps = if throughput_limited(client) {
361        pacing_fps(client)
362    } else {
363        browser_pacing_fps(client)
364    };
365    frame_window(window_rtt_ms(client), window_fps)
366        .saturating_add(client.probe_frames.round().max(0.0) as usize)
367}
368
369fn base_queue_ms(client: &ClientState) -> f32 {
370    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
371    frame_ms * if throughput_limited(client) { 2.0 } else { 8.0 }
372}
373
374fn target_queue_ms(client: &ClientState) -> f32 {
375    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
376    let probe_scale = if throughput_limited(client) {
377        0.25
378    } else {
379        1.0
380    };
381    base_queue_ms(client) + client.probe_frames.max(0.0) * frame_ms * probe_scale
382}
383
384fn browser_ready(client: &ClientState) -> bool {
385    client.browser_ack_ahead_frames <= 1
386        && client.browser_apply_ms <= 1.0
387        && !outbox_backpressured(client)
388}
389
390fn bandwidth_floor_bps(client: &ClientState) -> f32 {
391    let browser_ready = browser_ready(client);
392    let backlog_scale = match client.browser_backlog_frames {
393        0..=2 => 0.9,
394        3..=8 => 0.8,
395        _ => 0.65,
396    };
397    let penalty = client
398        .goodput_jitter_bps
399        .max(client.max_goodput_jitter_bps * 0.5)
400        .min(client.goodput_bps * if browser_ready { 0.75 } else { 0.9 });
401    let goodput_floor = (client.goodput_bps - penalty)
402        .max(client.goodput_bps * if browser_ready { 0.35 } else { 0.2 });
403    // On a browser-ready path, the per-frame delivery estimate is already
404    // end-to-end and reacts much faster than ACK-window goodput. Halving it
405    // leaves large-frame local links chronically underpaced.
406    let delivery_floor = client.delivery_bps * if browser_ready { 1.0 } else { 0.5 };
407    let recent_sample_floor = if browser_ready && client.last_goodput_sample_bps > 0.0 {
408        client.last_goodput_sample_bps * backlog_scale
409    } else {
410        0.0
411    };
412    goodput_floor.max(recent_sample_floor).max(delivery_floor)
413}
414
415fn pacing_fps(client: &ClientState) -> f32 {
416    let frame_bytes = client.avg_paced_frame_bytes.max(256.0);
417    let sustainable = bandwidth_floor_bps(client) / frame_bytes;
418    sustainable.min(browser_pacing_fps(client))
419}
420
421fn throughput_limited(client: &ClientState) -> bool {
422    let floor = bandwidth_floor_bps(client);
423    // Consider total demand: lead at cadence rate plus previews at their cap.
424    // The old check (pacing_fps < cadence * 0.9) only saw lead bandwidth,
425    // which is often tiny, so previews could starve the lead undetected.
426    let lead_bps = client.avg_paced_frame_bytes.max(256.0) * browser_pacing_fps(client);
427    let preview_bps = client.avg_preview_frame_bytes.max(256.0) * client.display_fps.max(1.0);
428    (lead_bps + preview_bps) > floor * 0.9
429}
430
431fn browser_pacing_fps(client: &ClientState) -> f32 {
432    let mut fps = client.display_fps.max(1.0);
433
434    // Backlog and ack-ahead are direct signals from the browser about
435    // whether it's keeping up.  No predictive apply-time bound — it
436    // consistently underestimates capacity and causes 30fps death spirals.
437    let backlog = client.browser_backlog_frames as f32;
438    if backlog > 4.0 {
439        fps = fps.min(fps * (4.0 / backlog));
440    }
441
442    if client.browser_ack_ahead_frames > 4 {
443        fps = fps.min(client.display_fps.max(1.0) * 0.5);
444    }
445
446    fps.max(1.0)
447}
448
449fn browser_backlog_blocked(client: &ClientState) -> bool {
450    client.browser_backlog_frames > 8
451}
452
453fn byte_budget_for(client: &ClientState, budget_ms: f32) -> usize {
454    let budget_bps = if throughput_limited(client) {
455        bandwidth_floor_bps(client)
456    } else {
457        client.goodput_bps.max(bandwidth_floor_bps(client))
458    };
459    let bytes = budget_bps * budget_ms.max(1.0) / 1_000.0;
460    bytes.ceil().max(client.avg_frame_bytes.max(256.0)) as usize
461}
462
463fn target_byte_window(client: &ClientState) -> usize {
464    let budget = byte_budget_for(client, path_rtt_ms(client) + target_queue_ms(client));
465    let frame_bytes = client.avg_paced_frame_bytes.max(256.0).ceil() as usize;
466    let target_frames = target_frame_window(client);
467    let pipeline_bytes = frame_bytes.saturating_mul(target_frames);
468    // For small pipelines (e.g. idle terminals with 1KB frames), allow the
469    // full frame window worth of bytes so we pipeline across the RTT instead
470    // of stop-and-wait.  For large pipelines (e.g. 50KB frames × 5 frames =
471    // 250KB), the budget (BDP-based) is the binding constraint; fall back to
472    // a one-frame floor so we don't pile up many RTTs worth of large frames.
473    const PIPELINE_FLOOR_LIMIT: usize = 32_768; // 32 KB
474    let floor = if pipeline_bytes <= PIPELINE_FLOOR_LIMIT {
475        pipeline_bytes
476    } else {
477        frame_bytes // one-frame floor for large pipelines
478    };
479    budget.max(floor)
480}
481
482fn send_interval(client: &ClientState) -> Duration {
483    Duration::from_secs_f64(1.0 / browser_pacing_fps(client).max(1.0) as f64)
484}
485
486fn preview_fps(client: &ClientState) -> f32 {
487    let mut fps = client.display_fps.max(1.0);
488    if client.lead.is_some() {
489        // Always budget preview bandwidth: available minus lead's share.
490        // Without this, large preview frames (e.g. 12 KB) at 30 fps consume
491        // 360 KB/s, starving the lead even when lead frames are tiny.
492        let avail = bandwidth_floor_bps(client);
493        let lead_bps = client.avg_paced_frame_bytes.max(256.0) * browser_pacing_fps(client);
494        let preview_budget = (avail - lead_bps).max(avail * 0.25).max(0.0);
495        let bw_cap = preview_budget / client.avg_preview_frame_bytes.max(256.0);
496        fps = fps.min(bw_cap.max(1.0));
497    }
498    fps.max(1.0)
499}
500
501fn preview_send_interval(client: &ClientState) -> Duration {
502    Duration::from_secs_f64(1.0 / preview_fps(client) as f64)
503}
504
505fn advance_deadline(deadline: &mut Instant, now: Instant, interval: Duration) {
506    let scheduled = deadline.checked_add(interval).unwrap_or(now + interval);
507    *deadline = if scheduled + interval < now {
508        now + interval
509    } else {
510        scheduled
511    };
512}
513
514fn should_snapshot_pty(dirty: bool, needful: bool, synced_output: bool) -> bool {
515    dirty && needful && !synced_output
516}
517
518fn enqueue_ready_frame(queue: &mut VecDeque<FrameState>, frame: FrameState) -> bool {
519    if queue.len() >= READY_FRAME_QUEUE_CAP {
520        return false;
521    }
522    queue.push_back(frame);
523    true
524}
525
526fn pty_has_visual_update(pty: &Pty) -> bool {
527    pty.dirty || !pty.ready_frames.is_empty() || !pty.byte_rx.is_empty()
528}
529
530/// Find the first `\x1b[?2026l` in `bytes`, handling sequences that span
531/// the `prefix`/`bytes` boundary. Uses SIMD-accelerated memchr for the
532/// initial ESC scan.
533fn find_sync_output_end(prefix: &[u8], bytes: &[u8]) -> Option<usize> {
534    if bytes.is_empty() {
535        return None;
536    }
537    let needle = SYNC_OUTPUT_END;
538    let nlen = needle.len();
539
540    // Check for a match straddling the prefix/bytes boundary.
541    if !prefix.is_empty() {
542        let tail = if prefix.len() >= nlen - 1 {
543            &prefix[prefix.len() - (nlen - 1)..]
544        } else {
545            prefix
546        };
547        let combined_len = tail.len() + bytes.len().min(nlen);
548        if combined_len >= nlen {
549            // Small stack buffer to check the boundary region.
550            let mut buf = [0u8; 32]; // SYNC_OUTPUT_END is 8 bytes, so 32 is plenty
551            let blen = combined_len.min(buf.len());
552            let tlen = tail.len().min(blen);
553            buf[..tlen].copy_from_slice(&tail[..tlen]);
554            let rest = (blen - tlen).min(bytes.len());
555            buf[tlen..tlen + rest].copy_from_slice(&bytes[..rest]);
556            for i in 0..=(blen.saturating_sub(nlen)) {
557                if &buf[i..i + nlen] == needle {
558                    let end_in_bytes = (i + nlen).saturating_sub(tail.len());
559                    if end_in_bytes > 0 && end_in_bytes <= bytes.len() {
560                        return Some(end_in_bytes);
561                    }
562                }
563            }
564        }
565    }
566
567    // SIMD-scan for ESC (0x1b) then verify the full sequence.
568    let mut offset = 0;
569    while let Some(pos) = memchr::memchr(0x1b, &bytes[offset..]) {
570        let abs = offset + pos;
571        if abs + nlen <= bytes.len() && &bytes[abs..abs + nlen] == needle {
572            return Some(abs + nlen);
573        }
574        offset = abs + 1;
575    }
576    None
577}
578
579fn update_sync_scan_tail(tail: &mut Vec<u8>, bytes: &[u8]) {
580    if bytes.is_empty() {
581        return;
582    }
583    tail.extend_from_slice(bytes);
584    let keep = SYNC_OUTPUT_END.len().saturating_sub(1);
585    if tail.len() > keep {
586        let drop = tail.len() - keep;
587        tail.drain(..drop);
588    }
589}
590
591fn preview_deadline(client: &ClientState, pid: u16, now: Instant) -> Instant {
592    client
593        .preview_next_send_at
594        .get(&pid)
595        .copied()
596        .unwrap_or(now)
597}
598
599fn client_has_due_preview(sess: &Session, client: &ClientState, now: Instant) -> bool {
600    if client.lead.is_none() {
601        return false;
602    }
603    client.subscriptions.iter().copied().any(|pid| {
604        Some(pid) != client.lead
605            && preview_deadline(client, pid, now) <= now
606            && sess
607                .ptys
608                .get(&pid)
609                .map(pty_has_visual_update)
610                .unwrap_or(false)
611    })
612}
613
614fn outbox_queued_frames(client: &ClientState) -> usize {
615    OUTBOX_CAPACITY.saturating_sub(client.tx.capacity())
616}
617
618fn outbox_backpressured(client: &ClientState) -> bool {
619    outbox_queued_frames(client) >= OUTBOX_SOFT_QUEUE_LIMIT_FRAMES
620}
621
622fn can_send_preview(client: &ClientState, pid: u16, now: Instant) -> bool {
623    window_open(client) && now >= preview_deadline(client, pid, now)
624}
625
626fn record_preview_send(client: &mut ClientState, pid: u16, now: Instant) {
627    let mut deadline = client
628        .preview_next_send_at
629        .get(&pid)
630        .copied()
631        .unwrap_or(now);
632    advance_deadline(&mut deadline, now, preview_send_interval(client));
633    client.preview_next_send_at.insert(pid, deadline);
634}
635
636fn window_open(client: &ClientState) -> bool {
637    !browser_backlog_blocked(client)
638        && !outbox_backpressured(client)
639        && client.inflight_frames.len() < target_frame_window(client)
640        && client.inflight_bytes < target_byte_window(client)
641}
642
643fn lead_window_open(client: &ClientState, reserve_preview_slot: bool) -> bool {
644    if !reserve_preview_slot || client.lead.is_none() {
645        return window_open(client);
646    }
647    if browser_backlog_blocked(client) || outbox_backpressured(client) {
648        return false;
649    }
650    let target_frames = target_frame_window(client);
651    let reserve_frames = PREVIEW_FRAME_RESERVE.min(target_frames.saturating_sub(1));
652    let frame_limit = target_frames.saturating_sub(reserve_frames).max(1);
653    let reserve_bytes = client.avg_preview_frame_bytes.max(256.0).ceil() as usize;
654    let byte_limit = target_byte_window(client)
655        .saturating_sub(reserve_bytes)
656        .max(client.avg_paced_frame_bytes.max(256.0).ceil() as usize);
657    client.inflight_frames.len() < frame_limit && client.inflight_bytes < byte_limit
658}
659
660fn can_send_frame(client: &ClientState, now: Instant, reserve_preview_slot: bool) -> bool {
661    lead_window_open(client, reserve_preview_slot) && now >= client.next_send_at
662}
663
664fn record_send(client: &mut ClientState, bytes: usize, now: Instant, paced: bool) {
665    client.inflight_bytes += bytes;
666    client.inflight_frames.push_back(InFlightFrame {
667        sent_at: now,
668        bytes,
669        paced,
670    });
671    if paced {
672        let interval = send_interval(client);
673        advance_deadline(&mut client.next_send_at, now, interval);
674    }
675}
676
677fn ewma_with_direction(old: f32, sample: f32, rise_alpha: f32, fall_alpha: f32) -> f32 {
678    let alpha = if sample > old { rise_alpha } else { fall_alpha };
679    old * (1.0 - alpha) + sample * alpha
680}
681
682fn window_saturated(client: &ClientState, inflight_frames: usize, inflight_bytes: usize) -> bool {
683    let target_frames = target_frame_window(client);
684    let target_bytes = target_byte_window(client);
685    inflight_frames.saturating_mul(10) >= target_frames.saturating_mul(9)
686        || inflight_bytes.saturating_mul(10) >= target_bytes.saturating_mul(9)
687}
688
689fn record_ack(client: &mut ClientState) {
690    if let Some(frame) = client.inflight_frames.pop_front() {
691        let prev_inflight_frames = client.inflight_frames.len() + 1;
692        let prev_inflight_bytes = client.inflight_bytes;
693        client.inflight_bytes = client.inflight_bytes.saturating_sub(frame.bytes);
694        client.acked_bytes_since_log = client.acked_bytes_since_log.saturating_add(frame.bytes);
695        let sample_ms = frame.sent_at.elapsed().as_secs_f32() * 1_000.0;
696        client.rtt_ms = ewma_with_direction(client.rtt_ms, sample_ms, 0.125, 0.25);
697        if client.min_rtt_ms > 0.0 {
698            // Only update downward: min_rtt tracks the unloaded path RTT and
699            // must not drift upward during congestion (queued RTT ≠ path RTT).
700            client.min_rtt_ms = client.min_rtt_ms.min(sample_ms);
701        } else {
702            client.min_rtt_ms = sample_ms;
703        }
704        client.min_rtt_ms = client.min_rtt_ms.max(0.5);
705        let sample_bps = frame.bytes as f32 / sample_ms.max(1.0e-3) * 1_000.0;
706        client.delivery_bps = ewma_with_direction(client.delivery_bps, sample_bps, 0.5, 0.125);
707        client.avg_frame_bytes =
708            ewma_with_direction(client.avg_frame_bytes, frame.bytes as f32, 0.5, 0.125);
709        if frame.paced {
710            client.avg_paced_frame_bytes =
711                ewma_with_direction(client.avg_paced_frame_bytes, frame.bytes as f32, 0.5, 0.125);
712        } else {
713            client.avg_preview_frame_bytes = ewma_with_direction(
714                client.avg_preview_frame_bytes,
715                frame.bytes as f32,
716                0.5,
717                0.125,
718            );
719        }
720        let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
721        let path_rtt = path_rtt_ms(client);
722        let likely_window_limited =
723            window_saturated(client, prev_inflight_frames, prev_inflight_bytes);
724        client.goodput_window_bytes = client.goodput_window_bytes.saturating_add(frame.bytes);
725        let now = Instant::now();
726        let goodput_elapsed = now
727            .duration_since(client.goodput_window_start)
728            .as_secs_f32();
729        if goodput_elapsed >= 0.02 {
730            let sample_goodput = client.goodput_window_bytes as f32 / goodput_elapsed.max(1.0e-3);
731            if likely_window_limited || client.browser_backlog_frames > 0 {
732                let prev_goodput_sample = if client.last_goodput_sample_bps > 0.0 {
733                    client.last_goodput_sample_bps
734                } else {
735                    sample_goodput
736                };
737                let jitter_sample = (sample_goodput - prev_goodput_sample).abs();
738                client.goodput_bps =
739                    ewma_with_direction(client.goodput_bps, sample_goodput, 0.5, 0.125);
740                // Only update jitter from windows with at least 2 frames.
741                // Single-frame windows are pure measurement noise (0 or 1
742                // frame per 25 ms is a Bernoulli trial, not a congestion
743                // signal) and inflate jitter_bps, which in turn depresses
744                // bandwidth_floor_bps and causes pacing to stall.
745                let min_reliable = (client.avg_paced_frame_bytes.max(256.0) * 2.0) as usize;
746                if client.goodput_window_bytes >= min_reliable {
747                    client.goodput_jitter_bps =
748                        ewma_with_direction(client.goodput_jitter_bps, jitter_sample, 0.5, 0.125);
749                    let jitter_decay = if browser_ready(client) && sample_ms < path_rtt * 3.0 {
750                        0.90
751                    } else {
752                        0.98
753                    };
754                    client.max_goodput_jitter_bps =
755                        (client.max_goodput_jitter_bps * jitter_decay).max(jitter_sample);
756                    // Cap jitter at 45% of goodput so jitter_ratio can never
757                    // exceed 0.45 from measurement noise alone.  Real congestion
758                    // will still drive goodput_bps down and widen the window.
759                    client.max_goodput_jitter_bps =
760                        client.max_goodput_jitter_bps.min(client.goodput_bps * 0.45);
761                } else {
762                    // Thin sample: gently decay jitter rather than updating it.
763                    client.goodput_jitter_bps *= 0.9;
764                    client.max_goodput_jitter_bps *= 0.95;
765                }
766                // Sticky-high: never let last_goodput_sample_bps drop abruptly.
767                // A sudden drop (e.g. 1-frame window following a 2-frame window)
768                // inflates jitter_sample on the next cycle, collapsing probe_frames.
769                client.last_goodput_sample_bps =
770                    (client.last_goodput_sample_bps * 0.99).max(sample_goodput);
771            } else {
772                // When the path is underfilled, ACK cadence mostly measures our
773                // own pacing rather than network capacity.  Use a fall alpha
774                // proportional to estimation error: when the estimate is 10x+
775                // the sample, converge aggressively; when close, stay gentle.
776                let ratio = client.goodput_bps / sample_goodput.max(1.0);
777                let fall_alpha = if ratio > 10.0 {
778                    0.5
779                } else if ratio > 3.0 {
780                    0.25
781                } else {
782                    0.03
783                };
784                client.goodput_bps =
785                    ewma_with_direction(client.goodput_bps, sample_goodput, 0.5, fall_alpha);
786                client.goodput_jitter_bps *= 0.5;
787                client.max_goodput_jitter_bps *= 0.9;
788                client.last_goodput_sample_bps =
789                    (client.last_goodput_sample_bps * 0.99).max(sample_goodput);
790            }
791            client.goodput_window_bytes = 0;
792            client.goodput_window_start = now;
793        }
794        let queue_baseline_ms = if throughput_limited(client) {
795            window_rtt_ms(client)
796        } else {
797            path_rtt
798        };
799        let queue_delay_ms = (sample_ms - queue_baseline_ms).max(0.0);
800        let max_probe_frames = (browser_pacing_fps(client) * 0.125).max(4.0);
801        let jitter_ratio = client.max_goodput_jitter_bps / client.goodput_bps.max(1.0);
802        let low_delay_frames = if throughput_limited(client) { 2.0 } else { 8.0 };
803        let high_delay_frames = if throughput_limited(client) {
804            4.0
805        } else {
806            12.0
807        };
808        if likely_window_limited
809            && queue_delay_ms <= frame_ms * low_delay_frames
810            && jitter_ratio < 0.25
811        {
812            client.probe_frames = (client.probe_frames + 1.0).min(max_probe_frames);
813        } else if !likely_window_limited
814            && browser_ready(client)
815            && queue_delay_ms <= frame_ms * 2.0
816            && jitter_ratio < 0.25
817        {
818            client.probe_frames = (client.probe_frames + 0.25).min(max_probe_frames * 0.5);
819        } else if queue_delay_ms > frame_ms * high_delay_frames || jitter_ratio > 0.5 {
820            client.probe_frames = (client.probe_frames * 0.5).max(1.0);
821        } else if queue_delay_ms > frame_ms * 2.0 || !browser_ready(client) {
822            client.probe_frames = (client.probe_frames - 0.5).max(0.0);
823        }
824    } else {
825        client.inflight_bytes = 0;
826    }
827}
828
829fn reset_inflight(client: &mut ClientState) {
830    client.inflight_bytes = 0;
831    client.inflight_frames.clear();
832    client.next_send_at = Instant::now();
833    client.browser_backlog_frames = 0;
834    client.browser_ack_ahead_frames = 0;
835}
836
837fn is_unset_view_size(rows: u16, cols: u16) -> bool {
838    rows == 0 && cols == 0
839}
840
841fn subscribe_client_to(client: &mut ClientState, pty_id: u16) {
842    if client.subscriptions.insert(pty_id) {
843        client.last_sent.remove(&pty_id);
844        client.preview_next_send_at.remove(&pty_id);
845    }
846}
847
848fn unsubscribe_client_from(client: &mut ClientState, pty_id: u16) -> bool {
849    let removed_sub = client.subscriptions.remove(&pty_id);
850    client.last_sent.remove(&pty_id);
851    client.preview_next_send_at.remove(&pty_id);
852    client.scroll_offsets.remove(&pty_id);
853    client.scroll_caches.remove(&pty_id);
854    let removed_view = client.view_sizes.remove(&pty_id).is_some();
855    if client.lead == Some(pty_id) {
856        client.lead = None;
857    }
858    removed_sub || removed_view
859}
860
861fn update_client_scroll_state(client: &mut ClientState, pty_id: u16, next_offset: usize) -> bool {
862    let prev_offset = client.scroll_offsets.get(&pty_id).copied().unwrap_or(0);
863    if prev_offset == next_offset {
864        return false;
865    }
866
867    if prev_offset == 0 && next_offset > 0 {
868        client.scroll_caches.insert(
869            pty_id,
870            client.last_sent.get(&pty_id).cloned().unwrap_or_default(),
871        );
872    } else if prev_offset > 0 && next_offset == 0 {
873        if let Some(cache) = client.scroll_caches.remove(&pty_id) {
874            if cache.rows() > 0 && cache.cols() > 0 {
875                client.last_sent.insert(pty_id, cache);
876            } else {
877                client.last_sent.remove(&pty_id);
878            }
879        }
880    }
881
882    if next_offset > 0 {
883        client.scroll_offsets.insert(pty_id, next_offset);
884    } else {
885        client.scroll_offsets.remove(&pty_id);
886    }
887    reset_inflight(client);
888    true
889}
890
891struct Session {
892    ptys: HashMap<u16, Pty>,
893    next_client_id: u64,
894    /// Diagnostics: how many times tick() was called this second.
895    tick_fires: u32,
896    /// Diagnostics: how many ticks found the focused PTY dirty (snapshot taken).
897    tick_snaps: u32,
898    clients: HashMap<u64, ClientState>,
899}
900
901struct SearchResultRow {
902    pty_id: u16,
903    score: u32,
904    primary_source: u8,
905    matched_sources: u8,
906    context: String,
907    scroll_offset: Option<usize>,
908}
909
910struct TickOutcome {
911    did_work: bool,
912    next_deadline: Option<Instant>,
913}
914
915impl Session {
916    fn new() -> Self {
917        Self {
918            ptys: HashMap::new(),
919            next_client_id: 1,
920            clients: HashMap::new(),
921            tick_fires: 0,
922            tick_snaps: 0,
923        }
924    }
925
926    fn allocate_pty_id(&mut self) -> Option<u16> {
927        (1..=u16::MAX).find(|id| !self.ptys.contains_key(id))
928    }
929
930    fn send_to_all(&self, msg: &[u8]) {
931        for c in self.clients.values() {
932            let _ = c.tx.try_send(msg.to_vec());
933        }
934    }
935
936    fn mediated_size_for_pty(&self, pty_id: u16) -> Option<(u16, u16)> {
937        let mut min_rows: Option<u16> = None;
938        let mut min_cols: Option<u16> = None;
939        for c in self.clients.values() {
940            if let Some((r, cols)) = c.view_sizes.get(&pty_id).copied() {
941                min_rows = Some(min_rows.map_or(r, |m: u16| m.min(r)));
942                min_cols = Some(min_cols.map_or(cols, |m: u16| m.min(cols)));
943            }
944        }
945        match (min_rows, min_cols) {
946            (Some(r), Some(c)) => Some((r.max(1), c.max(1))),
947            _ => None,
948        }
949    }
950
951    fn resize_pty(&mut self, pty_id: u16, rows: u16, cols: u16) -> bool {
952        let pty = match self.ptys.get_mut(&pty_id) {
953            Some(p) => p,
954            None => return false,
955        };
956        let (cur_rows, cur_cols) = pty.driver.size();
957        if cur_rows == rows && cur_cols == cols {
958            return false;
959        }
960        pty.ready_frames.clear();
961        pty.driver.resize(rows, cols);
962        pty.mark_dirty();
963        for c in self.clients.values_mut() {
964            if c.subscriptions.contains(&pty_id) {
965                c.last_sent.remove(&pty_id);
966            }
967            if c.scroll_caches.remove(&pty_id).is_some() {
968                reset_inflight(c);
969            }
970        }
971        if !pty.exited {
972            unsafe {
973                let ws = libc::winsize {
974                    ws_row: rows,
975                    ws_col: cols,
976                    ws_xpixel: 0,
977                    ws_ypixel: 0,
978                };
979                libc::ioctl(pty.master_fd, libc::TIOCSWINSZ, &ws);
980                let mut fg_pgid: libc::pid_t = 0;
981                libc::ioctl(pty.master_fd, libc::TIOCGPGRP, &mut fg_pgid);
982                if fg_pgid > 0 {
983                    libc::kill(-fg_pgid, libc::SIGWINCH);
984                }
985                libc::kill(-pty.child_pid, libc::SIGWINCH);
986            }
987        }
988        true
989    }
990
991    fn resize_ptys_to_mediated_sizes<I>(&mut self, pty_ids: I) -> bool
992    where
993        I: IntoIterator<Item = u16>,
994    {
995        let mut changed = false;
996        let mut seen = HashSet::new();
997        for pty_id in pty_ids {
998            if !seen.insert(pty_id) {
999                continue;
1000            }
1001            if let Some((rows, cols)) = self.mediated_size_for_pty(pty_id) {
1002                changed |= self.resize_pty(pty_id, rows, cols);
1003            }
1004        }
1005        changed
1006    }
1007
1008    fn pty_list_msg(&self) -> Vec<u8> {
1009        let mut msg = vec![S2C_LIST];
1010        let count = self.ptys.len() as u16;
1011        msg.extend_from_slice(&count.to_le_bytes());
1012        let mut ids: Vec<u16> = self.ptys.keys().copied().collect();
1013        ids.sort();
1014        for id in ids {
1015            let pty = &self.ptys[&id];
1016            let tag = pty.tag.as_bytes();
1017            msg.extend_from_slice(&id.to_le_bytes());
1018            msg.extend_from_slice(&(tag.len() as u16).to_le_bytes());
1019            msg.extend_from_slice(tag);
1020            let cmd = pty.command.as_deref().unwrap_or("").as_bytes();
1021            msg.extend_from_slice(&(cmd.len() as u16).to_le_bytes());
1022            msg.extend_from_slice(cmd);
1023        }
1024        msg
1025    }
1026}
1027
1028type AppState = Arc<(Config, Mutex<Session>, PtyFds, Arc<Notify>)>;
1029
1030fn nudge_delivery(state: &AppState) {
1031    state.3.notify_one();
1032}
1033
1034fn pty_cwd(pid: libc::pid_t) -> Option<String> {
1035    #[cfg(target_os = "linux")]
1036    {
1037        std::fs::read_link(format!("/proc/{pid}/cwd"))
1038            .ok()
1039            .and_then(|p| p.into_os_string().into_string().ok())
1040    }
1041    #[cfg(target_os = "macos")]
1042    {
1043        use std::ffi::CStr;
1044        let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
1045        let ret = unsafe {
1046            libc::proc_pidinfo(
1047                pid,
1048                libc::PROC_PIDVNODEPATHINFO,
1049                0,
1050                buf.as_mut_ptr() as *mut libc::c_void,
1051                std::mem::size_of::<libc::proc_vnodepathinfo>() as i32,
1052            )
1053        };
1054        if ret <= 0 {
1055            return None;
1056        }
1057        let info = unsafe { &*(buf.as_ptr() as *const libc::proc_vnodepathinfo) };
1058        let cstr =
1059            unsafe { CStr::from_ptr(info.pvi_cdir.vip_path.as_ptr() as *const libc::c_char) };
1060        cstr.to_str().ok().map(|s| s.to_owned())
1061    }
1062    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1063    {
1064        let _ = pid;
1065        None
1066    }
1067}
1068
1069/// On macOS, child processes forked from a CLI tool (as opposed to a native
1070/// .app with an NSWindow) don't inherit foreground-app scheduling: the kernel
1071/// has no window association for the PTY session, so it schedules the children
1072/// on efficiency cores with possible duty-cycling.  Explicitly requesting
1073/// QOS_CLASS_USER_INTERACTIVE restores parity with terminals like Ghostty.
1074fn set_qos_user_interactive() {
1075    #[cfg(target_os = "macos")]
1076    {
1077        const QOS_CLASS_USER_INTERACTIVE: libc::c_uint = 0x21;
1078        extern "C" {
1079            fn pthread_set_qos_class_self_np(
1080                qos_class: libc::c_uint,
1081                relative_priority: libc::c_int,
1082            ) -> libc::c_int;
1083        }
1084        unsafe {
1085            pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
1086        }
1087    }
1088}
1089
1090#[allow(clippy::too_many_arguments)]
1091fn spawn_pty(
1092    shell: &str,
1093    rows: u16,
1094    cols: u16,
1095    id: u16,
1096    tag: &str,
1097    command: Option<&str>,
1098    argv: Option<&[&str]>,
1099    dir: Option<&str>,
1100    scrollback: usize,
1101    state: AppState,
1102) -> Option<Pty> {
1103    let mut master: libc::c_int = 0;
1104    let mut slave: libc::c_int = 0;
1105    unsafe {
1106        if libc::openpty(
1107            &mut master,
1108            &mut slave,
1109            std::ptr::null_mut(),
1110            std::ptr::null_mut(),
1111            std::ptr::null_mut(),
1112        ) != 0
1113        {
1114            eprintln!("openpty failed for pty {id}");
1115            return None;
1116        }
1117        let ws = libc::winsize {
1118            ws_row: rows,
1119            ws_col: cols,
1120            ws_xpixel: 0,
1121            ws_ypixel: 0,
1122        };
1123        libc::ioctl(master, libc::TIOCSWINSZ, &ws);
1124    }
1125
1126    let pid = unsafe { libc::fork() };
1127    if pid < 0 {
1128        eprintln!("fork failed for pty {id}");
1129        unsafe {
1130            libc::close(master);
1131            libc::close(slave);
1132        }
1133        return None;
1134    }
1135
1136    if pid == 0 {
1137        unsafe {
1138            libc::close(master);
1139            libc::setsid();
1140            libc::ioctl(slave, libc::TIOCSCTTY as _, 0);
1141            libc::dup2(slave, 0);
1142            libc::dup2(slave, 1);
1143            libc::dup2(slave, 2);
1144            if slave > 2 {
1145                libc::close(slave);
1146            }
1147        }
1148        set_qos_user_interactive();
1149        let effective_dir = dir.map(String::from);
1150        if let Some(d) = effective_dir {
1151            if let Ok(dir_c) = CString::new(d) {
1152                unsafe {
1153                    libc::chdir(dir_c.as_ptr());
1154                }
1155            }
1156        }
1157        std::env::set_var("TERM", "xterm-256color");
1158        std::env::set_var("COLORTERM", "truecolor");
1159        // Don't set COLUMNS/LINES — ncurses apps prioritize these over
1160        // TIOCGWINSZ and won't resize properly if they're set to stale values.
1161        std::env::remove_var("COLUMNS");
1162        std::env::remove_var("LINES");
1163        for (key, _) in std::env::vars() {
1164            if key.starts_with("BLIT_") && key != "BLIT_HUB" && key != "BLIT_DISPLAY_FPS" {
1165                std::env::remove_var(&key);
1166            }
1167        }
1168        let shell_flags = &state.0.shell_flags;
1169        if let Some(command) = command {
1170            let shell_c = CString::new(shell).unwrap();
1171            let command_c = CString::new(command).unwrap();
1172            let flag = CString::new(if shell_flags.is_empty() {
1173                "-c".to_owned()
1174            } else {
1175                format!("-{}c", shell_flags)
1176            })
1177            .unwrap();
1178            unsafe {
1179                let p = shell_c.as_ptr();
1180                let f = flag.as_ptr();
1181                let c = command_c.as_ptr();
1182                libc::execvp(p, [p, f, c, std::ptr::null()].as_ptr());
1183                libc::_exit(1);
1184            }
1185        }
1186        if let Some(args) = argv {
1187            if !args.is_empty() {
1188                let cargs: Vec<CString> = args.iter().map(|s| CString::new(*s).unwrap()).collect();
1189                let ptrs: Vec<*const libc::c_char> = cargs
1190                    .iter()
1191                    .map(|c| c.as_ptr())
1192                    .chain(std::iter::once(std::ptr::null()))
1193                    .collect();
1194                unsafe {
1195                    libc::execvp(ptrs[0], ptrs.as_ptr());
1196                    libc::_exit(1);
1197                }
1198            }
1199        }
1200        let shell_c = CString::new(shell).unwrap();
1201        unsafe {
1202            if shell_flags.is_empty() {
1203                let p = shell_c.as_ptr();
1204                libc::execvp(p, [p, std::ptr::null()].as_ptr());
1205            } else {
1206                let flag = CString::new(format!("-{}", shell_flags)).unwrap();
1207                let p = shell_c.as_ptr();
1208                let f = flag.as_ptr();
1209                libc::execvp(p, [p, f, std::ptr::null()].as_ptr());
1210            }
1211            libc::_exit(1);
1212        }
1213    }
1214
1215    unsafe {
1216        libc::close(slave);
1217        let flags = libc::fcntl(master, libc::F_GETFL);
1218        libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
1219    }
1220
1221    unsafe {
1222        libc::close(slave);
1223        let flags = libc::fcntl(master, libc::F_GETFL);
1224        libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
1225    }
1226
1227    state.2.write().unwrap().insert(id, master);
1228    let (byte_tx, byte_rx) = mpsc::channel(PTY_CHANNEL_CAPACITY);
1229    let reader_handle = std::thread::spawn({
1230        let notify = state.3.clone();
1231        move || pty_reader(master, byte_tx, notify)
1232    });
1233    let lflag_cache = pty_lflag(master);
1234
1235    Some(Pty {
1236        master_fd: master,
1237        child_pid: pid,
1238        driver: Box::new(AlacrittyDriver::new(rows, cols, scrollback)),
1239        tag: tag.to_owned(),
1240        dirty: true,
1241        ready_frames: VecDeque::new(),
1242        byte_rx,
1243        reader_handle,
1244        lflag_cache,
1245        lflag_last: Instant::now(),
1246        last_title_send: Instant::now(),
1247        title_pending: false,
1248        exited: false,
1249        exit_status: blit_remote::EXIT_STATUS_UNKNOWN,
1250        command: command.map(|s| s.to_owned()),
1251    })
1252}
1253
1254/// Spawn a new child process on a fresh PTY pair.
1255/// Returns (master_fd, child_pid, reader_handle, byte_rx) for swapping into an existing Pty.
1256fn respawn_child(
1257    shell: &str,
1258    rows: u16,
1259    cols: u16,
1260    pty_id: u16,
1261    command: Option<&str>,
1262    state: AppState,
1263) -> Option<(libc::c_int, libc::pid_t, std::thread::JoinHandle<()>, mpsc::Receiver<PtyInput>)> {
1264    let mut master: libc::c_int = 0;
1265    let mut slave: libc::c_int = 0;
1266    unsafe {
1267        if libc::openpty(
1268            &mut master,
1269            &mut slave,
1270            std::ptr::null_mut(),
1271            std::ptr::null_mut(),
1272            std::ptr::null_mut(),
1273        ) != 0
1274        {
1275            return None;
1276        }
1277        let ws = libc::winsize {
1278            ws_row: rows,
1279            ws_col: cols,
1280            ws_xpixel: 0,
1281            ws_ypixel: 0,
1282        };
1283        libc::ioctl(master, libc::TIOCSWINSZ, &ws);
1284    }
1285
1286    let pid = unsafe { libc::fork() };
1287    if pid < 0 {
1288        unsafe {
1289            libc::close(master);
1290            libc::close(slave);
1291        }
1292        return None;
1293    }
1294    if pid == 0 {
1295        unsafe {
1296            libc::close(master);
1297            libc::setsid();
1298            libc::ioctl(slave, libc::TIOCSCTTY as _, 0);
1299            libc::dup2(slave, 0);
1300            libc::dup2(slave, 1);
1301            libc::dup2(slave, 2);
1302            if slave > 2 {
1303                libc::close(slave);
1304            }
1305        }
1306        set_qos_user_interactive();
1307        std::env::set_var("TERM", "xterm-256color");
1308        std::env::set_var("COLORTERM", "truecolor");
1309        std::env::remove_var("COLUMNS");
1310        std::env::remove_var("LINES");
1311        for (key, _) in std::env::vars() {
1312            if key.starts_with("BLIT_") && key != "BLIT_HUB" && key != "BLIT_DISPLAY_FPS" {
1313                std::env::remove_var(&key);
1314            }
1315        }
1316        let shell_flags = &state.0.shell_flags;
1317        if let Some(cmd) = command {
1318            let shell_c = CString::new(shell).unwrap();
1319            let flag = CString::new(if shell_flags.is_empty() {
1320                "-c".to_owned()
1321            } else {
1322                format!("-{}c", shell_flags)
1323            })
1324            .unwrap();
1325            let cmd_c = CString::new(cmd).unwrap();
1326            unsafe {
1327                libc::execvp(
1328                    shell_c.as_ptr(),
1329                    [
1330                        shell_c.as_ptr(),
1331                        flag.as_ptr(),
1332                        cmd_c.as_ptr(),
1333                        std::ptr::null(),
1334                    ]
1335                    .as_ptr(),
1336                );
1337                libc::_exit(1);
1338            }
1339        }
1340        let shell_c = CString::new(shell).unwrap();
1341        unsafe {
1342            if shell_flags.is_empty() {
1343                let p = shell_c.as_ptr();
1344                libc::execvp(p, [p, std::ptr::null()].as_ptr());
1345            } else {
1346                let flag = CString::new(format!("-{}", shell_flags)).unwrap();
1347                let p = shell_c.as_ptr();
1348                let f = flag.as_ptr();
1349                libc::execvp(p, [p, f, std::ptr::null()].as_ptr());
1350            }
1351            libc::_exit(1);
1352        }
1353    }
1354
1355    unsafe {
1356        libc::close(slave);
1357        let flags = libc::fcntl(master, libc::F_GETFL);
1358        libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
1359    }
1360
1361    state.2.write().unwrap().insert(pty_id, master);
1362    let (byte_tx, byte_rx) = mpsc::channel(PTY_CHANNEL_CAPACITY);
1363    let reader_handle = std::thread::spawn({
1364        let notify = state.3.clone();
1365        move || pty_reader(master, byte_tx, notify)
1366    });
1367    Some((master, pid, reader_handle, byte_rx))
1368}
1369
1370fn respond_to_queries(fd: libc::c_int, data: &[u8], size: (u16, u16), cursor: (u16, u16)) {
1371    // VT420 with features matching xterm-256color capabilities.
1372    const DA1_RESPONSE: &[u8] = b"\x1b[?64;1;2;6;9;15;18;21;22c";
1373
1374    let mut i = 0;
1375    while i < data.len() {
1376        if data[i] != 0x1b || i + 2 >= data.len() || data[i + 1] != b'[' {
1377            i += 1;
1378            continue;
1379        }
1380        i += 2;
1381        let has_q = i < data.len() && data[i] == b'?';
1382        if has_q {
1383            i += 1;
1384        }
1385        let param_start = i;
1386        while i < data.len() && (data[i].is_ascii_digit() || data[i] == b';') {
1387            i += 1;
1388        }
1389        if i >= data.len() {
1390            break;
1391        }
1392        let final_byte = data[i];
1393        let params = &data[param_start..i];
1394        i += 1;
1395        if has_q {
1396            continue;
1397        }
1398        let resp: Option<String> = match final_byte {
1399            b'c' if params.is_empty() || params == b"0" => {
1400                Some(String::from_utf8_lossy(DA1_RESPONSE).into_owned())
1401            }
1402            b'n' if params == b"6" => Some(format!("\x1b[{};{}R", cursor.0 + 1, cursor.1 + 1)),
1403            b'n' if params == b"5" => Some("\x1b[0n".into()),
1404            b't' if params == b"18" => {
1405                let (rows, cols) = size;
1406                Some(format!("\x1b[8;{rows};{cols}t"))
1407            }
1408            b't' if params == b"14" => {
1409                let (rows, cols) = size;
1410                Some(format!("\x1b[4;{};{}t", rows * 16, cols * 8))
1411            }
1412            _ => None,
1413        };
1414        if let Some(r) = resp {
1415            pty_write_all(fd, r.as_bytes());
1416        }
1417    }
1418}
1419
1420fn pty_reader(
1421    fd: libc::c_int,
1422    tx: mpsc::Sender<PtyInput>,
1423    notify: Arc<Notify>,
1424) {
1425    // Use a dedicated OS thread with a plain blocking read() instead of
1426    // tokio's AsyncFd (kqueue/epoll). On macOS, registering a kqueue watcher
1427    // on the PTY master fd adds significant per-write overhead in the kernel's
1428    // TTY layer — every slave write triggers a kevent notification. A blocking
1429    // read in a dedicated thread avoids this entirely, matching what native
1430    // terminals like Ghostty do.
1431
1432    // Ensure the fd is in blocking mode.
1433    unsafe {
1434        let flags = libc::fcntl(fd, libc::F_GETFL);
1435        libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
1436    }
1437
1438    let mut buf = vec![0u8; 64 * 1024];
1439    let mut sync_scan_tail = Vec::new();
1440
1441    loop {
1442        let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) };
1443        if n > 0 {
1444            let data = buf[..n as usize].to_vec();
1445            let mut remaining = data;
1446            loop {
1447                if remaining.is_empty() {
1448                    break;
1449                }
1450                if let Some(boundary) = find_sync_output_end(&sync_scan_tail, &remaining) {
1451                    let before = remaining[..boundary].to_vec();
1452                    let after = remaining[boundary..].to_vec();
1453                    update_sync_scan_tail(&mut sync_scan_tail, &before);
1454                    if tx.blocking_send(PtyInput::SyncBoundary { before, after: after.clone() }).is_err() {
1455                        return;
1456                    }
1457                    notify.notify_one();
1458                    remaining = after;
1459                } else {
1460                    update_sync_scan_tail(&mut sync_scan_tail, &remaining);
1461                    if tx.blocking_send(PtyInput::Data(remaining)).is_err() {
1462                        return;
1463                    }
1464                    notify.notify_one();
1465                    break;
1466                }
1467            }
1468        } else {
1469            let _ = tx.blocking_send(PtyInput::Eof);
1470            notify.notify_one();
1471            return;
1472        }
1473    }
1474}
1475
1476/// Split accumulated bytes at sync-output boundaries and send through the channel.
1477async fn cleanup_pty(pty_id: u16, state: &AppState) {
1478    // Remove the fd so no more writes go to the closed master.
1479    state.2.write().unwrap().remove(&pty_id);
1480    let mut sess = state.1.lock().await;
1481    if let Some(pty) = sess.ptys.get_mut(&pty_id) {
1482        if pty.exited {
1483            return;
1484        }
1485        pty.exited = true;
1486
1487        unsafe {
1488            libc::kill(pty.child_pid, libc::SIGHUP);
1489            libc::close(pty.master_fd);
1490            let mut wstatus: libc::c_int = 0;
1491            if libc::waitpid(pty.child_pid, &mut wstatus, libc::WNOHANG) > 0 {
1492                if libc::WIFEXITED(wstatus) {
1493                    pty.exit_status = libc::WEXITSTATUS(wstatus);
1494                } else if libc::WIFSIGNALED(wstatus) {
1495                    pty.exit_status = -(libc::WTERMSIG(wstatus) as i32);
1496                }
1497            }
1498        }
1499        pty.mark_dirty();
1500        let msg = blit_remote::msg_exited(pty_id, pty.exit_status);
1501        sess.send_to_all(&msg);
1502    }
1503}
1504
1505fn pty_lflag(fd: libc::c_int) -> (bool, bool) {
1506    unsafe {
1507        let mut termios: libc::termios = std::mem::zeroed();
1508        if libc::tcgetattr(fd, &mut termios) == 0 {
1509            (
1510                termios.c_lflag & libc::ECHO != 0,
1511                termios.c_lflag & libc::ICANON != 0,
1512            )
1513        } else {
1514            (false, false)
1515        }
1516    }
1517}
1518
1519fn take_snapshot(pty: &mut Pty) -> FrameState {
1520    if pty.lflag_last.elapsed() >= Duration::from_millis(250) {
1521        pty.lflag_cache = pty_lflag(pty.master_fd);
1522        pty.lflag_last = Instant::now();
1523    }
1524    let (echo, icanon) = pty.lflag_cache;
1525    pty.driver.snapshot(echo, icanon)
1526}
1527
1528fn build_scrollback_update(
1529    pty: &mut Pty,
1530    id: u16,
1531    offset: usize,
1532    prev_frame: &FrameState,
1533) -> Option<(Vec<u8>, FrameState)> {
1534    let frame = pty.driver.scrollback_frame(offset);
1535    let msg = build_update_msg(id, &frame, prev_frame);
1536    msg.map(|m| (m, frame))
1537}
1538
1539fn build_search_results_msg(request_id: u16, results: &[SearchResultRow]) -> Vec<u8> {
1540    let count = results.len().min(u16::MAX as usize);
1541    let payload_bytes: usize = results[..count]
1542        .iter()
1543        .map(|result| 14 + result.context.len().min(u16::MAX as usize))
1544        .sum();
1545    let mut msg = Vec::with_capacity(5 + payload_bytes);
1546    msg.push(S2C_SEARCH_RESULTS);
1547    msg.extend_from_slice(&request_id.to_le_bytes());
1548    msg.extend_from_slice(&(count as u16).to_le_bytes());
1549    for result in &results[..count] {
1550        msg.extend_from_slice(&result.pty_id.to_le_bytes());
1551        msg.extend_from_slice(&result.score.to_le_bytes());
1552        msg.push(result.primary_source);
1553        msg.push(result.matched_sources);
1554        let scroll_offset = result
1555            .scroll_offset
1556            .map(|offset| offset.min(u32::MAX as usize - 1) as u32)
1557            .unwrap_or(u32::MAX);
1558        msg.extend_from_slice(&scroll_offset.to_le_bytes());
1559        let context = result.context.as_bytes();
1560        let context_len = context.len().min(u16::MAX as usize);
1561        msg.extend_from_slice(&(context_len as u16).to_le_bytes());
1562        msg.extend_from_slice(&context[..context_len]);
1563    }
1564    msg
1565}
1566
1567enum SendOutcome {
1568    NoChange,
1569    Sent,
1570    Backpressured,
1571}
1572
1573fn try_send_update(
1574    client: &mut ClientState,
1575    pid: u16,
1576    current: FrameState,
1577    msg: Option<Vec<u8>>,
1578    now: Instant,
1579    paced: bool,
1580) -> SendOutcome {
1581    let Some(msg) = msg else {
1582        return SendOutcome::NoChange;
1583    };
1584    let bytes = msg.len();
1585    if client.tx.try_send(msg).is_ok() {
1586        client.last_sent.insert(pid, current);
1587        record_send(client, bytes, now, paced);
1588        client.frames_sent = client.frames_sent.wrapping_add(1);
1589        SendOutcome::Sent
1590    } else {
1591        // Outbox full — the sender can't keep up.  Advance last_sent to
1592        // the current frame so the NEXT diff is small (only changes since
1593        // now), effectively dropping this intermediate state.  Without
1594        // this, backpressure causes the tick to re-dirty the PTY, building
1595        // ever-larger diffs that make the backlog worse.
1596        client.last_sent.insert(pid, current);
1597        SendOutcome::Backpressured
1598    }
1599}
1600
1601pub fn default_socket_path() -> String {
1602    if let Ok(dir) = std::env::var("TMPDIR") {
1603        return format!("{dir}/blit.sock");
1604    }
1605    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
1606        return format!("{dir}/blit.sock");
1607    }
1608    if let Ok(user) = std::env::var("USER") {
1609        return format!("/tmp/blit-{user}.sock");
1610    }
1611    "/tmp/blit.sock".into()
1612}
1613
1614
1615
1616enum RecvFdResult {
1617    Fd(RawFd),
1618    WouldBlock,
1619    Closed,
1620}
1621
1622fn recv_fd(channel: RawFd) -> RecvFdResult {
1623    unsafe {
1624        let mut buf = [0u8; 1];
1625        let mut iov = libc::iovec {
1626            iov_base: buf.as_mut_ptr() as *mut libc::c_void,
1627            iov_len: buf.len(),
1628        };
1629        let cmsg_space = libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) as usize;
1630        let mut cmsg_buf = vec![0u8; cmsg_space];
1631        let mut msg: libc::msghdr = std::mem::zeroed();
1632        msg.msg_iov = &mut iov;
1633        msg.msg_iovlen = 1;
1634        msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
1635        msg.msg_controllen = cmsg_space as _;
1636        let n = libc::recvmsg(channel, &mut msg, libc::MSG_DONTWAIT);
1637        if n < 0 {
1638            let err = std::io::Error::last_os_error();
1639            if err.kind() == std::io::ErrorKind::WouldBlock {
1640                return RecvFdResult::WouldBlock;
1641            }
1642            if err.raw_os_error() == Some(libc::EINTR) {
1643                return RecvFdResult::WouldBlock;
1644            }
1645            return RecvFdResult::Closed;
1646        }
1647        if n == 0 {
1648            return RecvFdResult::Closed;
1649        }
1650        let cmsg = libc::CMSG_FIRSTHDR(&msg);
1651        if cmsg.is_null() {
1652            return RecvFdResult::Closed;
1653        }
1654        if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS {
1655            let fd_ptr = libc::CMSG_DATA(cmsg) as *const RawFd;
1656            RecvFdResult::Fd(std::ptr::read_unaligned(fd_ptr))
1657        } else {
1658            RecvFdResult::Closed
1659        }
1660    }
1661}
1662
1663fn bind_socket(sock_path: &str, verbose: bool) -> UnixListener {
1664    let _ = std::fs::remove_file(sock_path);
1665    let listener = UnixListener::bind(sock_path).unwrap_or_else(|e| {
1666        eprintln!("blit-server: cannot bind to {sock_path}: {e}");
1667        std::process::exit(1);
1668    });
1669    if let Err(e) = std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o700)) {
1670        eprintln!("blit-server: warning: cannot set socket permissions: {e}");
1671    }
1672    if verbose {
1673        eprintln!("listening on {sock_path}");
1674    }
1675    listener
1676}
1677
1678pub async fn run(config: Config) {
1679    let state: AppState = Arc::new((
1680        config,
1681        Mutex::new(Session::new()),
1682        Arc::new(std::sync::RwLock::new(HashMap::new())),
1683        Arc::new(Notify::new()),
1684    ));
1685
1686    let delivery_state = state.clone();
1687    tokio::spawn(async move {
1688        let mut next_deadline: Option<Instant> = None;
1689        loop {
1690            if let Some(deadline) = next_deadline {
1691                tokio::select! {
1692                    _ = delivery_state.3.notified() => {}
1693                    _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {}
1694                }
1695            } else {
1696                delivery_state.3.notified().await;
1697            }
1698            loop {
1699                let outcome = tick(&delivery_state).await;
1700                next_deadline = outcome.next_deadline;
1701                if !outcome.did_work {
1702                    break;
1703                }
1704                tokio::task::yield_now().await;
1705            }
1706        }
1707    });
1708
1709    tokio::spawn(async {
1710        loop {
1711            tokio::time::sleep(Duration::from_secs(5)).await;
1712            unsafe { while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {} }
1713        }
1714    });
1715
1716    if let Some(channel_fd) = state.0.fd_channel {
1717        use std::os::unix::io::FromRawFd;
1718        if state.0.verbose {
1719            eprintln!("accepting clients via fd-channel (fd {channel_fd})");
1720        }
1721        let channel = unsafe { std::os::unix::net::UnixStream::from_raw_fd(channel_fd) };
1722        channel.set_nonblocking(true).unwrap();
1723        let async_channel = AsyncFd::new(channel).unwrap();
1724        loop {
1725            let mut guard = match async_channel.readable().await {
1726                Ok(g) => g,
1727                Err(e) => {
1728                    eprintln!("fd-channel error: {e}");
1729                    break;
1730                }
1731            };
1732            match recv_fd(channel_fd) {
1733                RecvFdResult::Fd(client_fd) => {
1734                    let std_stream =
1735                        unsafe { std::os::unix::net::UnixStream::from_raw_fd(client_fd) };
1736                    std_stream.set_nonblocking(true).unwrap();
1737                    let stream = tokio::net::UnixStream::from_std(std_stream).unwrap();
1738                    let state = state.clone();
1739                    tokio::spawn(handle_client(stream, state));
1740                    guard.retain_ready();
1741                }
1742                RecvFdResult::WouldBlock => {
1743                    guard.clear_ready();
1744                }
1745                RecvFdResult::Closed => {
1746                    break;
1747                }
1748            }
1749        }
1750        if state.0.verbose {
1751            eprintln!("fd-channel closed, shutting down");
1752        }
1753        return;
1754    }
1755
1756    // systemd socket activation: if LISTEN_FDS is set, use fd 3.
1757    // LISTEN_PID is checked but not required to match — some container runtimes
1758    // and service managers don't set it to the final process PID.
1759    let listener = if let Ok(fds) = std::env::var("LISTEN_FDS") {
1760        if fds.trim() == "1" {
1761            use std::os::unix::io::FromRawFd;
1762            let std_listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
1763            std_listener.set_nonblocking(true).unwrap();
1764            if state.0.verbose {
1765                eprintln!("using socket activation (fd 3)");
1766            }
1767            UnixListener::from_std(std_listener).unwrap()
1768        } else {
1769            if state.0.verbose {
1770                eprintln!("LISTEN_FDS={fds}, expected 1; falling back to bind");
1771            }
1772            bind_socket(&state.0.socket_path, state.0.verbose)
1773        }
1774    } else {
1775        bind_socket(&state.0.socket_path, state.0.verbose)
1776    };
1777
1778    loop {
1779        let (stream, _) = match listener.accept().await {
1780            Ok(conn) => conn,
1781            Err(e) => {
1782                eprintln!("accept error: {e}");
1783                tokio::time::sleep(Duration::from_millis(100)).await;
1784                continue;
1785            }
1786        };
1787        let state = state.clone();
1788        tokio::spawn(handle_client(stream, state));
1789    }
1790}
1791
1792async fn tick(state: &AppState) -> TickOutcome {
1793    let mut sess = state.1.lock().await;
1794    sess.tick_fires += 1;
1795    let mut did_work = false;
1796    let mut next_deadline: Option<Instant> = None;
1797    let now = Instant::now();
1798
1799    let max_fps = sess
1800        .clients
1801        .values()
1802        .map(browser_pacing_fps)
1803        .fold(1.0_f32, f32::max);
1804    let title_interval = Duration::from_secs_f64(1.0 / max_fps as f64);
1805    let ids: Vec<u16> = sess.ptys.keys().copied().collect();
1806    for &id in &ids {
1807        let Some(pty) = sess.ptys.get_mut(&id) else {
1808            continue;
1809        };
1810        if pty.driver.take_title_dirty() {
1811            pty.mark_dirty();
1812            pty.title_pending = true;
1813        }
1814        if pty.title_pending && now.duration_since(pty.last_title_send) >= title_interval {
1815            let msg = {
1816                let title_bytes = pty.driver.title().as_bytes();
1817                let mut msg = Vec::with_capacity(3 + title_bytes.len());
1818                msg.push(S2C_TITLE);
1819                msg.extend_from_slice(&id.to_le_bytes());
1820                msg.extend_from_slice(title_bytes);
1821                msg
1822            };
1823            pty.last_title_send = now;
1824            pty.title_pending = false;
1825            sess.send_to_all(&msg);
1826            did_work = true;
1827        }
1828    }
1829
1830    // Drain bytes from PTY reader channels. This is the only place
1831    // process() is called, so there is no contention with the readers.
1832    let mut eof_ptys: Vec<u16> = Vec::new();
1833    for &id in &ids {
1834        let Some(pty) = sess.ptys.get_mut(&id) else {
1835            continue;
1836        };
1837        while let Ok(input) = pty.byte_rx.try_recv() {
1838            match input {
1839                PtyInput::Data(data) => {
1840                    respond_to_queries(
1841                        pty.master_fd,
1842                        &data,
1843                        pty.driver.size(),
1844                        pty.driver.cursor_position(),
1845                    );
1846                    pty.driver.process(&data);
1847                    pty.mark_dirty();
1848                    did_work = true;
1849                }
1850                PtyInput::SyncBoundary { before, after } => {
1851                    if !before.is_empty() {
1852                        respond_to_queries(
1853                            pty.master_fd,
1854                            &before,
1855                            pty.driver.size(),
1856                            pty.driver.cursor_position(),
1857                        );
1858                        pty.driver.process(&before);
1859                        pty.mark_dirty();
1860                    }
1861                    if !pty.driver.synced_output() {
1862                        let frame = take_snapshot(pty);
1863                        enqueue_ready_frame(&mut pty.ready_frames, frame);
1864                        pty.clear_dirty();
1865                    }
1866                    if !after.is_empty() {
1867                        respond_to_queries(
1868                            pty.master_fd,
1869                            &after,
1870                            pty.driver.size(),
1871                            pty.driver.cursor_position(),
1872                        );
1873                        pty.driver.process(&after);
1874                        pty.mark_dirty();
1875                    }
1876                    did_work = true;
1877                }
1878                PtyInput::Eof => {
1879                    eof_ptys.push(id);
1880                }
1881            }
1882        }
1883    }
1884    // Handle EOF outside the borrow loop.
1885    drop(sess);
1886    for id in eof_ptys {
1887        tokio::time::sleep(Duration::from_millis(50)).await;
1888        cleanup_pty(id, state).await;
1889    }
1890    let mut sess = state.1.lock().await;
1891
1892    // Only snapshot PTYs that have at least one client ready to consume a fresh
1893    // frame right now. This avoids burning CPU on snapshot+diff+compress work
1894    // while the lead is merely waiting for its next pacing deadline.
1895    let needful_ptys: HashSet<u16> = sess
1896        .clients
1897        .values()
1898        .flat_map(|c| {
1899            let reserve_preview_slot = client_has_due_preview(&sess, c, now);
1900            c.subscriptions.iter().copied().filter(move |pid| {
1901                let scrolled = c.scroll_offsets.get(pid).copied().unwrap_or(0) > 0;
1902                if Some(*pid) == c.lead {
1903                    !scrolled && can_send_frame(c, now, reserve_preview_slot)
1904                } else {
1905                    !scrolled && can_send_preview(c, *pid, now)
1906                }
1907            })
1908        })
1909        .collect();
1910
1911    let mut snapshots: HashMap<u16, FrameState> = HashMap::new();
1912    for &id in &ids {
1913        let Some(pty) = sess.ptys.get_mut(&id) else {
1914            continue;
1915        };
1916        if needful_ptys.contains(&id) {
1917            if let Some(frame) = pty.ready_frames.pop_front() {
1918                snapshots.insert(id, frame);
1919                sess.tick_snaps += 1;
1920                did_work = true;
1921                continue;
1922            }
1923        }
1924        if !should_snapshot_pty(
1925            pty.dirty,
1926            needful_ptys.contains(&id),
1927            pty.driver.synced_output(),
1928        ) {
1929            continue;
1930        }
1931        // Applications that care about complete-frame boundaries should
1932        // use DEC synchronized output (?2026). Outside that bracket we
1933        // snapshot immediately instead of heuristically coalescing reads.
1934        snapshots.insert(id, take_snapshot(pty));
1935        pty.clear_dirty();
1936        sess.tick_snaps += 1;
1937        did_work = true;
1938    }
1939
1940    let client_ids: Vec<u64> = sess.clients.keys().copied().collect();
1941    for cid in client_ids {
1942        // When the pipe is idle (nothing in flight), RTT cannot be measured
1943        // and the last observed value stales.  Decay it toward min_rtt so
1944        // a stale congested RTT doesn't permanently suppress the send window
1945        // after congestion clears or traffic patterns change (e.g. switching
1946        // from a large-frame burst to idle small-frame updates).
1947        if let Some(c) = sess.clients.get_mut(&cid) {
1948            if c.inflight_bytes == 0 && c.min_rtt_ms > 0.0 && c.rtt_ms > c.min_rtt_ms {
1949                c.rtt_ms = (c.rtt_ms * 0.99 + c.min_rtt_ms * 0.01).max(c.min_rtt_ms);
1950            }
1951            // Decay stale browser metrics so a missed/delayed metrics update
1952            // can't permanently block the delivery loop.
1953            if c.last_metrics_update.elapsed() > Duration::from_secs(1) {
1954                c.browser_backlog_frames = 0;
1955                c.browser_ack_ahead_frames = 0;
1956            }
1957        }
1958        let (
1959            lead,
1960            subscriptions,
1961            scrolled_ptys,
1962            can_send_lead,
1963            lead_has_window,
1964            any_send_window,
1965            lead_deadline,
1966        ) = {
1967            let Some(c) = sess.clients.get(&cid) else {
1968                continue;
1969            };
1970            let reserve_preview_slot = client_has_due_preview(&sess, c, now);
1971            (
1972                c.lead,
1973                c.subscriptions.iter().copied().collect::<Vec<_>>(),
1974                c.scroll_offsets.iter().map(|(&k, &v)| (k, v)).collect::<Vec<_>>(),
1975                can_send_frame(c, now, reserve_preview_slot),
1976                lead_window_open(c, reserve_preview_slot),
1977                lead_window_open(c, reserve_preview_slot) || window_open(c),
1978                c.next_send_at,
1979            )
1980        };
1981
1982        if subscriptions.is_empty() {
1983            continue;
1984        }
1985
1986        // Send scrollback frames for any scrolled PTY.
1987        for &(scroll_pid, scroll_offset) in &scrolled_ptys {
1988            if scroll_offset == 0 { continue; }
1989            let is_lead = lead == Some(scroll_pid);
1990            let can_send = if is_lead { can_send_lead } else { true };
1991            if can_send {
1992                let prev_frame = {
1993                    let Some(c) = sess.clients.get(&cid) else {
1994                        continue;
1995                    };
1996                    c.scroll_caches.get(&scroll_pid).cloned().unwrap_or_default()
1997                };
1998                let outcome = if let Some(pty) = sess.ptys.get_mut(&scroll_pid) {
1999                    if let Some((msg, new_frame)) =
2000                        build_scrollback_update(pty, scroll_pid, scroll_offset, &prev_frame)
2001                    {
2002                        let Some(c) = sess.clients.get_mut(&cid) else {
2003                            break;
2004                        };
2005                        let bytes = msg.len();
2006                        if c.tx.try_send(msg).is_ok() {
2007                            c.scroll_caches.insert(scroll_pid, new_frame);
2008                            record_send(c, bytes, now, is_lead);
2009                            c.frames_sent += 1;
2010                            SendOutcome::Sent
2011                        } else {
2012                            SendOutcome::Backpressured
2013                        }
2014                    } else {
2015                        SendOutcome::NoChange
2016                    }
2017                } else {
2018                    SendOutcome::NoChange
2019                };
2020                match outcome {
2021                    SendOutcome::Sent => did_work = true,
2022                    SendOutcome::Backpressured => {
2023                        if let Some(pty) = sess.ptys.get_mut(&scroll_pid) {
2024                            pty.mark_dirty();
2025                        }
2026                    }
2027                    SendOutcome::NoChange => {}
2028                }
2029            } else if is_lead && lead_has_window {
2030                next_deadline = Some(match next_deadline {
2031                    Some(existing) => existing.min(lead_deadline),
2032                    None => lead_deadline,
2033                });
2034            }
2035        }
2036
2037        let lead_scroll_offset = lead.and_then(|pid| scrolled_ptys.iter().find(|&&(k, _)| k == pid).map(|&(_, v)| v)).unwrap_or(0);
2038
2039        if let Some(pid) = lead {
2040            if lead_scroll_offset == 0 && can_send_lead {
2041                if let Some(cur) = snapshots.get(&pid).cloned() {
2042                    let previous = sess
2043                        .clients
2044                        .get(&cid)
2045                        .and_then(|c| c.last_sent.get(&pid).cloned())
2046                        .unwrap_or_default();
2047                    drop(sess);
2048                    let msg = build_update_msg(pid, &cur, &previous);
2049                    sess = state.1.lock().await;
2050                    let Some(c) = sess.clients.get_mut(&cid) else {
2051                        continue;
2052                    };
2053                    match try_send_update(c, pid, cur, msg, now, true) {
2054                        SendOutcome::Sent => did_work = true,
2055                        SendOutcome::Backpressured => {
2056                            if let Some(pty) = sess.ptys.get_mut(&pid) {
2057                                pty.mark_dirty();
2058                            }
2059                        }
2060                        SendOutcome::NoChange => {}
2061                    }
2062                } else {
2063                    let has_pending = sess
2064                        .ptys
2065                        .get(&pid)
2066                        .map(pty_has_visual_update)
2067                        .unwrap_or(false);
2068                    let _ = has_pending;
2069                }
2070            } else {
2071                let has_pending = sess
2072                    .ptys
2073                    .get(&pid)
2074                    .map(pty_has_visual_update)
2075                    .unwrap_or(false);
2076                if has_pending && lead_has_window {
2077                    next_deadline = Some(match next_deadline {
2078                        Some(existing) => existing.min(lead_deadline),
2079                        None => lead_deadline,
2080                    });
2081                }
2082            }
2083        }
2084
2085        if !any_send_window {
2086            continue;
2087        }
2088
2089        let mut preview_ids = subscriptions;
2090        preview_ids.retain(|pid| Some(*pid) != lead);
2091        preview_ids.sort_unstable();
2092
2093        for pid in preview_ids {
2094            let (preview_can_send, preview_due_at, preview_has_window) =
2095                match sess.clients.get(&cid) {
2096                    Some(c) => (
2097                        can_send_preview(c, pid, now),
2098                        preview_deadline(c, pid, now),
2099                        window_open(c),
2100                    ),
2101                    None => (false, now, false),
2102                };
2103            if !preview_has_window {
2104                break;
2105            }
2106            if !preview_can_send {
2107                let has_pending = sess
2108                    .ptys
2109                    .get(&pid)
2110                    .map(pty_has_visual_update)
2111                    .unwrap_or(false);
2112                // Only set a deadline when the reason is *timing* (deadline
2113                // in the future), not capacity (preview window closed).
2114                // A past deadline here spins the delivery loop because
2115                // sleep_until(past) returns immediately.
2116                if has_pending && preview_due_at > now {
2117                    next_deadline = Some(match next_deadline {
2118                        Some(existing) => existing.min(preview_due_at),
2119                        None => preview_due_at,
2120                    });
2121                }
2122                continue;
2123            }
2124            let Some(cur) = snapshots.get(&pid) else {
2125                let has_pending = sess
2126                    .ptys
2127                    .get(&pid)
2128                    .map(pty_has_visual_update)
2129                    .unwrap_or(false);
2130                let _ = has_pending;
2131                continue;
2132            };
2133            let cur = cur.clone();
2134            let previous = sess
2135                .clients
2136                .get(&cid)
2137                .and_then(|c| c.last_sent.get(&pid).cloned())
2138                .unwrap_or_default();
2139            drop(sess);
2140            let msg = build_update_msg(pid, &cur, &previous);
2141            sess = state.1.lock().await;
2142            let Some(c) = sess.clients.get_mut(&cid) else {
2143                break;
2144            };
2145            match try_send_update(c, pid, cur, msg, now, false) {
2146                SendOutcome::Sent => {
2147                    record_preview_send(c, pid, now);
2148                    did_work = true;
2149                }
2150                SendOutcome::Backpressured => {
2151                    if let Some(pty) = sess.ptys.get_mut(&pid) {
2152                        pty.mark_dirty();
2153                    }
2154                    break;
2155                }
2156                SendOutcome::NoChange => {}
2157            }
2158        }
2159    }
2160
2161    TickOutcome {
2162        did_work,
2163        next_deadline,
2164    }
2165}
2166
2167async fn handle_client(stream: tokio::net::UnixStream, state: AppState) {
2168    let config = &state.0;
2169    let (mut reader, mut writer) = stream.into_split();
2170
2171    let (out_tx, mut out_rx) = mpsc::channel::<Vec<u8>>(OUTBOX_CAPACITY);
2172    let sender = tokio::spawn(async move {
2173        while let Some(msg) = out_rx.recv().await {
2174            if !write_frame(&mut writer, &msg).await {
2175                break;
2176            }
2177        }
2178    });
2179    let client_id;
2180
2181    {
2182        let mut sess = state.1.lock().await;
2183        client_id = sess.next_client_id;
2184        sess.next_client_id += 1;
2185        sess.clients.insert(
2186            client_id,
2187            ClientState {
2188                tx: out_tx,
2189                lead: None,
2190                subscriptions: HashSet::new(),
2191                view_sizes: HashMap::new(),
2192                scroll_offsets: HashMap::new(),
2193                scroll_caches: HashMap::new(),
2194                last_sent: HashMap::new(),
2195                preview_next_send_at: HashMap::new(),
2196                rtt_ms: 50.0,
2197                min_rtt_ms: 0.0,
2198                display_fps: 60.0,
2199                // Conservative seed — the rise alpha (0.5) converges up to
2200                // multi-MB/s in a handful of samples on low-latency paths. Starting
2201                // high causes catastrophic bufferbloat on slow links because
2202                // target_byte_window scales with the goodput estimate.
2203                delivery_bps: 262_144.0,
2204                goodput_bps: 262_144.0,
2205                goodput_jitter_bps: 0.0,
2206                max_goodput_jitter_bps: 0.0,
2207                last_goodput_sample_bps: 0.0,
2208                avg_frame_bytes: 1_024.0,
2209                avg_paced_frame_bytes: 1_024.0,
2210                avg_preview_frame_bytes: 1_024.0,
2211                inflight_bytes: 0,
2212                inflight_frames: VecDeque::new(),
2213                next_send_at: Instant::now(),
2214                probe_frames: 0.0,
2215                frames_sent: 0,
2216                acks_recv: 0,
2217                acked_bytes_since_log: 0,
2218                browser_backlog_frames: 0,
2219                browser_ack_ahead_frames: 0,
2220                browser_apply_ms: 0.0,
2221                last_metrics_update: Instant::now(),
2222                last_log: Instant::now(),
2223                goodput_window_bytes: 0,
2224                goodput_window_start: Instant::now(),
2225            },
2226        );
2227        if let Some(c) = sess.clients.get(&client_id) {
2228            let _ = c.tx.try_send(msg_hello(
2229                1,
2230                FEATURE_CREATE_NONCE | FEATURE_RESTART | FEATURE_RESIZE_BATCH | FEATURE_COPY_RANGE,
2231            ));
2232        }
2233        let mut initial_msgs = Vec::new();
2234        initial_msgs.push(sess.pty_list_msg());
2235        for (&id, pty) in &sess.ptys {
2236            let title = pty.driver.title();
2237            if !title.is_empty() {
2238                let title_bytes = title.as_bytes();
2239                let mut msg = Vec::with_capacity(3 + title_bytes.len());
2240                msg.push(S2C_TITLE);
2241                msg.extend_from_slice(&id.to_le_bytes());
2242                msg.extend_from_slice(title_bytes);
2243                initial_msgs.push(msg);
2244            }
2245            if pty.exited {
2246                initial_msgs.push(blit_remote::msg_exited(id, pty.exit_status));
2247            }
2248        }
2249        initial_msgs.push(vec![S2C_READY]);
2250        let tx = sess.clients.get(&client_id).map(|c| c.tx.clone());
2251        drop(sess);
2252        if let Some(tx) = tx {
2253            for msg in initial_msgs {
2254                if tx.send(msg).await.is_err() {
2255                    break;
2256                }
2257            }
2258        }
2259    }
2260
2261    if state.0.verbose {
2262        eprintln!("client connected");
2263    }
2264
2265    while let Some(data) = read_frame(&mut reader).await {
2266        if data.is_empty() {
2267            continue;
2268        }
2269
2270        if data[0] == C2S_ACK {
2271            let mut sess = state.1.lock().await;
2272            let (
2273                do_log,
2274                frames_sent,
2275                acks_recv,
2276                rtt_ms,
2277                min_rtt_ms,
2278                eff_rtt_ms,
2279                inflight_bytes,
2280                delivery_bps,
2281                goodput_ewma_bps,
2282                goodput_jitter_bps,
2283                max_goodput_jitter_bps,
2284                avg_frame_bytes,
2285                avg_paced_frame_bytes,
2286                avg_preview_frame_bytes,
2287                display_fps,
2288                paced_fps,
2289                display_need_bps,
2290                probe_frames,
2291                goodput_bps,
2292                window_frames,
2293                window_bytes,
2294                outbox_frames,
2295                browser_backlog_frames,
2296                browser_ack_ahead_frames,
2297                browser_apply_ms,
2298            ) = {
2299                let Some(c) = sess.clients.get_mut(&client_id) else {
2300                    continue;
2301                };
2302                c.acks_recv += 1;
2303                record_ack(c);
2304                let do_log = c.last_log.elapsed().as_secs_f32() >= 1.0;
2305                let log_elapsed = c.last_log.elapsed().as_secs_f32().max(1.0e-3);
2306                let paced_fps = pacing_fps(c);
2307                let display_need_bps = display_need_bps(c);
2308                let out = (
2309                    do_log,
2310                    c.frames_sent,
2311                    c.acks_recv,
2312                    c.rtt_ms,
2313                    path_rtt_ms(c),
2314                    window_rtt_ms(c),
2315                    c.inflight_bytes,
2316                    c.delivery_bps,
2317                    c.goodput_bps,
2318                    c.goodput_jitter_bps,
2319                    c.max_goodput_jitter_bps,
2320                    c.avg_frame_bytes,
2321                    c.avg_paced_frame_bytes,
2322                    c.avg_preview_frame_bytes,
2323                    c.display_fps,
2324                    paced_fps,
2325                    display_need_bps,
2326                    c.probe_frames,
2327                    c.acked_bytes_since_log as f32 / log_elapsed,
2328                    target_frame_window(c),
2329                    target_byte_window(c),
2330                    outbox_queued_frames(c),
2331                    c.browser_backlog_frames,
2332                    c.browser_ack_ahead_frames,
2333                    c.browser_apply_ms,
2334                );
2335                if do_log {
2336                    c.frames_sent = 0;
2337                    c.acks_recv = 0;
2338                    c.acked_bytes_since_log = 0;
2339                    c.last_log = Instant::now();
2340                }
2341                out
2342            };
2343            if do_log && config.verbose {
2344                eprintln!(
2345                    "client {client_id}: sent={frames_sent} acks={acks_recv} rtt={rtt_ms:.0}ms min_rtt={min_rtt_ms:.0}ms eff_rtt={eff_rtt_ms:.0}ms window={window_frames}f/{window_bytes}B probe={probe_frames:.0}f inflight={inflight_bytes}B outbox={outbox_frames}f goodput={goodput_bps:.0}B/s goodput_ewma={goodput_ewma_bps:.0}B/s jitter={goodput_jitter_bps:.0}/{max_goodput_jitter_bps:.0}B/s rate={delivery_bps:.0}B/s avg_frame={avg_frame_bytes:.0}B lead_frame={avg_paced_frame_bytes:.0}B preview_frame={avg_preview_frame_bytes:.0}B need={display_need_bps:.0}B/s display_fps={display_fps:.0} paced_fps={paced_fps:.0} backlog={browser_backlog_frames} ack_ahead={browser_ack_ahead_frames} apply={browser_apply_ms:.1}ms | tick_fires={} tick_snaps={}",
2346                    sess.tick_fires, sess.tick_snaps,
2347                );
2348            }
2349            if do_log {
2350                sess.tick_fires = 0;
2351                sess.tick_snaps = 0;
2352            }
2353            nudge_delivery(&state);
2354            continue;
2355        }
2356
2357        if data[0] == C2S_DISPLAY_RATE && data.len() >= 3 {
2358            let fps = u16::from_le_bytes([data[1], data[2]]) as f32;
2359            if fps > 0.0 {
2360                let mut sess = state.1.lock().await;
2361                if let Some(c) = sess.clients.get_mut(&client_id) {
2362                    c.display_fps = fps;
2363                }
2364            }
2365            nudge_delivery(&state);
2366            continue;
2367        }
2368
2369        if data[0] == C2S_CLIENT_METRICS && data.len() >= 7 {
2370            let backlog_frames = u16::from_le_bytes([data[1], data[2]]);
2371            let ack_ahead_frames = u16::from_le_bytes([data[3], data[4]]);
2372            let apply_ms = u16::from_le_bytes([data[5], data[6]]) as f32 * 0.1;
2373            let mut sess = state.1.lock().await;
2374            if let Some(c) = sess.clients.get_mut(&client_id) {
2375                c.browser_backlog_frames = backlog_frames;
2376                c.browser_ack_ahead_frames = ack_ahead_frames;
2377                c.browser_apply_ms = apply_ms;
2378                c.last_metrics_update = Instant::now();
2379            }
2380            nudge_delivery(&state);
2381            continue;
2382        }
2383
2384        // Server-side mouse: client sends structured mouse data, server generates
2385        // the correct escape sequence using the terminal's current mouse mode/encoding.
2386        if data[0] == C2S_MOUSE && data.len() >= 9 {
2387            let pid = u16::from_le_bytes([data[1], data[2]]);
2388            let type_ = data[3];
2389            let button = data[4];
2390            let col = u16::from_le_bytes([data[5], data[6]]);
2391            let row = u16::from_le_bytes([data[7], data[8]]);
2392            let sess = state.1.lock().await;
2393            if let Some(pty) = sess.ptys.get(&pid) {
2394                let (echo, icanon) = pty.lflag_cache;
2395                if let Some(seq) = pty
2396                    .driver
2397                    .mouse_event(type_, button, col, row, echo, icanon)
2398                {
2399                    if let Some(&fd) = state.2.read().unwrap().get(&pid) {
2400                        pty_write_all(fd, &seq);
2401                    }
2402                }
2403            }
2404            continue;
2405        }
2406
2407        if data[0] == C2S_INPUT && data.len() >= 3 {
2408            let pid = u16::from_le_bytes([data[1], data[2]]);
2409            let mut need_nudge = false;
2410            {
2411                let mut sess = state.1.lock().await;
2412                if let Some(c) = sess.clients.get_mut(&client_id) {
2413                    if update_client_scroll_state(c, pid, 0) {
2414                        if let Some(pty) = sess.ptys.get_mut(&pid) {
2415                            pty.mark_dirty();
2416                            need_nudge = true;
2417                        }
2418                    }
2419                }
2420            }
2421            if need_nudge {
2422                nudge_delivery(&state);
2423            }
2424            if let Some(&fd) = state.2.read().unwrap().get(&pid) {
2425                pty_write_all(fd, &data[3..]);
2426            }
2427            continue;
2428        }
2429
2430        if data[0] == C2S_SEARCH && data.len() >= 3 {
2431            let request_id = u16::from_le_bytes([data[1], data[2]]);
2432            let query = std::str::from_utf8(&data[3..]).unwrap_or("").trim();
2433            let mut sess = state.1.lock().await;
2434            let lead = sess.clients.get(&client_id).and_then(|c| c.lead);
2435            let mut ranked: Vec<SearchResultRow> = if query.is_empty() {
2436                Vec::new()
2437            } else {
2438                sess.ptys
2439                    .iter()
2440                    .filter_map(|(&pty_id, pty)| {
2441                        pty.driver
2442                            .search_result(query)
2443                            .map(|result| SearchResultRow {
2444                                pty_id,
2445                                score: result.score,
2446                                primary_source: result.primary_source,
2447                                matched_sources: result.matched_sources,
2448                                context: result.context,
2449                                scroll_offset: result.scroll_offset,
2450                            })
2451                    })
2452                    .collect()
2453            };
2454            ranked.sort_by(|a, b| {
2455                b.score
2456                    .cmp(&a.score)
2457                    .then_with(|| (Some(b.pty_id) == lead).cmp(&(Some(a.pty_id) == lead)))
2458                    .then_with(|| a.pty_id.cmp(&b.pty_id))
2459            });
2460            if let Some(client) = sess.clients.get_mut(&client_id) {
2461                let _ = client
2462                    .tx
2463                    .try_send(build_search_results_msg(request_id, &ranked));
2464            }
2465            continue;
2466        }
2467
2468        let mut sess = state.1.lock().await;
2469        let mut need_nudge = false;
2470        match data[0] {
2471            C2S_SCROLL if data.len() >= 7 => {
2472                let pid = u16::from_le_bytes([data[1], data[2]]);
2473                let offset = u32::from_le_bytes([data[3], data[4], data[5], data[6]]) as usize;
2474                if sess.ptys.contains_key(&pid) {
2475                    if let Some(c) = sess.clients.get_mut(&client_id) {
2476                        update_client_scroll_state(c, pid, offset);
2477                    }
2478                    if let Some(pty) = sess.ptys.get_mut(&pid) {
2479                        pty.mark_dirty();
2480                        need_nudge = true;
2481                    }
2482                }
2483            }
2484            C2S_RESIZE if data.len() >= 7 => {
2485                let entries = data[1..].chunks_exact(6);
2486                if !entries.remainder().is_empty() {
2487                    continue;
2488                }
2489                let mut touched = Vec::new();
2490                for entry in entries {
2491                    let pid = u16::from_le_bytes([entry[0], entry[1]]);
2492                    if !sess.ptys.contains_key(&pid) {
2493                        continue;
2494                    }
2495                    let rows = u16::from_le_bytes([entry[2], entry[3]]);
2496                    let cols = u16::from_le_bytes([entry[4], entry[5]]);
2497                    if let Some(c) = sess.clients.get_mut(&client_id) {
2498                        if is_unset_view_size(rows, cols) {
2499                            if c.view_sizes.remove(&pid).is_some() {
2500                                touched.push(pid);
2501                            }
2502                        } else if rows == 0 || cols == 0 {
2503                            continue;
2504                        } else {
2505                            c.view_sizes.insert(pid, (rows, cols));
2506                            touched.push(pid);
2507                        }
2508                    }
2509                }
2510                if sess.resize_ptys_to_mediated_sizes(touched) {
2511                    need_nudge = true;
2512                }
2513            }
2514            C2S_CREATE => {
2515                // Format: [opcode][rows:2][cols:2][tag_len:2][tag:N][command...]
2516                let (rows, cols) = if data.len() >= 5 {
2517                    (
2518                        u16::from_le_bytes([data[1], data[2]]),
2519                        u16::from_le_bytes([data[3], data[4]]),
2520                    )
2521                } else {
2522                    (24, 80)
2523                };
2524                let tag_len = if data.len() >= 7 {
2525                    u16::from_le_bytes([data[5], data[6]]) as usize
2526                } else {
2527                    0
2528                };
2529                let tag = if data.len() >= 7 + tag_len {
2530                    std::str::from_utf8(&data[7..7 + tag_len]).unwrap_or_default()
2531                } else {
2532                    ""
2533                };
2534                let cmd_start = 7 + tag_len;
2535                let dir: Option<String> = None;
2536                let create_payload = data
2537                    .get(cmd_start..)
2538                    .and_then(|bytes| std::str::from_utf8(bytes).ok());
2539                let command = create_payload
2540                    .filter(|payload| !payload.contains('\0'))
2541                    .map(str::trim)
2542                    .filter(|payload| !payload.is_empty());
2543                let argv: Option<Vec<&str>> = create_payload
2544                    .filter(|payload| payload.contains('\0'))
2545                    .map(|payload| {
2546                        payload
2547                            .split('\0')
2548                            .filter(|arg| !arg.is_empty())
2549                            .collect::<Vec<_>>()
2550                    })
2551                    .filter(|args| !args.is_empty());
2552                let Some(id) = sess.allocate_pty_id() else {
2553                    continue;
2554                };
2555                if let Some(pty) = spawn_pty(
2556                    &config.shell,
2557                    rows,
2558                    cols,
2559                    id,
2560                    tag,
2561                    command,
2562                    argv.as_deref(),
2563                    dir.as_deref(),
2564                    config.scrollback,
2565                    state.clone(),
2566                ) {
2567                    let mut msg = Vec::with_capacity(3 + pty.tag.len());
2568                    msg.push(S2C_CREATED);
2569                    msg.extend_from_slice(&id.to_le_bytes());
2570                    msg.extend_from_slice(pty.tag.as_bytes());
2571                    sess.ptys.insert(id, pty);
2572                    if let Some(c) = sess.clients.get_mut(&client_id) {
2573                        c.lead = Some(id);
2574                        c.view_sizes.insert(id, (rows, cols));
2575                        subscribe_client_to(c, id);
2576                        // Per-PTY scroll: no blanket reset needed.
2577                        reset_inflight(c);
2578                    }
2579                    sess.send_to_all(&msg);
2580                    need_nudge = true;
2581                }
2582            }
2583            C2S_CREATE_N => {
2584                // Format: [opcode][nonce:2][rows:2][cols:2][tag_len:2][tag:N][command...]
2585                let nonce = if data.len() >= 3 {
2586                    u16::from_le_bytes([data[1], data[2]])
2587                } else {
2588                    0
2589                };
2590                let (rows, cols) = if data.len() >= 7 {
2591                    (
2592                        u16::from_le_bytes([data[3], data[4]]),
2593                        u16::from_le_bytes([data[5], data[6]]),
2594                    )
2595                } else {
2596                    (24, 80)
2597                };
2598                let tag_len = if data.len() >= 9 {
2599                    u16::from_le_bytes([data[7], data[8]]) as usize
2600                } else {
2601                    0
2602                };
2603                let tag = if data.len() >= 9 + tag_len {
2604                    std::str::from_utf8(&data[9..9 + tag_len]).unwrap_or_default()
2605                } else {
2606                    ""
2607                };
2608                let cmd_start = 9 + tag_len;
2609                let dir: Option<String> = None;
2610                let create_payload = data
2611                    .get(cmd_start..)
2612                    .and_then(|bytes| std::str::from_utf8(bytes).ok());
2613                let command = create_payload
2614                    .filter(|payload| !payload.contains('\0'))
2615                    .map(str::trim)
2616                    .filter(|payload| !payload.is_empty());
2617                let argv: Option<Vec<&str>> = create_payload
2618                    .filter(|payload| payload.contains('\0'))
2619                    .map(|payload| {
2620                        payload
2621                            .split('\0')
2622                            .filter(|arg| !arg.is_empty())
2623                            .collect::<Vec<_>>()
2624                    })
2625                    .filter(|args| !args.is_empty());
2626                let Some(id) = sess.allocate_pty_id() else {
2627                    continue;
2628                };
2629                if let Some(pty) = spawn_pty(
2630                    &config.shell,
2631                    rows,
2632                    cols,
2633                    id,
2634                    tag,
2635                    command,
2636                    argv.as_deref(),
2637                    dir.as_deref(),
2638                    config.scrollback,
2639                    state.clone(),
2640                ) {
2641                    let tag_bytes = pty.tag.as_bytes();
2642                    let mut nonce_msg = Vec::with_capacity(5 + tag_bytes.len());
2643                    nonce_msg.push(S2C_CREATED_N);
2644                    nonce_msg.extend_from_slice(&nonce.to_le_bytes());
2645                    nonce_msg.extend_from_slice(&id.to_le_bytes());
2646                    nonce_msg.extend_from_slice(tag_bytes);
2647                    let mut broadcast_msg = Vec::with_capacity(3 + tag_bytes.len());
2648                    broadcast_msg.push(S2C_CREATED);
2649                    broadcast_msg.extend_from_slice(&id.to_le_bytes());
2650                    broadcast_msg.extend_from_slice(tag_bytes);
2651                    sess.ptys.insert(id, pty);
2652                    if let Some(c) = sess.clients.get_mut(&client_id) {
2653                        c.lead = Some(id);
2654                        c.view_sizes.insert(id, (rows, cols));
2655                        subscribe_client_to(c, id);
2656                        // Per-PTY scroll: no blanket reset needed.
2657                        reset_inflight(c);
2658                        let _ = c.tx.try_send(nonce_msg);
2659                    }
2660                    for (&cid, c) in sess.clients.iter() {
2661                        if cid != client_id {
2662                            let _ = c.tx.try_send(broadcast_msg.clone());
2663                        }
2664                    }
2665                    need_nudge = true;
2666                }
2667            }
2668            C2S_CREATE_AT => {
2669                // Format: [opcode][rows:2][cols:2][tag_len:2][tag:N][src_pty_id:2]
2670                let (rows, cols) = if data.len() >= 5 {
2671                    (
2672                        u16::from_le_bytes([data[1], data[2]]),
2673                        u16::from_le_bytes([data[3], data[4]]),
2674                    )
2675                } else {
2676                    (24, 80)
2677                };
2678                let tag_len = if data.len() >= 7 {
2679                    u16::from_le_bytes([data[5], data[6]]) as usize
2680                } else {
2681                    0
2682                };
2683                let tag = if data.len() >= 7 + tag_len {
2684                    std::str::from_utf8(&data[7..7 + tag_len]).unwrap_or_default()
2685                } else {
2686                    ""
2687                };
2688                let src_start = 7 + tag_len;
2689                let dir = if data.len() >= src_start + 2 {
2690                    let src_id = u16::from_le_bytes([data[src_start], data[src_start + 1]]);
2691                    sess.ptys.get(&src_id).and_then(|p| pty_cwd(p.child_pid))
2692                } else {
2693                    None
2694                };
2695                let Some(id) = sess.allocate_pty_id() else {
2696                    continue;
2697                };
2698                if let Some(pty) = spawn_pty(
2699                    &config.shell,
2700                    rows,
2701                    cols,
2702                    id,
2703                    tag,
2704                    None,
2705                    None,
2706                    dir.as_deref(),
2707                    config.scrollback,
2708                    state.clone(),
2709                ) {
2710                    let mut msg = Vec::with_capacity(3 + pty.tag.len());
2711                    msg.push(S2C_CREATED);
2712                    msg.extend_from_slice(&id.to_le_bytes());
2713                    msg.extend_from_slice(pty.tag.as_bytes());
2714                    sess.ptys.insert(id, pty);
2715                    if let Some(c) = sess.clients.get_mut(&client_id) {
2716                        c.lead = Some(id);
2717                        c.view_sizes.insert(id, (rows, cols));
2718                        subscribe_client_to(c, id);
2719                        // Per-PTY scroll: no blanket reset needed.
2720                        reset_inflight(c);
2721                    }
2722                    sess.send_to_all(&msg);
2723                    need_nudge = true;
2724                }
2725            }
2726            C2S_CREATE2 => {
2727                // Generic create: [0x18][nonce:2][rows:2][cols:2][features:1][tag_len:2][tag:N][...fields]
2728                if data.len() < 10 {
2729                    continue;
2730                }
2731                let nonce = u16::from_le_bytes([data[1], data[2]]);
2732                let rows = u16::from_le_bytes([data[3], data[4]]);
2733                let cols = u16::from_le_bytes([data[5], data[6]]);
2734                let features = data[7];
2735                let tag_len = u16::from_le_bytes([data[8], data[9]]) as usize;
2736                let tag = if data.len() >= 10 + tag_len {
2737                    std::str::from_utf8(&data[10..10 + tag_len]).unwrap_or_default()
2738                } else {
2739                    ""
2740                };
2741                let mut cursor = 10 + tag_len;
2742                let dir = if features & CREATE2_HAS_SRC_PTY != 0 && data.len() >= cursor + 2 {
2743                    let src_id = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
2744                    cursor += 2;
2745                    sess.ptys.get(&src_id).and_then(|p| pty_cwd(p.child_pid))
2746                } else {
2747                    None
2748                };
2749                let create_payload = if features & CREATE2_HAS_COMMAND != 0 {
2750                    data.get(cursor..).and_then(|b| std::str::from_utf8(b).ok())
2751                } else {
2752                    None
2753                };
2754                let command = create_payload
2755                    .filter(|p| !p.contains('\0'))
2756                    .map(str::trim)
2757                    .filter(|p| !p.is_empty());
2758                let argv: Option<Vec<&str>> = create_payload
2759                    .filter(|p| p.contains('\0'))
2760                    .map(|p| p.split('\0').filter(|a| !a.is_empty()).collect::<Vec<_>>())
2761                    .filter(|a| !a.is_empty());
2762                let Some(id) = sess.allocate_pty_id() else {
2763                    continue;
2764                };
2765                if let Some(pty) = spawn_pty(
2766                    &config.shell,
2767                    rows,
2768                    cols,
2769                    id,
2770                    tag,
2771                    command,
2772                    argv.as_deref(),
2773                    dir.as_deref(),
2774                    config.scrollback,
2775                    state.clone(),
2776                ) {
2777                    let tag_bytes = pty.tag.as_bytes();
2778                    let mut nonce_msg = Vec::with_capacity(5 + tag_bytes.len());
2779                    nonce_msg.push(S2C_CREATED_N);
2780                    nonce_msg.extend_from_slice(&nonce.to_le_bytes());
2781                    nonce_msg.extend_from_slice(&id.to_le_bytes());
2782                    nonce_msg.extend_from_slice(tag_bytes);
2783                    let mut broadcast_msg = Vec::with_capacity(3 + tag_bytes.len());
2784                    broadcast_msg.push(S2C_CREATED);
2785                    broadcast_msg.extend_from_slice(&id.to_le_bytes());
2786                    broadcast_msg.extend_from_slice(tag_bytes);
2787                    sess.ptys.insert(id, pty);
2788                    if let Some(c) = sess.clients.get_mut(&client_id) {
2789                        c.lead = Some(id);
2790                        c.view_sizes.insert(id, (rows, cols));
2791                        subscribe_client_to(c, id);
2792                        // Per-PTY scroll: no blanket reset needed.
2793                        reset_inflight(c);
2794                        let _ = c.tx.try_send(nonce_msg);
2795                    }
2796                    for (&cid, c) in sess.clients.iter() {
2797                        if cid != client_id {
2798                            let _ = c.tx.try_send(broadcast_msg.clone());
2799                        }
2800                    }
2801                    need_nudge = true;
2802                }
2803            }
2804            C2S_FOCUS if data.len() >= 3 => {
2805                let pid = u16::from_le_bytes([data[1], data[2]]);
2806                if sess.ptys.contains_key(&pid) {
2807                    let old_pid = sess.clients.get(&client_id).and_then(|c| c.lead);
2808                    if let Some(c) = sess.clients.get_mut(&client_id) {
2809                        c.lead = Some(pid);
2810                        subscribe_client_to(c, pid);
2811                        if old_pid == Some(pid) {
2812                            update_client_scroll_state(c, pid, 0);
2813                        } else {
2814                            reset_inflight(c);
2815                        }
2816                    }
2817                    if let Some(pty) = sess.ptys.get_mut(&pid) {
2818                        pty.mark_dirty();
2819                        need_nudge = true;
2820                    }
2821                }
2822            }
2823            C2S_SUBSCRIBE if data.len() >= 3 => {
2824                let pid = u16::from_le_bytes([data[1], data[2]]);
2825                if sess.ptys.contains_key(&pid) {
2826                    if let Some(c) = sess.clients.get_mut(&client_id) {
2827                        subscribe_client_to(c, pid);
2828                    }
2829                    if let Some(pty) = sess.ptys.get_mut(&pid) {
2830                        pty.mark_dirty();
2831                    }
2832                    need_nudge = true;
2833                }
2834            }
2835            C2S_UNSUBSCRIBE if data.len() >= 3 => {
2836                let pid = u16::from_le_bytes([data[1], data[2]]);
2837                if sess.ptys.contains_key(&pid) {
2838                    let mut touched = Vec::new();
2839                    if let Some(c) = sess.clients.get_mut(&client_id) {
2840                        if unsubscribe_client_from(c, pid) {
2841                            touched.push(pid);
2842                        }
2843                        reset_inflight(c);
2844                    }
2845                    if sess.resize_ptys_to_mediated_sizes(touched) {
2846                        need_nudge = true;
2847                    }
2848                }
2849            }
2850            C2S_RESTART if data.len() >= 3 => {
2851                let pid = u16::from_le_bytes([data[1], data[2]]);
2852                let restart_info = sess
2853                    .ptys
2854                    .get(&pid)
2855                    .filter(|p| p.exited)
2856                    .map(|p| (p.driver.size(), p.command.clone(), p.tag.clone()));
2857                if let Some(((rows, cols), command, tag)) = restart_info {
2858                    if let Some((master, child, reader, byte_rx)) = respawn_child(
2859                        &state.0.shell,
2860                        rows,
2861                        cols,
2862                        pid,
2863                        command.as_deref(),
2864                        state.clone(),
2865                    ) {
2866                        let Some(pty) = sess.ptys.get_mut(&pid) else {
2867                            break;
2868                        };
2869                        pty.master_fd = master;
2870                        pty.child_pid = child;
2871                        pty.reader_handle = reader;
2872                        pty.byte_rx = byte_rx;
2873                        pty.driver.reset_modes();
2874                        pty.exited = false;
2875                        pty.exit_status = blit_remote::EXIT_STATUS_UNKNOWN;
2876                        pty.lflag_cache = pty_lflag(master);
2877                        pty.lflag_last = Instant::now();
2878                        pty.mark_dirty();
2879                        if let Some(c) = sess.clients.get_mut(&client_id) {
2880                            c.lead = Some(pid);
2881                            subscribe_client_to(c, pid);
2882                            update_client_scroll_state(c, pid, 0);
2883                            reset_inflight(c);
2884                        }
2885                        let mut msg = Vec::with_capacity(3 + tag.len());
2886                        msg.push(S2C_CREATED);
2887                        msg.extend_from_slice(&pid.to_le_bytes());
2888                        msg.extend_from_slice(tag.as_bytes());
2889                        sess.send_to_all(&msg);
2890                        need_nudge = true;
2891                    }
2892                }
2893            }
2894            C2S_READ if data.len() >= 13 => {
2895                let nonce = u16::from_le_bytes([data[1], data[2]]);
2896                let pid = u16::from_le_bytes([data[3], data[4]]);
2897                let req_offset = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
2898                let req_limit =
2899                    u32::from_le_bytes([data[9], data[10], data[11], data[12]]) as usize;
2900                let flags = data.get(13).copied().unwrap_or(0);
2901                let ansi = flags & READ_ANSI != 0;
2902                let tail = flags & READ_TAIL != 0;
2903
2904                if let Some(pty) = sess.ptys.get_mut(&pid) {
2905                    let (rows, _cols) = pty.driver.size();
2906                    let viewport = take_snapshot(pty);
2907                    let scrollback_lines = viewport.scrollback_lines() as usize;
2908                    let total_lines = scrollback_lines + rows as usize;
2909
2910                    let extract = |f: &FrameState| -> String {
2911                        if ansi {
2912                            f.get_ansi_text()
2913                        } else {
2914                            f.get_all_text()
2915                        }
2916                    };
2917
2918                    let mut all_lines: Vec<String> = Vec::new();
2919
2920                    let mut scroll_offset = scrollback_lines;
2921                    while scroll_offset > 0 {
2922                        let frame = pty.driver.scrollback_frame(scroll_offset);
2923                        let page = extract(&frame);
2924                        let page_lines: Vec<&str> = page.lines().collect();
2925                        let take = if scroll_offset < rows as usize {
2926                            scroll_offset.min(page_lines.len())
2927                        } else {
2928                            page_lines.len()
2929                        };
2930                        for line in &page_lines[..take] {
2931                            all_lines.push(line.to_string());
2932                        }
2933                        if scroll_offset <= rows as usize {
2934                            break;
2935                        }
2936                        scroll_offset = scroll_offset.saturating_sub(rows as usize);
2937                    }
2938
2939                    for line in extract(&viewport).lines() {
2940                        all_lines.push(line.to_string());
2941                    }
2942
2943                    let (start, end) = if tail {
2944                        let end = all_lines.len().saturating_sub(req_offset);
2945                        let start = if req_limit == 0 {
2946                            0
2947                        } else {
2948                            end.saturating_sub(req_limit)
2949                        };
2950                        (start, end)
2951                    } else {
2952                        let start = req_offset.min(all_lines.len());
2953                        let end = if req_limit == 0 {
2954                            all_lines.len()
2955                        } else {
2956                            (start + req_limit).min(all_lines.len())
2957                        };
2958                        (start, end)
2959                    };
2960                    let text = all_lines[start..end].join("\n");
2961
2962                    let mut msg = Vec::with_capacity(13 + text.len());
2963                    msg.push(S2C_TEXT);
2964                    msg.extend_from_slice(&nonce.to_le_bytes());
2965                    msg.extend_from_slice(&pid.to_le_bytes());
2966                    msg.extend_from_slice(&(total_lines as u32).to_le_bytes());
2967                    msg.extend_from_slice(&(start as u32).to_le_bytes());
2968                    msg.extend_from_slice(text.as_bytes());
2969                    if let Some(client) = sess.clients.get(&client_id) {
2970                        let _ = client.tx.try_send(msg);
2971                    }
2972                }
2973            }
2974            C2S_COPY_RANGE if data.len() >= 18 => {
2975                let nonce = u16::from_le_bytes([data[1], data[2]]);
2976                let pid = u16::from_le_bytes([data[3], data[4]]);
2977                let start_tail =
2978                    u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
2979                let start_col = u16::from_le_bytes([data[9], data[10]]);
2980                let end_tail =
2981                    u32::from_le_bytes([data[11], data[12], data[13], data[14]]);
2982                let end_col = u16::from_le_bytes([data[15], data[16]]);
2983
2984                if let Some(pty) = sess.ptys.get(&pid) {
2985                    let text =
2986                        pty.driver.get_text_range(start_tail, start_col, end_tail, end_col);
2987                    let total_lines = pty.driver.total_lines();
2988
2989                    let mut msg = Vec::with_capacity(13 + text.len());
2990                    msg.push(S2C_TEXT);
2991                    msg.extend_from_slice(&nonce.to_le_bytes());
2992                    msg.extend_from_slice(&pid.to_le_bytes());
2993                    msg.extend_from_slice(&total_lines.to_le_bytes());
2994                    msg.extend_from_slice(&start_tail.to_le_bytes());
2995                    msg.extend_from_slice(text.as_bytes());
2996                    if let Some(client) = sess.clients.get(&client_id) {
2997                        let _ = client.tx.try_send(msg);
2998                    }
2999                }
3000            }
3001            C2S_KILL if data.len() >= 7 => {
3002                let pid = u16::from_le_bytes([data[1], data[2]]);
3003                let signal = i32::from_le_bytes([data[3], data[4], data[5], data[6]]);
3004                if let Some(pty) = sess.ptys.get(&pid) {
3005                    if !pty.exited {
3006                        unsafe {
3007                            libc::kill(pty.child_pid, signal);
3008                        }
3009                    }
3010                }
3011            }
3012            C2S_CLOSE if data.len() >= 3 => {
3013                let pid = u16::from_le_bytes([data[1], data[2]]);
3014                if let Some(pty) = sess.ptys.remove(&pid) {
3015                    if !pty.exited {
3016                        state.2.write().unwrap().remove(&pid);
3017                        drop(pty.reader_handle); // thread exits when master fd is closed below
3018                        unsafe {
3019                            libc::kill(pty.child_pid, libc::SIGHUP);
3020                            libc::close(pty.master_fd);
3021                        }
3022                    }
3023                    for client in sess.clients.values_mut() {
3024                        unsubscribe_client_from(client, pid);
3025                    }
3026                    let mut msg = vec![S2C_CLOSED];
3027                    msg.extend_from_slice(&pid.to_le_bytes());
3028                    sess.send_to_all(&msg);
3029                }
3030            }
3031            _ => {}
3032        }
3033        drop(sess);
3034        if need_nudge {
3035            nudge_delivery(&state);
3036        }
3037    }
3038
3039    {
3040        let mut sess = state.1.lock().await;
3041        let mut need_nudge = false;
3042        let affected_ptys = sess
3043            .clients
3044            .remove(&client_id)
3045            .map(|client| client.view_sizes.keys().copied().collect::<Vec<_>>())
3046            .unwrap_or_default();
3047        if sess.resize_ptys_to_mediated_sizes(affected_ptys) {
3048            need_nudge = true;
3049        }
3050        drop(sess);
3051        if need_nudge {
3052            nudge_delivery(&state);
3053        }
3054    }
3055    sender.abort();
3056    if state.0.verbose {
3057        eprintln!("client disconnected");
3058    }
3059}
3060
3061#[cfg(test)]
3062mod tests {
3063    use super::*;
3064
3065    fn test_client_with_capacity(capacity: usize) -> (ClientState, mpsc::Receiver<Vec<u8>>) {
3066        let (tx, rx) = mpsc::channel(capacity);
3067        let client = ClientState {
3068            tx,
3069            lead: None,
3070            subscriptions: HashSet::new(),
3071            view_sizes: HashMap::new(),
3072            scroll_offsets: HashMap::new(),
3073            scroll_caches: HashMap::new(),
3074            last_sent: HashMap::new(),
3075            preview_next_send_at: HashMap::new(),
3076            rtt_ms: 50.0,
3077            min_rtt_ms: 50.0,
3078            display_fps: 60.0,
3079            delivery_bps: 262_144.0,
3080            goodput_bps: 262_144.0,
3081            goodput_jitter_bps: 0.0,
3082            max_goodput_jitter_bps: 0.0,
3083            last_goodput_sample_bps: 0.0,
3084            avg_frame_bytes: 1_024.0,
3085            avg_paced_frame_bytes: 1_024.0,
3086            avg_preview_frame_bytes: 1_024.0,
3087            inflight_bytes: 0,
3088            inflight_frames: VecDeque::new(),
3089            next_send_at: Instant::now(),
3090            probe_frames: 0.0,
3091            frames_sent: 0,
3092            acks_recv: 0,
3093            acked_bytes_since_log: 0,
3094            browser_backlog_frames: 0,
3095            browser_ack_ahead_frames: 0,
3096            browser_apply_ms: 0.0,
3097            last_metrics_update: Instant::now(),
3098            last_log: Instant::now(),
3099            goodput_window_bytes: 0,
3100            goodput_window_start: Instant::now(),
3101        };
3102        (client, rx)
3103    }
3104
3105    fn test_client() -> ClientState {
3106        let (client, _rx) = test_client_with_capacity(OUTBOX_CAPACITY);
3107        client
3108    }
3109
3110    fn fill_inflight(client: &mut ClientState, frames: usize, bytes_per_frame: usize) {
3111        let now = Instant::now();
3112        client.inflight_bytes = frames.saturating_mul(bytes_per_frame);
3113        client.inflight_frames = (0..frames)
3114            .map(|_| InFlightFrame {
3115                sent_at: now,
3116                bytes: bytes_per_frame,
3117                paced: true,
3118            })
3119            .collect();
3120    }
3121
3122    fn sample_frame(text: &str) -> FrameState {
3123        let mut frame = FrameState::new(2, 8);
3124        frame.write_text(0, 0, text, blit_remote::CellStyle::default());
3125        frame
3126    }
3127
3128    #[test]
3129    fn unset_view_size_accepts_zero_pair_only() {
3130        assert!(is_unset_view_size(0, 0));
3131        assert!(!is_unset_view_size(0, 80));
3132        assert!(!is_unset_view_size(u16::MAX, u16::MAX));
3133    }
3134
3135    #[test]
3136    fn unsubscribe_client_from_clears_view_size() {
3137        let mut client = test_client();
3138        client.subscriptions.insert(7);
3139        client.view_sizes.insert(7, (24, 80));
3140        assert!(unsubscribe_client_from(&mut client, 7));
3141        assert!(!client.subscriptions.contains(&7));
3142        assert!(!client.view_sizes.contains_key(&7));
3143    }
3144
3145    #[test]
3146    fn mediated_size_uses_per_pty_view_sizes_without_lead() {
3147        let mut session = Session::new();
3148        let mut c1 = test_client();
3149        let mut c2 = test_client();
3150        c1.view_sizes.insert(7, (30, 120));
3151        c2.view_sizes.insert(7, (24, 100));
3152        session.clients.insert(1, c1);
3153        session.clients.insert(2, c2);
3154        assert_eq!(session.mediated_size_for_pty(7), Some((24, 100)));
3155    }
3156
3157    #[test]
3158    fn due_preview_reserves_the_last_lead_slot() {
3159        let mut client = test_client();
3160        client.lead = Some(1);
3161        client.subscriptions.insert(1);
3162        client.subscriptions.insert(2);
3163
3164        let target_frames = target_frame_window(&client);
3165        let lead_limit = target_frames.saturating_sub(1).max(1);
3166        fill_inflight(&mut client, lead_limit, 512);
3167
3168        assert!(window_open(&client));
3169        assert!(lead_window_open(&client, false));
3170        assert!(!lead_window_open(&client, true));
3171        assert!(can_send_preview(&client, 2, Instant::now()));
3172    }
3173
3174    #[test]
3175    fn entering_scrollback_uses_current_visible_frame_as_baseline() {
3176        let mut client = test_client();
3177        let live = sample_frame("live");
3178        client.lead = Some(7);
3179        client.subscriptions.insert(7);
3180        client.last_sent.insert(7, live.clone());
3181
3182        assert!(update_client_scroll_state(&mut client, 7, 12));
3183        assert_eq!(client.scroll_offsets.get(&7), Some(&12));
3184        assert_eq!(client.scroll_caches.get(&7), Some(&live));
3185    }
3186
3187    #[test]
3188    fn leaving_scrollback_seeds_live_diff_from_scrollback_view() {
3189        let mut client = test_client();
3190        let history = sample_frame("hist");
3191        client.lead = Some(7);
3192        client.subscriptions.insert(7);
3193        client.scroll_offsets.insert(7, 12);
3194        client.scroll_caches.insert(7, history.clone());
3195
3196        assert!(update_client_scroll_state(&mut client, 7, 0));
3197        assert_eq!(client.scroll_offsets.get(&7), None);
3198        assert_eq!(client.last_sent.get(&7), Some(&history));
3199        assert_eq!(client.scroll_caches.get(&7), None);
3200    }
3201
3202    // ── frame_window ──
3203
3204    #[test]
3205    fn frame_window_minimum_is_two() {
3206        assert!(frame_window(0.0, 60.0) >= 2);
3207    }
3208
3209    #[test]
3210    fn frame_window_scales_with_rtt() {
3211        let low = frame_window(10.0, 60.0);
3212        let high = frame_window(200.0, 60.0);
3213        assert!(high > low, "higher RTT should need more frames in flight");
3214    }
3215
3216    #[test]
3217    fn frame_window_scales_with_fps() {
3218        let slow = frame_window(100.0, 10.0);
3219        let fast = frame_window(100.0, 120.0);
3220        assert!(fast > slow, "higher fps should need more frames in flight");
3221    }
3222
3223    #[test]
3224    fn frame_window_zero_rtt() {
3225        assert!(frame_window(0.0, 120.0) >= 2);
3226    }
3227
3228    // ── path_rtt_ms ──
3229
3230    #[test]
3231    fn path_rtt_ms_uses_min_when_positive() {
3232        let mut client = test_client();
3233        client.rtt_ms = 100.0;
3234        client.min_rtt_ms = 30.0;
3235        assert_eq!(path_rtt_ms(&client), 30.0);
3236    }
3237
3238    #[test]
3239    fn path_rtt_ms_falls_back_to_rtt_when_min_zero() {
3240        let mut client = test_client();
3241        client.rtt_ms = 80.0;
3242        client.min_rtt_ms = 0.0;
3243        assert_eq!(path_rtt_ms(&client), 80.0);
3244    }
3245
3246    // ── ewma_with_direction ──
3247
3248    #[test]
3249    fn ewma_rising_uses_rise_alpha() {
3250        let result = ewma_with_direction(100.0, 200.0, 0.5, 0.1);
3251        // rise: 100 * 0.5 + 200 * 0.5 = 150
3252        assert!((result - 150.0).abs() < 0.01);
3253    }
3254
3255    #[test]
3256    fn ewma_falling_uses_fall_alpha() {
3257        let result = ewma_with_direction(200.0, 100.0, 0.5, 0.1);
3258        // fall: 200 * 0.9 + 100 * 0.1 = 190
3259        assert!((result - 190.0).abs() < 0.01);
3260    }
3261
3262    #[test]
3263    fn ewma_same_value_unchanged() {
3264        let result = ewma_with_direction(50.0, 50.0, 0.5, 0.5);
3265        assert!((result - 50.0).abs() < 0.01);
3266    }
3267
3268    // ── advance_deadline ──
3269
3270    #[test]
3271    fn advance_deadline_steps_forward() {
3272        let now = Instant::now();
3273        let mut deadline = now;
3274        let interval = Duration::from_millis(16);
3275        advance_deadline(&mut deadline, now, interval);
3276        assert!(deadline > now);
3277        assert!(deadline <= now + interval + Duration::from_micros(100));
3278    }
3279
3280    #[test]
3281    fn advance_deadline_resets_when_far_behind() {
3282        let now = Instant::now();
3283        // deadline is way in the past (more than 2 intervals ago)
3284        let mut deadline = now - Duration::from_secs(10);
3285        let interval = Duration::from_millis(16);
3286        advance_deadline(&mut deadline, now, interval);
3287        // Should snap to now + interval since scheduled + interval < now
3288        assert!(deadline >= now);
3289    }
3290
3291    #[test]
3292    fn should_snapshot_pty_requires_dirty_and_needful() {
3293        assert!(should_snapshot_pty(true, true, false));
3294        assert!(!should_snapshot_pty(false, true, false));
3295        assert!(!should_snapshot_pty(true, false, false));
3296    }
3297
3298    #[test]
3299    fn should_snapshot_pty_defers_synced_output() {
3300        assert!(!should_snapshot_pty(true, true, true));
3301        assert!(should_snapshot_pty(true, true, false));
3302    }
3303
3304    #[test]
3305    fn enqueue_ready_frame_refuses_new_frames_when_capped() {
3306        let mut queue = VecDeque::new();
3307        for cols in 1..=(READY_FRAME_QUEUE_CAP as u16) {
3308            assert!(enqueue_ready_frame(&mut queue, FrameState::new(1, cols)));
3309        }
3310        assert!(!enqueue_ready_frame(
3311            &mut queue,
3312            FrameState::new(1, READY_FRAME_QUEUE_CAP as u16 + 1),
3313        ));
3314        assert_eq!(queue.len(), READY_FRAME_QUEUE_CAP);
3315        assert_eq!(queue.front().map(FrameState::cols), Some(1));
3316        assert_eq!(
3317            queue.back().map(FrameState::cols),
3318            Some(READY_FRAME_QUEUE_CAP as u16),
3319        );
3320    }
3321
3322    #[test]
3323    fn find_sync_output_end_returns_end_of_first_close_sequence() {
3324        let bytes = b"abc\x1b[?2026lrest\x1b[?2026l";
3325        assert_eq!(find_sync_output_end(&[], bytes), Some(11));
3326    }
3327
3328    #[test]
3329    fn find_sync_output_end_returns_none_without_close_sequence() {
3330        assert_eq!(find_sync_output_end(&[], b"\x1b[?2026hpartial"), None);
3331    }
3332
3333    #[test]
3334    fn find_sync_output_end_detects_boundary_split_across_reads() {
3335        assert_eq!(find_sync_output_end(b"abc\x1b[?20", b"26lrest"), Some(3));
3336    }
3337
3338    #[test]
3339    fn update_sync_scan_tail_keeps_recent_suffix_only() {
3340        let mut tail = Vec::new();
3341        update_sync_scan_tail(&mut tail, b"123456789");
3342        assert_eq!(tail, b"3456789");
3343    }
3344
3345    // ── window_saturated ──
3346
3347    #[test]
3348    fn window_saturated_at_90_percent_frames() {
3349        let client = test_client();
3350        let target = target_frame_window(&client);
3351        let frames_90 = (target * 9).div_ceil(10); // ceil(target * 0.9)
3352        assert!(window_saturated(&client, frames_90, 0));
3353    }
3354
3355    #[test]
3356    fn window_saturated_not_at_low_usage() {
3357        let client = test_client();
3358        assert!(!window_saturated(&client, 1, 0));
3359    }
3360
3361    #[test]
3362    fn window_saturated_at_90_percent_bytes() {
3363        let client = test_client();
3364        let target_bytes = target_byte_window(&client);
3365        let bytes_90 = (target_bytes * 9).div_ceil(10);
3366        assert!(window_saturated(&client, 0, bytes_90));
3367    }
3368
3369    // ── outbox_queued_frames / outbox_backpressured ──
3370
3371    #[test]
3372    fn outbox_queued_frames_zero_when_empty() {
3373        let client = test_client();
3374        assert_eq!(outbox_queued_frames(&client), 0);
3375    }
3376
3377    #[test]
3378    fn outbox_backpressured_when_queue_full() {
3379        let (client, _rx) = test_client_with_capacity(OUTBOX_CAPACITY);
3380        // Fill the channel to trigger backpressure
3381        for _ in 0..OUTBOX_SOFT_QUEUE_LIMIT_FRAMES {
3382            let _ = client.tx.try_send(vec![0u8]);
3383        }
3384        assert!(outbox_backpressured(&client));
3385    }
3386
3387    #[test]
3388    fn outbox_not_backpressured_when_empty() {
3389        let client = test_client();
3390        assert!(!outbox_backpressured(&client));
3391    }
3392
3393    // ── browser_pacing_fps baseline ──
3394
3395    #[test]
3396    fn browser_pacing_fps_matches_display_fps_when_browser_ready() {
3397        let mut client = test_client();
3398        client.rtt_ms = 1.0;
3399        client.min_rtt_ms = 1.0;
3400        client.browser_backlog_frames = 0;
3401        client.browser_ack_ahead_frames = 0;
3402        client.browser_apply_ms = 0.0;
3403        client.goodput_bps = 1_000_000.0;
3404        client.delivery_bps = 1_000_000.0;
3405        client.display_fps = 144.0;
3406        assert!((browser_pacing_fps(&client) - 144.0).abs() < 0.01);
3407    }
3408
3409    #[test]
3410    fn browser_pacing_fps_drops_below_display_fps_when_backlogged() {
3411        let mut client = test_client();
3412        client.browser_backlog_frames = 20;
3413        let fps = browser_pacing_fps(&client);
3414        assert!(fps >= 1.0);
3415        assert!(fps < client.display_fps);
3416    }
3417
3418    // ── effective_rtt_ms ──
3419
3420    #[test]
3421    fn effective_rtt_ms_equals_path_when_queue_is_empty() {
3422        let mut client = test_client();
3423        client.rtt_ms = 1.0;
3424        client.min_rtt_ms = 1.0;
3425        client.browser_backlog_frames = 0;
3426        client.browser_ack_ahead_frames = 0;
3427        client.browser_apply_ms = 0.0;
3428        client.goodput_bps = 1_000_000.0;
3429        client.delivery_bps = 1_000_000.0;
3430        assert!((effective_rtt_ms(&client) - 1.0).abs() < 0.01);
3431    }
3432
3433    #[test]
3434    fn effective_rtt_ms_at_least_path_rtt() {
3435        let client = test_client();
3436        assert!(effective_rtt_ms(&client) >= path_rtt_ms(&client));
3437    }
3438
3439    // ── target_frame_window ──
3440
3441    #[test]
3442    fn target_frame_window_at_least_two() {
3443        let client = test_client();
3444        assert!(target_frame_window(&client) >= 2);
3445    }
3446
3447    #[test]
3448    fn target_frame_window_grows_with_probe() {
3449        let mut client = test_client();
3450        let base = target_frame_window(&client);
3451        client.probe_frames = 10.0;
3452        let probed = target_frame_window(&client);
3453        assert!(probed > base, "probe_frames should grow the window");
3454    }
3455
3456    // ── bandwidth_floor_bps ──
3457
3458    #[test]
3459    fn bandwidth_floor_bps_at_least_16k() {
3460        let mut client = test_client();
3461        client.goodput_bps = 0.0;
3462        client.delivery_bps = 0.0;
3463        assert_eq!(bandwidth_floor_bps(&client), 0.0);
3464    }
3465
3466    #[test]
3467    fn bandwidth_floor_bps_scales_with_goodput() {
3468        let mut client = test_client();
3469        client.goodput_bps = 1_000_000.0;
3470        client.delivery_bps = 1_000_000.0;
3471        let floor = bandwidth_floor_bps(&client);
3472        assert!(floor > 0.0);
3473    }
3474
3475    #[test]
3476    fn browser_ready_delivery_floor_can_drive_large_frames_to_display_fps() {
3477        let mut client = test_client();
3478        client.display_fps = 60.0;
3479        client.browser_backlog_frames = 0;
3480        client.browser_ack_ahead_frames = 0;
3481        client.browser_apply_ms = 0.2;
3482        client.goodput_bps = 3_000_000.0;
3483        client.delivery_bps = 9_500_000.0;
3484        client.last_goodput_sample_bps = 3_000_000.0;
3485        client.avg_paced_frame_bytes = 150_000.0;
3486        client.avg_preview_frame_bytes = 1_024.0;
3487        client.avg_frame_bytes = 150_000.0;
3488
3489        assert!(
3490            (pacing_fps(&client) - client.display_fps).abs() < 0.01,
3491            "browser-ready delivery floor should let large frames reach display_fps on a fast path",
3492        );
3493    }
3494
3495    // ── pacing_fps ──
3496
3497    #[test]
3498    fn pacing_fps_zero_when_no_bandwidth() {
3499        let mut client = test_client();
3500        client.goodput_bps = 0.0;
3501        client.delivery_bps = 0.0;
3502        client.last_goodput_sample_bps = 0.0;
3503        assert!(
3504            pacing_fps(&client) == 0.0,
3505            "pacing_fps should be 0 with zero bandwidth"
3506        );
3507    }
3508
3509    #[test]
3510    fn pacing_fps_reaches_display_fps_when_not_bandwidth_limited() {
3511        let mut client = test_client();
3512        client.rtt_ms = 1.0;
3513        client.min_rtt_ms = 1.0;
3514        client.browser_backlog_frames = 0;
3515        client.browser_ack_ahead_frames = 0;
3516        client.browser_apply_ms = 0.0;
3517        client.goodput_bps = 1_000_000.0;
3518        client.delivery_bps = 1_000_000.0;
3519        client.display_fps = 60.0;
3520        assert!((pacing_fps(&client) - 60.0).abs() < 0.01);
3521    }
3522
3523    // ── throughput_limited ──
3524
3525    #[test]
3526    fn throughput_limited_when_low_bandwidth() {
3527        let mut client = test_client();
3528        client.goodput_bps = 1_000.0;
3529        client.delivery_bps = 1_000.0;
3530        client.last_goodput_sample_bps = 0.0;
3531        assert!(throughput_limited(&client));
3532    }
3533
3534    #[test]
3535    fn throughput_not_limited_with_high_bandwidth() {
3536        let mut client = test_client();
3537        client.goodput_bps = 100_000_000.0;
3538        client.delivery_bps = 100_000_000.0;
3539        assert!(!throughput_limited(&client));
3540    }
3541
3542    // ── browser_pacing_fps ──
3543
3544    #[test]
3545    fn browser_pacing_fps_at_least_one() {
3546        let client = test_client();
3547        assert!(browser_pacing_fps(&client) >= 1.0);
3548    }
3549
3550    #[test]
3551    fn browser_pacing_fps_reduced_by_high_backlog() {
3552        let mut client = test_client();
3553        let normal = browser_pacing_fps(&client);
3554        client.browser_backlog_frames = 20;
3555        let backlogged = browser_pacing_fps(&client);
3556        assert!(backlogged < normal, "high backlog should reduce pacing fps");
3557    }
3558
3559    #[test]
3560    fn browser_pacing_fps_reduced_by_high_ack_ahead() {
3561        let mut client = test_client();
3562        let normal = browser_pacing_fps(&client);
3563        client.browser_ack_ahead_frames = 10;
3564        let ahead = browser_pacing_fps(&client);
3565        assert!(ahead < normal, "high ack_ahead should reduce pacing fps");
3566    }
3567
3568    // ── browser_backlog_blocked ──
3569
3570    #[test]
3571    fn browser_backlog_blocked_over_threshold() {
3572        let mut client = test_client();
3573        client.browser_backlog_frames = 9;
3574        assert!(browser_backlog_blocked(&client));
3575    }
3576
3577    #[test]
3578    fn browser_backlog_not_blocked_under_threshold() {
3579        let mut client = test_client();
3580        client.browser_backlog_frames = 8;
3581        assert!(!browser_backlog_blocked(&client));
3582    }
3583
3584    // ── byte_budget_for ──
3585
3586    #[test]
3587    fn byte_budget_for_at_least_one_frame() {
3588        let client = test_client();
3589        let budget = byte_budget_for(&client, 10.0);
3590        assert!(budget >= client.avg_frame_bytes.max(256.0) as usize);
3591    }
3592
3593    #[test]
3594    fn byte_budget_for_grows_with_time() {
3595        let client = test_client();
3596        let short = byte_budget_for(&client, 10.0);
3597        let long = byte_budget_for(&client, 1000.0);
3598        assert!(long >= short);
3599    }
3600
3601    // ── target_byte_window ──
3602
3603    #[test]
3604    fn target_byte_window_positive() {
3605        let client = test_client();
3606        assert!(target_byte_window(&client) > 0);
3607    }
3608
3609    #[test]
3610    fn target_byte_window_covers_frame_window() {
3611        let client = test_client();
3612        let byte_win = target_byte_window(&client);
3613        let frame_win = target_frame_window(&client);
3614        let min_bytes =
3615            (client.avg_paced_frame_bytes.max(256.0) * frame_win.max(2) as f32).ceil() as usize;
3616        assert!(
3617            byte_win >= min_bytes,
3618            "byte window should cover at least frame_window worth of paced frames"
3619        );
3620    }
3621
3622    // ── send_interval ──
3623
3624    #[test]
3625    fn send_interval_matches_browser_pacing() {
3626        let client = test_client();
3627        let interval = send_interval(&client);
3628        let expected = Duration::from_secs_f64(1.0 / browser_pacing_fps(&client) as f64);
3629        let diff = interval.abs_diff(expected);
3630        assert!(diff < Duration::from_micros(10));
3631    }
3632
3633    // ── preview_fps ──
3634
3635    #[test]
3636    fn preview_fps_at_least_one() {
3637        let client = test_client();
3638        assert!(preview_fps(&client) >= 1.0);
3639    }
3640
3641    // ── window_open ──
3642
3643    #[test]
3644    fn window_open_initially() {
3645        let client = test_client();
3646        assert!(window_open(&client));
3647    }
3648
3649    #[test]
3650    fn window_open_false_when_browser_blocked() {
3651        let mut client = test_client();
3652        client.browser_backlog_frames = 20;
3653        assert!(!window_open(&client));
3654    }
3655
3656    #[test]
3657    fn window_open_false_when_inflight_full() {
3658        let mut client = test_client();
3659        let target = target_frame_window(&client);
3660        fill_inflight(&mut client, target + 10, 1024);
3661        assert!(!window_open(&client));
3662    }
3663
3664    // ── lead_window_open ──
3665
3666    #[test]
3667    fn lead_window_open_no_reserve_same_as_window_open() {
3668        let client = test_client();
3669        assert_eq!(lead_window_open(&client, false), window_open(&client));
3670    }
3671
3672    #[test]
3673    fn lead_window_open_reserves_preview_slot() {
3674        let mut client = test_client();
3675        client.lead = Some(1);
3676        client.subscriptions.insert(1);
3677        let target = target_frame_window(&client);
3678        // Fill to just under target minus reserve
3679        fill_inflight(&mut client, target.saturating_sub(1), 512);
3680        // Without reserve: may still be open
3681        // With reserve: should be closed
3682        assert!(!lead_window_open(&client, true));
3683    }
3684
3685    // ── can_send_frame ──
3686
3687    #[test]
3688    fn can_send_frame_when_window_open_and_time_due() {
3689        let mut client = test_client();
3690        client.next_send_at = Instant::now() - Duration::from_millis(100);
3691        assert!(can_send_frame(&client, Instant::now(), false));
3692    }
3693
3694    #[test]
3695    fn can_send_frame_false_when_not_due() {
3696        let mut client = test_client();
3697        client.next_send_at = Instant::now() + Duration::from_secs(10);
3698        assert!(!can_send_frame(&client, Instant::now(), false));
3699    }
3700
3701    #[test]
3702    fn can_send_frame_false_when_window_closed() {
3703        let mut client = test_client();
3704        client.browser_backlog_frames = 20; // triggers browser_backlog_blocked
3705        client.next_send_at = Instant::now() - Duration::from_millis(100);
3706        assert!(!can_send_frame(&client, Instant::now(), false));
3707    }
3708
3709    // ── record_send / record_ack state transitions ──
3710
3711    #[test]
3712    fn record_send_increases_inflight() {
3713        let mut client = test_client();
3714        let now = Instant::now();
3715        assert_eq!(client.inflight_bytes, 0);
3716        assert_eq!(client.inflight_frames.len(), 0);
3717
3718        record_send(&mut client, 1000, now, true);
3719        assert_eq!(client.inflight_bytes, 1000);
3720        assert_eq!(client.inflight_frames.len(), 1);
3721
3722        record_send(&mut client, 500, now, false);
3723        assert_eq!(client.inflight_bytes, 1500);
3724        assert_eq!(client.inflight_frames.len(), 2);
3725    }
3726
3727    #[test]
3728    fn record_send_paced_advances_deadline() {
3729        let mut client = test_client();
3730        let now = Instant::now();
3731        client.next_send_at = now;
3732        record_send(&mut client, 1000, now, true);
3733        assert!(client.next_send_at > now);
3734    }
3735
3736    #[test]
3737    fn record_send_unpaced_does_not_advance_deadline() {
3738        let mut client = test_client();
3739        let now = Instant::now();
3740        let before = client.next_send_at;
3741        record_send(&mut client, 1000, now, false);
3742        assert_eq!(client.next_send_at, before);
3743    }
3744
3745    #[test]
3746    fn record_ack_decreases_inflight() {
3747        let mut client = test_client();
3748        let now = Instant::now();
3749        record_send(&mut client, 1000, now, true);
3750        record_send(&mut client, 500, now, true);
3751        assert_eq!(client.inflight_frames.len(), 2);
3752
3753        record_ack(&mut client);
3754        assert_eq!(client.inflight_frames.len(), 1);
3755        assert_eq!(client.inflight_bytes, 500);
3756    }
3757
3758    #[test]
3759    fn record_ack_on_empty_clears_bytes() {
3760        let mut client = test_client();
3761        client.inflight_bytes = 999; // stale state
3762        record_ack(&mut client);
3763        assert_eq!(client.inflight_bytes, 0);
3764    }
3765
3766    #[test]
3767    fn record_ack_updates_rtt_estimate() {
3768        let mut client = test_client();
3769        let now = Instant::now();
3770        client.inflight_frames.push_back(InFlightFrame {
3771            sent_at: now - Duration::from_millis(20),
3772            bytes: 512,
3773            paced: true,
3774        });
3775        client.inflight_bytes = 512;
3776        let old_rtt = client.rtt_ms;
3777        record_ack(&mut client);
3778        // RTT should have been updated (moved toward ~20ms from the default 50ms)
3779        assert!(
3780            (client.rtt_ms - old_rtt).abs() > 0.01,
3781            "rtt_ms should be updated after ack"
3782        );
3783    }
3784
3785    #[test]
3786    fn record_ack_paced_updates_avg_paced_frame_bytes() {
3787        let mut client = test_client();
3788        let now = Instant::now();
3789        client.inflight_frames.push_back(InFlightFrame {
3790            sent_at: now - Duration::from_millis(10),
3791            bytes: 4096,
3792            paced: true,
3793        });
3794        client.inflight_bytes = 4096;
3795        let old_avg = client.avg_paced_frame_bytes;
3796        record_ack(&mut client);
3797        // Should move toward 4096 from 1024
3798        assert!(client.avg_paced_frame_bytes > old_avg);
3799    }
3800
3801    #[test]
3802    fn record_ack_unpaced_updates_avg_preview_frame_bytes() {
3803        let mut client = test_client();
3804        let now = Instant::now();
3805        client.inflight_frames.push_back(InFlightFrame {
3806            sent_at: now - Duration::from_millis(10),
3807            bytes: 8192,
3808            paced: false,
3809        });
3810        client.inflight_bytes = 8192;
3811        let old_avg = client.avg_preview_frame_bytes;
3812        record_ack(&mut client);
3813        assert!(client.avg_preview_frame_bytes > old_avg);
3814    }
3815
3816    // ── Session::pty_list_msg format ──
3817
3818    #[test]
3819    fn pty_list_msg_empty_session() {
3820        let sess = Session::new();
3821        let msg = sess.pty_list_msg();
3822        assert_eq!(msg[0], S2C_LIST);
3823        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 0);
3824        assert_eq!(msg.len(), 3);
3825    }
3826
3827    #[test]
3828    fn pty_list_msg_includes_tags() {
3829        let _sess = Session::new();
3830        // Insert minimal Pty entries. We can't call spawn_pty, so build
3831        // a mock-like Pty with a stub driver. Instead, directly insert
3832        // into the HashMap using an unsafe-free approach: just build the
3833        // wire message by hand and verify against a known layout.
3834        //
3835        // The wire format is: [S2C_LIST] [count:u16le] [id:u16le tag_len:u16le tag_bytes]...
3836        //
3837        // Since we can't easily construct a Pty without forking, verify
3838        // the format by constructing the expected bytes and comparing.
3839        let tag1 = "shell";
3840        let tag2 = "build";
3841
3842        // Expected wire for ptys {1 => "shell", 3 => "build"} sorted by id:
3843        let mut expected = vec![S2C_LIST];
3844        expected.extend_from_slice(&2u16.to_le_bytes());
3845        // id=1
3846        expected.extend_from_slice(&1u16.to_le_bytes());
3847        expected.extend_from_slice(&(tag1.len() as u16).to_le_bytes());
3848        expected.extend_from_slice(tag1.as_bytes());
3849        // id=3
3850        expected.extend_from_slice(&3u16.to_le_bytes());
3851        expected.extend_from_slice(&(tag2.len() as u16).to_le_bytes());
3852        expected.extend_from_slice(tag2.as_bytes());
3853
3854        // Verify our expected format starts with S2C_LIST and has correct count
3855        assert_eq!(expected[0], S2C_LIST);
3856        assert_eq!(u16::from_le_bytes([expected[1], expected[2]]), 2);
3857        // Verify tags are embedded
3858        let msg_str = String::from_utf8_lossy(&expected);
3859        assert!(msg_str.contains("shell"));
3860        assert!(msg_str.contains("build"));
3861    }
3862
3863    // ── can_send_preview / record_preview_send ──
3864
3865    #[test]
3866    fn can_send_preview_true_when_due() {
3867        let mut client = test_client();
3868        let now = Instant::now();
3869        client
3870            .preview_next_send_at
3871            .insert(5, now - Duration::from_millis(100));
3872        assert!(can_send_preview(&client, 5, now));
3873    }
3874
3875    #[test]
3876    fn can_send_preview_false_when_not_due() {
3877        let mut client = test_client();
3878        let now = Instant::now();
3879        client
3880            .preview_next_send_at
3881            .insert(5, now + Duration::from_secs(10));
3882        assert!(!can_send_preview(&client, 5, now));
3883    }
3884
3885    #[test]
3886    fn can_send_preview_false_when_window_closed() {
3887        let mut client = test_client();
3888        client.browser_backlog_frames = 20;
3889        let now = Instant::now();
3890        assert!(!can_send_preview(&client, 5, now));
3891    }
3892
3893    #[test]
3894    fn can_send_preview_true_for_unseen_pid() {
3895        let client = test_client();
3896        let now = Instant::now();
3897        // No entry in preview_next_send_at means deadline defaults to now
3898        assert!(can_send_preview(&client, 99, now));
3899    }
3900
3901    #[test]
3902    fn record_preview_send_sets_future_deadline() {
3903        let mut client = test_client();
3904        let now = Instant::now();
3905        record_preview_send(&mut client, 5, now);
3906        let deadline = client.preview_next_send_at.get(&5).unwrap();
3907        assert!(*deadline > now);
3908    }
3909
3910    #[test]
3911    fn record_preview_send_successive_calls_advance() {
3912        let mut client = test_client();
3913        let now = Instant::now();
3914        record_preview_send(&mut client, 5, now);
3915        let first = *client.preview_next_send_at.get(&5).unwrap();
3916        record_preview_send(&mut client, 5, first);
3917        let second = *client.preview_next_send_at.get(&5).unwrap();
3918        assert!(second > first, "successive sends should advance deadline");
3919    }
3920
3921    // ── congestion control end-to-end properties ──
3922    //
3923    // These tests encode the two goals of the congestion controller:
3924    //   1. Browser-ready, well-provisioned path → full display FPS, minimal added latency
3925    //   2. Bottleneck                           → lowest sustainable FPS, fast recovery when pipe clears
3926    //
3927    // Some tests assert desired future behaviour and currently FAIL due to
3928    // known issues (min_rtt contamination, lead_floor dominating byte window).
3929    // They are marked with a comment so they are easy to find when fixing.
3930
3931    /// Return a client in ideal low-latency, high-bandwidth conditions:
3932    /// browser ready, abundant bandwidth, and tiny RTT. The normal pacing path
3933    /// should still reach display_fps.
3934    fn browser_ready_high_bandwidth_client() -> ClientState {
3935        let mut c = test_client();
3936        c.display_fps = 120.0;
3937        c.rtt_ms = 1.0;
3938        c.min_rtt_ms = 1.0;
3939        c.goodput_bps = 50_000_000.0;
3940        c.delivery_bps = 50_000_000.0;
3941        c.last_goodput_sample_bps = 50_000_000.0;
3942        c.avg_paced_frame_bytes = 30_000.0;
3943        c.avg_preview_frame_bytes = 1_024.0;
3944        c.avg_frame_bytes = 30_000.0;
3945        c.browser_apply_ms = 0.3;
3946        c
3947    }
3948
3949    /// Return a client that has converged to a clearly congested state:
3950    /// ~10× min_rtt inflation, low goodput.
3951    fn congested_client() -> ClientState {
3952        let mut c = test_client();
3953        c.display_fps = 120.0;
3954        c.rtt_ms = 500.0;
3955        c.min_rtt_ms = 40.0;
3956        c.goodput_bps = 200_000.0;
3957        c.delivery_bps = 150_000.0;
3958        c.last_goodput_sample_bps = 200_000.0;
3959        c.avg_paced_frame_bytes = 50_000.0;
3960        c.avg_preview_frame_bytes = 1_024.0;
3961        c.avg_frame_bytes = 50_000.0;
3962        c.goodput_jitter_bps = 50_000.0;
3963        c.max_goodput_jitter_bps = 200_000.0;
3964        c.browser_apply_ms = 1.0;
3965        c
3966    }
3967
3968    /// Simulate one ACK: insert a frame with the given RTT into inflight and
3969    /// call record_ack.  Forces a goodput-window sample each call so that
3970    /// goodput estimates respond within a few calls.
3971    fn sim_ack(client: &mut ClientState, bytes: usize, rtt_ms: f32) {
3972        let sent_at = Instant::now() - Duration::from_millis(rtt_ms as u64);
3973        client.inflight_bytes += bytes;
3974        client.inflight_frames.push_back(InFlightFrame {
3975            sent_at,
3976            bytes,
3977            paced: true,
3978        });
3979        // Age the goodput window so record_ack always emits a sample.
3980        client.goodput_window_start = Instant::now() - Duration::from_millis(25);
3981        record_ack(client);
3982    }
3983
3984    fn sim_acks(client: &mut ClientState, n: usize, bytes: usize, rtt_ms: f32) {
3985        for _ in 0..n {
3986            sim_ack(client, bytes, rtt_ms);
3987        }
3988    }
3989
3990    // ── property: full FPS on a browser-ready path ──
3991
3992    #[test]
3993    fn browser_ready_high_bandwidth_client_uses_full_display_fps() {
3994        let client = browser_ready_high_bandwidth_client();
3995        assert!(
3996            (pacing_fps(&client) - client.display_fps).abs() < 0.01,
3997            "pacing_fps {} should equal display_fps {} when browser is ready and bandwidth is abundant",
3998            pacing_fps(&client),
3999            client.display_fps,
4000        );
4001    }
4002
4003    #[test]
4004    fn browser_ready_high_bandwidth_client_send_interval_within_one_frame() {
4005        let client = browser_ready_high_bandwidth_client();
4006        let interval_ms = send_interval(&client).as_secs_f32() * 1000.0;
4007        let frame_ms = 1000.0 / client.display_fps;
4008        assert!(
4009            interval_ms <= frame_ms + 0.1,
4010            "send_interval {interval_ms:.2}ms exceeds one frame ({frame_ms:.2}ms) when browser is ready"
4011        );
4012    }
4013
4014    // ── property: degraded FPS when bottlenecked ──
4015
4016    #[test]
4017    fn congested_pipe_reduces_pacing_fps_substantially() {
4018        let client = congested_client();
4019        let fps = pacing_fps(&client);
4020        assert!(
4021            fps < client.display_fps * 0.5,
4022            "pacing_fps {fps:.0} should be well below display_fps {} when congested",
4023            client.display_fps,
4024        );
4025    }
4026
4027    #[test]
4028    fn congested_pipe_is_throughput_limited() {
4029        let client = congested_client();
4030        assert!(
4031            throughput_limited(&client),
4032            "congested client must be recognised as throughput-limited"
4033        );
4034    }
4035
4036    // ── property: byte window should stay near BDP ──
4037    //
4038    // KNOWN FAILING: lead_floor in target_byte_window overrides the BDP
4039    // budget when avg_paced_frame_bytes is large.  Fix: cap lead_floor.
4040
4041    #[test]
4042    fn byte_window_bounded_near_bdp_when_congested() {
4043        let client = congested_client();
4044        // BDP at the unloaded path RTT.
4045        let bdp = client.goodput_bps * (path_rtt_ms(&client) / 1_000.0);
4046        let window = target_byte_window(&client);
4047        assert!(
4048            window < bdp as usize * 8,
4049            "byte window {window}B is {:.1}× BDP ({bdp:.0}B); \
4050             expected ≤ 8× — lead_floor may be dominating",
4051            window as f32 / bdp.max(1.0),
4052        );
4053    }
4054
4055    // ── property: min_rtt must not drift upward under congestion ──
4056    //
4057    // KNOWN FAILING: the `min_rtt_ms * 0.999 + rtt_ms * 0.001` update
4058    // bleeds queued RTT into min_rtt.
4059
4060    #[test]
4061    fn min_rtt_not_contaminated_by_congested_rtts() {
4062        let mut client = test_client();
4063        client.display_fps = 120.0;
4064        client.rtt_ms = 40.0;
4065        client.min_rtt_ms = 40.0;
4066        client.goodput_bps = 2_000_000.0;
4067        client.delivery_bps = 2_000_000.0;
4068        client.avg_paced_frame_bytes = 30_000.0;
4069        client.avg_preview_frame_bytes = 1_024.0;
4070        let original_min = client.min_rtt_ms;
4071
4072        // 200 ACKs arriving with 500ms RTT (severe congestion).
4073        sim_acks(&mut client, 200, 30_000, 500.0);
4074
4075        assert!(
4076            client.min_rtt_ms < original_min * 2.0,
4077            "min_rtt drifted from {original_min}ms to {:.1}ms after 200 congested ACKs",
4078            client.min_rtt_ms,
4079        );
4080    }
4081
4082    // ── property: fast recovery when congestion clears ──
4083
4084    #[test]
4085    fn delivery_bps_rises_quickly_when_congestion_clears() {
4086        let mut client = congested_client();
4087        let before = client.delivery_bps;
4088
4089        // 10 ACKs at low latency / high throughput.
4090        sim_acks(&mut client, 10, 30_000, 40.0);
4091
4092        assert!(
4093            client.delivery_bps > before * 2.0,
4094            "delivery_bps {:.0} should more than double from {before:.0} after 10 fast ACKs",
4095            client.delivery_bps,
4096        );
4097    }
4098
4099    #[test]
4100    fn pacing_fps_recovers_after_congestion_clears() {
4101        let mut client = congested_client();
4102
4103        // Use window-saturated rounds: fill the window with frames, age the
4104        // goodput window once, then ACK all.  The first ACK each round emits
4105        // a sample; the remaining target-1 ACKs carry over into the next
4106        // window, so sample throughput grows as target grows — mimicking a
4107        // real link where the sender keeps the pipe full across one RTT.
4108        for _ in 0..40 {
4109            let target = target_frame_window(&client).max(2);
4110            for _ in 0..target {
4111                let sent_at = Instant::now() - Duration::from_millis(40);
4112                client.inflight_bytes += 30_000;
4113                client.inflight_frames.push_back(InFlightFrame {
4114                    sent_at,
4115                    bytes: 30_000,
4116                    paced: true,
4117                });
4118            }
4119            client.goodput_window_start = Instant::now() - Duration::from_millis(25);
4120            for _ in 0..target {
4121                record_ack(&mut client);
4122            }
4123        }
4124
4125        let fps = pacing_fps(&client);
4126        assert!(
4127            fps > client.display_fps * 0.7,
4128            "pacing_fps {fps:.0} didn't recover toward display_fps {} \
4129             after window-saturated rounds at low RTT",
4130            client.display_fps,
4131        );
4132    }
4133
4134    #[test]
4135    fn rtt_estimate_drops_quickly_when_congestion_clears() {
4136        let mut client = test_client();
4137        client.rtt_ms = 500.0;
4138        client.min_rtt_ms = 40.0;
4139        client.goodput_bps = 2_000_000.0;
4140        client.avg_paced_frame_bytes = 30_000.0;
4141        client.avg_preview_frame_bytes = 1_024.0;
4142
4143        // The asymmetric EWMA uses rise=0.125, fall=0.25, so rtt_ms drops
4144        // at fall_alpha=0.25 per sample toward the new low.
4145        sim_acks(&mut client, 10, 30_000, 40.0);
4146
4147        assert!(
4148            client.rtt_ms < 300.0,
4149            "rtt_ms {:.0}ms did not fall fast enough after congestion cleared",
4150            client.rtt_ms,
4151        );
4152    }
4153
4154    // ── property: probing ──
4155
4156    #[test]
4157    fn probe_collapses_immediately_on_queue_delay() {
4158        let mut client = test_client();
4159        client.display_fps = 120.0;
4160        client.rtt_ms = 40.0;
4161        client.min_rtt_ms = 40.0;
4162        client.goodput_bps = 5_000_000.0;
4163        client.delivery_bps = 5_000_000.0;
4164        client.last_goodput_sample_bps = 5_000_000.0;
4165        client.avg_paced_frame_bytes = 10_000.0;
4166        client.avg_preview_frame_bytes = 1_024.0;
4167        client.probe_frames = 10.0;
4168
4169        // ACKs arriving with high RTT signal queue buildup.
4170        sim_acks(&mut client, 5, 10_000, 600.0);
4171
4172        assert!(
4173            client.probe_frames < 5.0,
4174            "probe_frames {:.1} should have collapsed on queue delay signal",
4175            client.probe_frames,
4176        );
4177    }
4178
4179    #[test]
4180    fn probe_grows_when_window_saturated_with_clean_rtt() {
4181        let mut client = test_client();
4182        client.display_fps = 120.0;
4183        client.rtt_ms = 40.0;
4184        client.min_rtt_ms = 40.0;
4185        client.goodput_bps = 5_000_000.0;
4186        client.delivery_bps = 5_000_000.0;
4187        client.last_goodput_sample_bps = 5_000_000.0;
4188        client.avg_paced_frame_bytes = 10_000.0;
4189        client.avg_preview_frame_bytes = 1_024.0;
4190        client.goodput_jitter_bps = 0.0;
4191        client.max_goodput_jitter_bps = 0.0;
4192        client.probe_frames = 0.0;
4193
4194        // Saturate inflight so window_saturated returns true during acks.
4195        let target = target_frame_window(&client);
4196        for _ in 0..target {
4197            let sent_at = Instant::now() - Duration::from_millis(40);
4198            client.inflight_bytes += 10_000;
4199            client.inflight_frames.push_back(InFlightFrame {
4200                sent_at,
4201                bytes: 10_000,
4202                paced: true,
4203            });
4204        }
4205
4206        // Ack one frame with clean RTT.  One saturated ACK is sufficient to
4207        // verify the property: as probe_frames increments, target_frame_window
4208        // grows, so the remaining (target-1) frames would fall below the 90%
4209        // threshold and trigger gentle decay.  The property under test is that
4210        // *receiving an ACK while window-saturated* increments probe_frames —
4211        // not that it stays incremented across subsequent unsaturated ACKs.
4212        // Also: do NOT age the goodput window — that would emit a per-frame
4213        // sample far below goodput_bps, spiking jitter and collapsing probe.
4214        record_ack(&mut client);
4215
4216        assert!(
4217            client.probe_frames > 0.0,
4218            "probe_frames should grow when window-saturated with clean RTT"
4219        );
4220    }
4221
4222    // ── property: frame window larger on high-latency links ──
4223
4224    #[test]
4225    fn frame_window_larger_on_high_latency_link() {
4226        let mut lo = test_client();
4227        lo.display_fps = 120.0;
4228        lo.rtt_ms = 10.0;
4229        lo.min_rtt_ms = 10.0;
4230        lo.goodput_bps = 5_000_000.0;
4231        lo.delivery_bps = 5_000_000.0;
4232        lo.avg_paced_frame_bytes = 10_000.0;
4233        lo.avg_preview_frame_bytes = 1_024.0;
4234
4235        let mut hi = test_client();
4236        hi.display_fps = 120.0;
4237        hi.rtt_ms = 200.0;
4238        hi.min_rtt_ms = 200.0;
4239        hi.goodput_bps = 5_000_000.0;
4240        hi.delivery_bps = 5_000_000.0;
4241        hi.avg_paced_frame_bytes = 10_000.0;
4242        hi.avg_preview_frame_bytes = 1_024.0;
4243
4244        let lo_win = target_frame_window(&lo);
4245        let hi_win = target_frame_window(&hi);
4246        assert!(
4247            hi_win > lo_win,
4248            "high-latency link ({hi_win}f) should need more frames in flight \
4249             than low-latency ({lo_win}f)"
4250        );
4251    }
4252
4253    // ── property: small-frame byte window allows pipelining ──
4254
4255    #[test]
4256    fn small_frame_byte_window_enables_pipelining() {
4257        // Tiny terminal frames (~1KB) with a stale congested RTT and low
4258        // goodput estimate (stop-and-wait artifact): byte window must be at
4259        // least target_frame_window × frame_bytes so the sender can pipeline
4260        // rather than stay stuck in stop-and-wait.
4261        let mut client = test_client();
4262        client.display_fps = 120.0;
4263        client.rtt_ms = 165.0;
4264        client.min_rtt_ms = 8.0;
4265        client.goodput_bps = 11_000.0; // stop-and-wait artifact
4266        client.delivery_bps = 6_800.0;
4267        client.last_goodput_sample_bps = 11_000.0;
4268        client.avg_paced_frame_bytes = 1_120.0;
4269        client.avg_preview_frame_bytes = 1_024.0;
4270        client.goodput_jitter_bps = 4_300.0;
4271        client.max_goodput_jitter_bps = 6_500.0;
4272
4273        let window = target_byte_window(&client);
4274        let frames = target_frame_window(&client);
4275        let pipeline = frames * 1_120;
4276
4277        assert!(
4278            window >= pipeline,
4279            "byte window {window}B should be >= pipeline ({frames}f × 1120B = {pipeline}B) \
4280             so small frames can pipeline across the RTT"
4281        );
4282    }
4283
4284    #[test]
4285    fn large_frame_byte_window_bounded_by_one_frame_floor() {
4286        // With large frames (50KB), pipelining the full frame window (5×50KB=250KB)
4287        // would be many multiples of BDP.  Byte window should fall back to
4288        // the one-frame floor so the BDP budget governs.
4289        let mut client = test_client();
4290        client.display_fps = 120.0;
4291        client.rtt_ms = 165.0;
4292        client.min_rtt_ms = 8.0;
4293        client.goodput_bps = 11_000.0;
4294        client.delivery_bps = 6_800.0;
4295        client.last_goodput_sample_bps = 11_000.0;
4296        client.avg_paced_frame_bytes = 50_000.0; // large frame
4297        client.avg_preview_frame_bytes = 1_024.0;
4298        client.goodput_jitter_bps = 0.0;
4299        client.max_goodput_jitter_bps = 0.0;
4300
4301        let window = target_byte_window(&client);
4302        let frames = target_frame_window(&client);
4303        let pipeline = frames.saturating_mul(50_000);
4304
4305        assert!(
4306            window < pipeline,
4307            "byte window {window}B should be < full pipeline {pipeline}B \
4308             ({frames}f × 50KB) — large frames must use one-frame floor"
4309        );
4310        assert!(
4311            window >= 50_000,
4312            "byte window {window}B must be at least one frame (50KB)"
4313        );
4314    }
4315
4316    // ── property: preview reservation applies uniformly ──
4317
4318    #[test]
4319    fn preview_reservation_applies_even_on_low_latency_high_bandwidth_links() {
4320        let mut client = browser_ready_high_bandwidth_client();
4321        client.lead = Some(1);
4322        client.subscriptions.insert(1);
4323        let target = target_frame_window(&client);
4324        fill_inflight(&mut client, target.saturating_sub(1), 512);
4325        assert!(
4326            !lead_window_open(&client, true),
4327            "preview reservation should apply uniformly for lead clients"
4328        );
4329    }
4330
4331    // ── property: blip recovery on healthy paths ──
4332
4333    #[test]
4334    fn probe_recovers_on_healthy_path_after_blip() {
4335        let mut client = browser_ready_high_bandwidth_client();
4336        client.probe_frames = 8.0;
4337
4338        // Blip: 3 ACKs with inflated RTT crush probes.
4339        sim_acks(&mut client, 3, 30_000, 200.0);
4340        let post_blip = client.probe_frames;
4341        assert!(
4342            post_blip < 4.0,
4343            "probe_frames {post_blip:.1} should have dropped after blip"
4344        );
4345
4346        // Reset browser metrics to healthy (browser cleared backlog).
4347        client.browser_backlog_frames = 0;
4348        client.browser_ack_ahead_frames = 0;
4349        client.browser_apply_ms = 0.3;
4350
4351        // Recovery: 20 healthy ACKs at low RTT on an underfilled path.
4352        sim_acks(&mut client, 20, 30_000, 1.0);
4353
4354        assert!(
4355            client.probe_frames > post_blip,
4356            "probe_frames {:.1} should have recovered from {post_blip:.1} after healthy ACKs",
4357            client.probe_frames,
4358        );
4359    }
4360
4361    #[test]
4362    fn jitter_decays_fast_on_browser_ready_path() {
4363        let mut client = browser_ready_high_bandwidth_client();
4364
4365        // Inject elevated jitter (simulating post-blip state).
4366        client.max_goodput_jitter_bps = client.goodput_bps * 0.4;
4367        client.goodput_jitter_bps = client.goodput_bps * 0.3;
4368        let initial_jitter = client.max_goodput_jitter_bps;
4369
4370        // 10 healthy ACKs on a browser-ready path.
4371        sim_acks(&mut client, 10, 30_000, 1.0);
4372
4373        assert!(
4374            client.max_goodput_jitter_bps < initial_jitter * 0.5,
4375            "max_goodput_jitter_bps {:.0} should have decayed below {:.0} \
4376             (50% of initial {initial_jitter:.0}) after 10 healthy ACKs on a ready path",
4377            client.max_goodput_jitter_bps,
4378            initial_jitter * 0.5,
4379        );
4380    }
4381
4382    #[test]
4383    fn byte_budget_uses_floor_when_goodput_depressed() {
4384        let mut client = browser_ready_high_bandwidth_client();
4385        client.goodput_bps = 100_000.0;
4386
4387        let budget = byte_budget_for(&client, 100.0);
4388        let floor_budget = (bandwidth_floor_bps(&client) * 100.0 / 1_000.0).ceil() as usize;
4389
4390        assert!(
4391            budget >= floor_budget,
4392            "byte_budget {budget} should be at least bandwidth_floor-based {floor_budget} \
4393             when goodput_bps is depressed but delivery_bps is high"
4394        );
4395    }
4396
4397    #[test]
4398    fn probe_floor_maintained_under_congestion_signal() {
4399        let mut client = test_client();
4400        client.display_fps = 120.0;
4401        client.rtt_ms = 40.0;
4402        client.min_rtt_ms = 40.0;
4403        client.goodput_bps = 5_000_000.0;
4404        client.delivery_bps = 5_000_000.0;
4405        client.last_goodput_sample_bps = 5_000_000.0;
4406        client.avg_paced_frame_bytes = 10_000.0;
4407        client.avg_preview_frame_bytes = 1_024.0;
4408        client.probe_frames = 10.0;
4409
4410        // Many ACKs with high RTT: probes should not drop below the floor.
4411        sim_acks(&mut client, 20, 10_000, 600.0);
4412
4413        assert!(
4414            client.probe_frames >= 1.0,
4415            "probe_frames {:.1} should not drop below the floor of 1.0",
4416            client.probe_frames,
4417        );
4418    }
4419}