Skip to main content

blit_server/
lib.rs

1use blit_alacritty::{SearchResult as AlacrittySearchResult, TerminalDriver as AlacrittyDriver};
2use blit_compositor::{CompositorCommand, CompositorEvent, CompositorHandle};
3use blit_remote::{
4    C2S_ACK, C2S_AUDIO_SUBSCRIBE, C2S_AUDIO_UNSUBSCRIBE, C2S_CLIENT_FEATURES, C2S_CLIENT_METRICS,
5    C2S_CLIPBOARD_GET, C2S_CLIPBOARD_LIST, C2S_CLIPBOARD_SET, C2S_CLOSE, C2S_COPY_RANGE,
6    C2S_CREATE, C2S_CREATE_AT, C2S_CREATE_N, C2S_CREATE2, C2S_DISPLAY_RATE, C2S_FOCUS, C2S_INPUT,
7    C2S_KILL, C2S_MOUSE, C2S_PING, C2S_QUIT, C2S_READ, C2S_RESIZE, C2S_RESTART, C2S_SCROLL,
8    C2S_SEARCH, C2S_SUBSCRIBE, C2S_SURFACE_ACK, C2S_SURFACE_CAPTURE, C2S_SURFACE_CLOSE,
9    C2S_SURFACE_FOCUS, C2S_SURFACE_INPUT, C2S_SURFACE_LIST, C2S_SURFACE_POINTER,
10    C2S_SURFACE_POINTER_AXIS, C2S_SURFACE_RESIZE, C2S_SURFACE_SUBSCRIBE, C2S_SURFACE_TEXT,
11    C2S_SURFACE_UNSUBSCRIBE, C2S_UNSUBSCRIBE, CAPTURE_FORMAT_AVIF, CAPTURE_FORMAT_PNG,
12    CREATE2_HAS_COMMAND, CREATE2_HAS_SRC_PTY, FEATURE_AUDIO, FEATURE_COMPOSITOR,
13    FEATURE_COPY_RANGE, FEATURE_CREATE_NONCE, FEATURE_RESIZE_BATCH, FEATURE_RESTART, FrameState,
14    READ_ANSI, READ_TAIL, S2C_CLOSED, S2C_CREATED, S2C_CREATED_N, S2C_LIST, S2C_PING, S2C_QUIT,
15    S2C_READY, S2C_SEARCH_RESULTS, S2C_SURFACE_CAPTURE, S2C_SURFACE_LIST, S2C_TEXT, S2C_TITLE,
16    SURFACE_FRAME_FLAG_KEYFRAME, build_update_msg, msg_hello, msg_s2c_clipboard_content,
17    msg_s2c_clipboard_list, msg_surface_app_id, msg_surface_created, msg_surface_destroyed,
18    msg_surface_encoder, msg_surface_frame, msg_surface_resized, msg_surface_title,
19};
20use std::collections::{HashMap, HashSet, VecDeque};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::time::{Duration, Instant};
24use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
25use tokio::sync::{Mutex, Notify, mpsc};
26
27#[cfg(target_os = "linux")]
28mod audio;
29mod gpu_libs;
30mod ipc;
31mod nvenc_encode;
32mod pty;
33mod surface_encoder;
34#[cfg(target_os = "linux")]
35mod vaapi_encode;
36
37pub use ipc::{IpcListener, default_ipc_path};
38use pty::{PtyHandle, PtyWriteTarget};
39pub use surface_encoder::ChromaSubsampling;
40use surface_encoder::SurfaceEncoder;
41pub use surface_encoder::SurfaceEncoderPreference;
42pub use surface_encoder::SurfaceH264EncoderPreference;
43pub use surface_encoder::SurfaceQuality;
44
45type PtyFds = Arc<std::sync::RwLock<HashMap<u16, PtyWriteTarget>>>;
46pub struct Config {
47    pub shell: String,
48    pub shell_flags: String,
49    pub scrollback: usize,
50    pub ipc_path: String,
51    pub surface_encoders: Vec<SurfaceEncoderPreference>,
52    pub surface_quality: SurfaceQuality,
53    pub chroma: ChromaSubsampling,
54    pub vaapi_device: String,
55    #[cfg(unix)]
56    pub fd_channel: Option<std::os::unix::io::RawFd>,
57    pub verbose: bool,
58    /// Maximum number of concurrent client connections (0 = unlimited).
59    pub max_connections: usize,
60    /// Maximum number of PTYs across all clients (0 = unlimited).
61    pub max_ptys: usize,
62    /// Application-level ping interval.  The server sends S2C_PING to every
63    /// client at this cadence so that transports without native keepalive
64    /// (WebRTC data channels) can detect dead connections.  0 = disabled.
65    pub ping_interval: Duration,
66    /// Skip compositor initialization (e.g. for share-only mode).
67    pub skip_compositor: bool,
68}
69
70trait PtyDriver: Send {
71    fn size(&self) -> (u16, u16);
72    fn resize(&mut self, rows: u16, cols: u16);
73    fn process(&mut self, data: &[u8]);
74    fn title(&self) -> &str;
75    fn search_result(&self, query: &str) -> Option<PtySearchResult>;
76    fn take_title_dirty(&mut self) -> bool;
77    fn cursor_position(&self) -> (u16, u16);
78    fn synced_output(&self) -> bool;
79    fn snapshot(&mut self, echo: bool, icanon: bool) -> FrameState;
80    fn scrollback_frame(&mut self, offset: usize) -> FrameState;
81    fn reset_modes(&mut self);
82    fn mouse_event(
83        &self,
84        type_: u8,
85        button: u8,
86        col: u16,
87        row: u16,
88        echo: bool,
89        icanon: bool,
90    ) -> Option<Vec<u8>>;
91    fn get_text_range(
92        &self,
93        start_tail: u32,
94        start_col: u16,
95        end_tail: u32,
96        end_col: u16,
97    ) -> String;
98    fn total_lines(&self) -> u32;
99}
100
101struct PtySearchResult {
102    score: u32,
103    primary_source: u8,
104    matched_sources: u8,
105    context: String,
106    scroll_offset: Option<usize>,
107}
108
109impl PtyDriver for AlacrittyDriver {
110    fn size(&self) -> (u16, u16) {
111        AlacrittyDriver::size(self)
112    }
113
114    fn resize(&mut self, rows: u16, cols: u16) {
115        AlacrittyDriver::resize(self, rows, cols);
116    }
117
118    fn process(&mut self, data: &[u8]) {
119        AlacrittyDriver::process(self, data);
120    }
121
122    fn title(&self) -> &str {
123        AlacrittyDriver::title(self)
124    }
125
126    fn search_result(&self, query: &str) -> Option<PtySearchResult> {
127        AlacrittyDriver::search_result(self, query).map(|result: AlacrittySearchResult| {
128            PtySearchResult {
129                score: result.score,
130                primary_source: result.primary_source as u8,
131                matched_sources: result.matched_sources,
132                context: result.context,
133                scroll_offset: result.scroll_offset,
134            }
135        })
136    }
137
138    fn take_title_dirty(&mut self) -> bool {
139        AlacrittyDriver::take_title_dirty(self)
140    }
141
142    fn cursor_position(&self) -> (u16, u16) {
143        AlacrittyDriver::cursor_position(self)
144    }
145
146    fn synced_output(&self) -> bool {
147        AlacrittyDriver::synced_output(self)
148    }
149
150    fn snapshot(&mut self, echo: bool, icanon: bool) -> FrameState {
151        AlacrittyDriver::snapshot(self, echo, icanon)
152    }
153
154    fn scrollback_frame(&mut self, offset: usize) -> FrameState {
155        AlacrittyDriver::scrollback_frame(self, offset)
156    }
157
158    fn reset_modes(&mut self) {
159        AlacrittyDriver::reset_modes(self);
160    }
161
162    fn mouse_event(
163        &self,
164        type_: u8,
165        button: u8,
166        col: u16,
167        row: u16,
168        echo: bool,
169        icanon: bool,
170    ) -> Option<Vec<u8>> {
171        AlacrittyDriver::mouse_event(self, type_, button, col, row, echo, icanon)
172    }
173
174    fn get_text_range(
175        &self,
176        start_tail: u32,
177        start_col: u16,
178        end_tail: u32,
179        end_col: u16,
180    ) -> String {
181        AlacrittyDriver::get_text_range(self, start_tail, start_col, end_tail, end_col)
182    }
183
184    fn total_lines(&self) -> u32 {
185        AlacrittyDriver::total_lines(self)
186    }
187}
188
189// Soft backpressure thresholds.  The outbox channel is unbounded so messages
190// are never dropped, but production is throttled (via `window_open` /
191// `surface_window_open`) once either counter exceeds these limits.
192const OUTBOX_SOFT_QUEUE_LIMIT_FRAMES: usize = 4;
193// Must comfortably hold one keyframe at 1920x1080 from a software encoder
194// (200-400 KB is typical).  Setting this too low deadlocks the outbox gate
195// when a single frame exceeds the cap — surface_window_open returns false
196// even at outbox=1, and no new frames can ever be produced.
197const OUTBOX_SOFT_QUEUE_LIMIT_BYTES: usize = 1024 * 1024;
198const PREVIEW_FRAME_RESERVE: usize = 1;
199const READY_FRAME_QUEUE_CAP: usize = 4;
200const PTY_CHANNEL_CAPACITY: usize = 64;
201const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l";
202
203/// Number of surface frames to send at wire speed after a keyframe request
204/// (subscribe, resubscribe, or error recovery).  During this burst window
205/// only outbox backpressure gates delivery — the time-based pacing interval
206/// is skipped.  This lets bandwidth estimates ramp up quickly on high-latency
207/// links instead of starving the pipeline with conservative initial rates.
208const SURFACE_BURST_FRAMES: u8 = 4;
209
210/// A chunk of data from the PTY reader, sent through a lock-free channel
211/// so the reader never contends with the delivery tick for the Session mutex.
212enum PtyInput {
213    /// Raw bytes from the PTY, with the reader's sync-scan tail for boundary
214    /// detection. The tick task calls `process()` + `respond_to_queries()`.
215    Data(Vec<u8>),
216    /// Data up to and including a sync-output-close (`\x1b[?2026l`).
217    /// Process `before` and then take a snapshot.  Any bytes following the
218    /// boundary are sent in a subsequent `Data` or `SyncBoundary` event —
219    /// the reader's loop re-scans them, so this event must not try to
220    /// process them itself.
221    SyncBoundary { before: Vec<u8> },
222    /// The PTY fd hit EOF or an error — the child likely exited.
223    Eof,
224}
225
226const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
227
228async fn read_frame(reader: &mut (impl AsyncRead + Unpin)) -> Option<Vec<u8>> {
229    let mut len_buf = [0u8; 4];
230    reader.read_exact(&mut len_buf).await.ok()?;
231    let len = u32::from_le_bytes(len_buf) as usize;
232    if len == 0 {
233        return Some(vec![]);
234    }
235    if len > MAX_FRAME_SIZE {
236        return None;
237    }
238    let mut buf = vec![0u8; len];
239    reader.read_exact(&mut buf).await.ok()?;
240    Some(buf)
241}
242
243async fn write_frame(writer: &mut (impl AsyncWrite + Unpin), payload: &[u8]) -> bool {
244    if payload.len() > u32::MAX as usize {
245        return false;
246    }
247    let len = payload.len() as u32;
248    let mut buf = Vec::with_capacity(4 + payload.len());
249    buf.extend_from_slice(&len.to_le_bytes());
250    buf.extend_from_slice(payload);
251    writer.write_all(&buf).await.is_ok()
252}
253
254/// Write a length-prefixed frame, draining any pending audio frames
255/// before the bulk write.  Audio frames are complete length-prefixed
256/// messages on the same stream, so they MUST be written between bulk
257/// frames — never interleaved within one.
258///
259/// Previous code inserted audio frames between write-syscall chunks of
260/// a single bulk frame.  This corrupted the stream: the reader does
261/// `read_exact(len, 4)` then `read_exact(payload, len)` and expects a
262/// contiguous `4+len` bytes.  Inserting audio bytes inside that span
263/// corrupts the payload and desynchronises every subsequent frame.
264async fn write_frame_interleaved(
265    writer: &mut (impl AsyncWrite + Unpin),
266    payload: &[u8],
267    audio_rx: &mut mpsc::UnboundedReceiver<Vec<u8>>,
268) -> bool {
269    // Drain pending audio *before* the bulk frame so audio is never
270    // starved for more than one bulk-frame write time.  Audio frames
271    // are tiny (~160 B) so this is near-instant.
272    while let Ok(audio_msg) = audio_rx.try_recv() {
273        if !write_frame(writer, &audio_msg).await {
274            return false;
275        }
276    }
277    write_frame(writer, payload).await
278}
279
280struct Pty {
281    handle: PtyHandle,
282    driver: Box<dyn PtyDriver>,
283    /// Client-chosen tag set at creation time.
284    tag: String,
285    dirty: bool,
286    ready_frames: VecDeque<FrameState>,
287    /// Receives raw byte chunks from the PTY reader task without mutex contention.
288    byte_rx: mpsc::Receiver<PtyInput>,
289    reader_handle: std::thread::JoinHandle<()>,
290    /// Cached (echo, icanon) from tcgetattr; refreshed every ~250ms.
291    lflag_cache: (bool, bool),
292    lflag_last: Instant,
293    /// When we last broadcast a title update for this PTY.
294    last_title_send: Instant,
295    /// Title changed but not yet sent (debounced).
296    title_pending: bool,
297    /// The subprocess has exited but the terminal state is retained for reading.
298    exited: bool,
299    /// Exit status: WEXITSTATUS if normal exit, negative signal number if signalled,
300    /// EXIT_STATUS_UNKNOWN if not yet collected.
301    exit_status: i32,
302    /// Command used to create this PTY (None = default shell).
303    command: Option<String>,
304}
305
306impl Pty {
307    fn mark_dirty(&mut self) {
308        self.dirty = true;
309    }
310
311    fn clear_dirty(&mut self) {
312        self.dirty = false;
313    }
314}
315
316struct CachedSurfaceInfo {
317    surface_id: u16,
318    parent_id: u16,
319    width: u16,
320    height: u16,
321    title: String,
322    app_id: String,
323}
324
325/// Last committed pixel buffer for a surface, kept so we can re-encode a
326/// keyframe for late-joining clients without going back to the compositor.
327struct LastPixels {
328    width: u32,
329    height: u32,
330    pixels: blit_compositor::PixelData,
331    /// Monotonically increasing counter bumped on every SurfaceCommit.
332    /// Used to skip re-encoding when the pixel data hasn't changed.
333    generation: u64,
334    /// CLOCK_MONOTONIC milliseconds captured at compositor commit time.
335    /// Used as the surface frame timestamp so the client sees the source's
336    /// presentation timing rather than the (jittery) encode-delivery clock.
337    timestamp_ms: u32,
338}
339
340struct SharedCompositor {
341    handle: CompositorHandle,
342    surfaces: HashMap<u16, CachedSurfaceInfo>,
343    /// Latest pixel snapshot per surface.
344    last_pixels: HashMap<u16, LastPixels>,
345    /// Per-surface timestamp of the last RequestFrame sent.  Used to
346    /// throttle requests to at most one per 1 ms so frame callbacks
347    /// carry distinct `elapsed_ms` timestamps — video players (mpv)
348    /// use these to pace their presentation clock.  Supports up to 1 kHz.
349    last_frame_request: HashMap<u16, Instant>,
350    created_at: Instant,
351    /// Monotonically increasing counter for pixel generations.
352    pixel_generation: u64,
353    /// Last time we sent blanket RequestFrame for all surfaces (including
354    /// those without subscribers).  Throttled to prevent hot-looping when
355    /// apps commit at high rates without any client consuming frames.
356    last_blanket_frame_request: Instant,
357    /// Last dimensions sent to the compositor via `CompositorCommand::SurfaceResize`.
358    /// Used to dedup resize commands — the composited output size
359    /// (`info.width`/`info.height`) may differ from the requested size
360    /// when the Wayland client sets `xdg_geometry` (e.g. excluding a
361    /// title bar), so we compare against the actually-requested values.
362    last_configured_size: HashMap<u16, (u16, u16, u16)>,
363    /// Audio capture pipeline (PipeWire → pw-cat → Opus encode).
364    /// `None` when PipeWire is not available or `BLIT_AUDIO=0`.
365    #[cfg(target_os = "linux")]
366    audio_pipeline: Option<audio::AudioPipeline>,
367    /// Compositor instance ID passed to `AudioPipeline::spawn()` so restarts
368    /// reuse the same audio runtime directory.
369    #[cfg(target_os = "linux")]
370    audio_session_id: u16,
371    /// When the last audio pipeline restart was attempted.  Used to enforce a
372    /// cooldown so we don't spin on persistent failures.
373    #[cfg(target_os = "linux")]
374    last_audio_restart: Option<Instant>,
375}
376
377fn encode_rgba_to_png(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
378    let mut buf = Vec::new();
379    {
380        let expected = (width as usize) * (height as usize) * 4;
381        let actual = pixels.len();
382        if actual != expected {
383            // Size mismatch — return a 1×1 red pixel PNG rather than panicking.
384            let mut encoder = png::Encoder::new(&mut buf, 1, 1);
385            encoder.set_color(png::ColorType::Rgba);
386            encoder.set_depth(png::BitDepth::Eight);
387            let mut writer = encoder.write_header().unwrap();
388            writer.write_image_data(&[255, 0, 0, 255]).unwrap();
389            eprintln!(
390                "[capture] pixel buffer size mismatch: {width}x{height} expected {expected} got {actual}"
391            );
392        } else {
393            let mut encoder = png::Encoder::new(&mut buf, width, height);
394            encoder.set_color(png::ColorType::Rgba);
395            encoder.set_depth(png::BitDepth::Eight);
396            let mut writer = encoder.write_header().unwrap();
397            writer.write_image_data(pixels).unwrap();
398        }
399    }
400    buf
401}
402
403/// Encode RGBA pixels to AVIF.  `quality` 0 = lossless, 1–100 = lossy.
404fn encode_rgba_to_avif(pixels: &[u8], width: u32, height: u32, quality: u8) -> Vec<u8> {
405    let rgba: Vec<rgb::RGBA8> = pixels
406        .chunks_exact(4)
407        .map(|c| rgb::RGBA8::new(c[0], c[1], c[2], c[3]))
408        .collect();
409    let img = ravif::Img::new(&rgba[..], width as usize, height as usize);
410    let q = if quality == 0 { 100.0 } else { quality as f32 };
411    let encoder = ravif::Encoder::new()
412        .with_quality(q)
413        .with_alpha_quality(q)
414        .with_speed(6)
415        .with_alpha_color_mode(ravif::AlphaColorMode::UnassociatedClean)
416        .with_num_threads(None);
417    let result = encoder.encode_rgba(img).expect("AVIF encoding failed");
418    result.avif_file
419}
420
421/// Encode RGBA pixels to the requested capture format.
422fn encode_capture(pixels: &[u8], width: u32, height: u32, format: u8, quality: u8) -> Vec<u8> {
423    match format {
424        CAPTURE_FORMAT_AVIF => encode_rgba_to_avif(pixels, width, height, quality),
425        _ => encode_rgba_to_png(pixels, width, height),
426    }
427}
428
429async fn request_surface_capture_with_timeout(
430    command_tx: std::sync::mpsc::Sender<CompositorCommand>,
431    surface_id: u16,
432    scale_120: u16,
433    timeout: Duration,
434) -> Option<(u32, u32, Vec<u8>)> {
435    let (tx, rx) = std::sync::mpsc::sync_channel(1);
436    command_tx
437        .send(CompositorCommand::Capture {
438            surface_id,
439            scale_120,
440            reply: tx,
441        })
442        .ok()?;
443
444    // The compositor replies through a blocking std::sync::mpsc channel.
445    // Wait for it off the async runtime so this request never stalls the
446    // tokio worker thread or holds the Session mutex while blocked.
447    tokio::task::spawn_blocking(move || rx.recv_timeout(timeout))
448        .await
449        .ok()?
450        .ok()
451        .flatten()
452}
453
454/// Per-surface bookkeeping for an active subscription.  Every field
455/// defaults to "no-op" so a fresh `entry(sid).or_default()` is safe
456/// even before any other state has been recorded.
457#[derive(Default)]
458struct SurfaceSubState {
459    /// Active encoder for this surface.  `None` between encode jobs
460    /// while the encoder is temporarily owned by the spawn_blocking
461    /// task (see `encode_in_flight`) or before the first encode.
462    encoder: Option<SurfaceEncoder>,
463    /// Next tick this surface may send a frame (pacing deadline).
464    next_send_at: Option<Instant>,
465    /// Frames remaining in the post-subscribe burst window that
466    /// bypass time-based pacing so bandwidth estimates ramp up fast
467    /// on high-latency links.
468    burst_remaining: u8,
469    /// True while an encoder-creation spawn_blocking task is running
470    /// for this surface.  Prevents dispatching a second creation in
471    /// parallel and (via the `needs_new_encoder` path) skips encode
472    /// dispatch until the creation task lands its result.
473    creation_in_flight: bool,
474    /// True while this surface's encoder is in an encode spawn_blocking
475    /// task.  Prevents dispatching a parallel encode for the same
476    /// surface (the encoder has been moved into the task).
477    encode_in_flight: bool,
478    /// Set if the in-flight encoder was invalidated by a codec /
479    /// quality change (resubscribe) while encoding — the completion
480    /// handler must drop the stale encoder instead of reinserting it.
481    encoder_invalidated: bool,
482    /// Pixel generation that was last encoded; used to skip re-
483    /// encoding identical pixel data on subsequent ticks.
484    last_encoded_gen: Option<u64>,
485    /// Consecutive `nal_data=None` encodes.  After too many, the
486    /// encoder is dropped so a fresh one is created on the next tick
487    /// (bounds runaway encoder-recreation loops).
488    nal_none_streak: u32,
489    /// When the streak last latched (hit the drop threshold).  Auto-
490    /// clears after a backoff so a freshly-created encoder can retry
491    /// without needing a user-driven resize/resubscribe.
492    nal_none_latched_at: Option<Instant>,
493    /// Per-surface codec support override from C2S_SURFACE_SUBSCRIBE
494    /// (bitmask of CODEC_SUPPORT_*).  0 = defer to client-wide
495    /// `surface_codec_support`.
496    codec_override: u8,
497    /// Per-surface quality override.  `None` = use server default.
498    quality_override: Option<SurfaceQuality>,
499}
500
501struct ClientState {
502    tx: mpsc::UnboundedSender<Vec<u8>>,
503    outbox_queued_frames: Arc<AtomicUsize>,
504    outbox_queued_bytes: Arc<AtomicUsize>,
505    /// Dedicated channel for audio frames.  The writer task selects on this
506    /// with higher priority than the main outbox so audio is never starved
507    /// by large video/terminal messages.
508    audio_tx: mpsc::UnboundedSender<Vec<u8>>,
509    lead: Option<u16>,
510    subscriptions: HashSet<u16>,
511    /// Active surface subscriptions for this client.
512    surface_subscriptions: HashSet<u16>,
513    /// Whether this client is subscribed to audio frames.
514    audio_subscribed: bool,
515    /// Per-client audio bitrate preference in kbps from C2S_AUDIO_SUBSCRIBE.
516    /// 0 means use the server/env default.
517    #[cfg(target_os = "linux")]
518    audio_bitrate_kbps: u16,
519    view_sizes: HashMap<u16, (u16, u16)>,
520    scroll_offsets: HashMap<u16, usize>,
521    scroll_caches: HashMap<u16, FrameState>,
522    last_sent: HashMap<u16, FrameState>,
523    preview_next_send_at: HashMap<u16, Instant>,
524    /// EWMA RTT estimate in milliseconds.
525    rtt_ms: f32,
526    /// Minimum-path RTT estimate in milliseconds, excluding queue growth.
527    min_rtt_ms: f32,
528    /// Client's measured display refresh rate (fps), reported via C2S_DISPLAY_RATE.
529    display_fps: f32,
530    /// EWMA of delivered payload rate in bytes/sec.
531    delivery_bps: f32,
532    /// EWMA of actual ACKed goodput in bytes/sec, based on ACK cadence rather than RTT.
533    goodput_bps: f32,
534    /// EWMA of absolute goodput sample-to-sample jitter in bytes/sec.
535    goodput_jitter_bps: f32,
536    /// Decaying peak goodput jitter in bytes/sec.
537    max_goodput_jitter_bps: f32,
538    /// Last sampled ACK goodput for jitter estimation.
539    last_goodput_sample_bps: f32,
540    /// EWMA of acknowledged frame payload size in bytes.
541    avg_frame_bytes: f32,
542    /// EWMA of acknowledged lead/paced frame payload size in bytes.
543    avg_paced_frame_bytes: f32,
544    /// EWMA of acknowledged preview/unpaced frame payload size in bytes.
545    avg_preview_frame_bytes: f32,
546    /// EWMA of surface (video) frame payload size in bytes.  Tracked
547    /// separately from terminal frame sizes so surface pacing uses
548    /// `goodput_bps / avg_surface_frame_bytes` without polluting
549    /// terminal congestion control estimates.
550    avg_surface_frame_bytes: f32,
551    /// Payload bytes currently in flight (sent, not yet ACKed).
552    inflight_bytes: usize,
553    /// Oldest in-flight frame first; ACKs arrive in order.
554    inflight_frames: VecDeque<InFlightFrame>,
555    /// Earliest time the next visual update should be sent for smooth pacing.
556    next_send_at: Instant,
557    /// Temporary additive window growth used to probe for more throughput after
558    /// a conservative backoff. Decays when queue delay grows.
559    probe_frames: f32,
560    /// Diagnostics.
561    frames_sent: u32,
562    acks_recv: u32,
563    acked_bytes_since_log: usize,
564    browser_backlog_frames: u16,
565    browser_ack_ahead_frames: u16,
566    browser_apply_ms: f32,
567    last_metrics_update: Instant,
568    last_log: Instant,
569    /// Throttle timestamp for "[surface-gate] blocked" diagnostic logs.
570    last_window_blocked_log: Instant,
571    /// Throttle timestamp for "[encode-skip]" diagnostic logs.
572    last_skip_log: Instant,
573    /// Counters for silent encode-skip paths, reset each pacing log tick.
574    skip_same_gen_count: u32,
575    skip_in_flight_count: u32,
576    skip_pacing_count: u32,
577    skip_vulkan_await_count: u32,
578    /// Client had no subscriptions when encode pass ran.
579    skip_no_subs_count: u32,
580    /// Client not subscribed to a given sid in pixel_snapshot.
581    skip_not_subbed_count: u32,
582    /// last_pixels entry missing / dimensions mismatched pixel_snapshot.
583    skip_last_pixels_mismatch_count: u32,
584    /// Iterations through pixel_snapshot for this client (sanity check).
585    encode_loop_iters: u32,
586    goodput_window_bytes: usize,
587    goodput_window_start: Instant,
588    /// Per-surface encode/pacing/override state.  Holds every piece of
589    /// bookkeeping the encode loop maintains between frames for a
590    /// subscribed surface.  Entries are created lazily via
591    /// `entry(sid).or_default()` on first touch and dropped wholesale
592    /// on UNSUBSCRIBE / SurfaceDestroyed.
593    surface_subs: HashMap<u16, SurfaceSubState>,
594    surface_needs_keyframe: bool,
595    /// Surfaces that use Vulkan Video encoding in the compositor rather than
596    /// a local SurfaceEncoder.  Maps surface_id → (encoder_name, codec_flag).
597    vulkan_video_surfaces: HashMap<u16, (&'static str, u8)>,
598    /// Surface frames in flight — separate from terminal inflight so surface
599    /// ACKs feed shared RTT / goodput without corrupting terminal frame-size
600    /// averages or probe_frames.
601    surface_inflight_frames: VecDeque<InFlightFrame>,
602    /// Per-client desired surface sizes (surface_id → (width, height, scale_120, codec_support)).
603    /// Mirrors `view_sizes` for PTYs: the server mediates across all clients
604    /// and picks min(width), min(height), max(scale).
605    /// `scale_120` is the DPR in 1/120th units (Wayland convention): 240 = 2×.
606    surface_view_sizes: HashMap<u16, (u16, u16, u16)>,
607    /// Intersection of codec support across all surfaces for this client.
608    /// Used to pick an encoder the client can decode.  0 = accept anything.
609    surface_codec_support: u8,
610    /// Evdev keycodes currently held down by this client on compositor
611    /// surfaces.  On disconnect we send synthetic key-up events for each
612    /// so modifiers don't stay stuck and keys don't auto-repeat forever.
613    pressed_surface_keys: HashSet<u32>,
614}
615
616struct InFlightFrame {
617    sent_at: Instant,
618    bytes: usize,
619    paced: bool,
620}
621
622/// Frames to keep in flight: enough to cover one RTT at the client's reported
623/// display rate. High-latency links need many frames in flight to avoid
624/// devolving into stop-and-wait.
625fn frame_window(rtt_ms: f32, display_fps: f32) -> usize {
626    let frame_ms = 1_000.0 / display_fps.max(1.0);
627    let base_frames = (rtt_ms / frame_ms).ceil().max(0.0) as usize;
628    let slack_frames = ((base_frames as f32) * 0.125).ceil() as usize + 2;
629    base_frames.saturating_add(slack_frames).max(2)
630}
631
632fn path_rtt_ms(client: &ClientState) -> f32 {
633    if client.min_rtt_ms > 0.0 {
634        client.min_rtt_ms
635    } else {
636        client.rtt_ms
637    }
638}
639
640fn display_need_bps(client: &ClientState) -> f32 {
641    client.avg_paced_frame_bytes.max(256.0) * client.display_fps.max(1.0)
642}
643
644fn effective_rtt_ms(client: &ClientState) -> f32 {
645    let path_rtt = path_rtt_ms(client);
646    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
647    let queue_allowance = frame_ms
648        * if throughput_limited(client) {
649            4.0
650        } else {
651            12.0
652        };
653    client.rtt_ms.clamp(path_rtt, path_rtt + queue_allowance)
654}
655
656fn window_rtt_ms(client: &ClientState) -> f32 {
657    let effective = effective_rtt_ms(client);
658    if !throughput_limited(client) {
659        effective
660    } else {
661        client.rtt_ms.clamp(effective, effective * 2.0)
662    }
663}
664
665fn target_frame_window(client: &ClientState) -> usize {
666    let window_fps = if throughput_limited(client) {
667        pacing_fps(client)
668    } else {
669        browser_pacing_fps(client)
670    };
671    frame_window(window_rtt_ms(client), window_fps)
672        .saturating_add(client.probe_frames.round().max(0.0) as usize)
673}
674
675fn base_queue_ms(client: &ClientState) -> f32 {
676    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
677    frame_ms * if throughput_limited(client) { 2.0 } else { 8.0 }
678}
679
680fn target_queue_ms(client: &ClientState) -> f32 {
681    let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
682    let probe_scale = if throughput_limited(client) {
683        0.25
684    } else {
685        1.0
686    };
687    base_queue_ms(client) + client.probe_frames.max(0.0) * frame_ms * probe_scale
688}
689
690fn browser_ready(client: &ClientState) -> bool {
691    client.browser_ack_ahead_frames <= 1
692        && client.browser_apply_ms <= 1.0
693        && !outbox_backpressured(client)
694}
695
696fn bandwidth_floor_bps(client: &ClientState) -> f32 {
697    let browser_ready = browser_ready(client);
698    let backlog_scale = match client.browser_backlog_frames {
699        0..=2 => 0.9,
700        3..=8 => 0.8,
701        _ => 0.65,
702    };
703    let penalty = client
704        .goodput_jitter_bps
705        .max(client.max_goodput_jitter_bps * 0.5)
706        .min(client.goodput_bps * if browser_ready { 0.75 } else { 0.9 });
707    let goodput_floor = (client.goodput_bps - penalty)
708        .max(client.goodput_bps * if browser_ready { 0.35 } else { 0.2 });
709    // On a browser-ready path, the per-frame delivery estimate is already
710    // end-to-end and reacts much faster than ACK-window goodput. Halving it
711    // leaves large-frame local links chronically underpaced.
712    let delivery_floor = client.delivery_bps * if browser_ready { 1.0 } else { 0.5 };
713    let recent_sample_floor = if browser_ready && client.last_goodput_sample_bps > 0.0 {
714        client.last_goodput_sample_bps * backlog_scale
715    } else {
716        0.0
717    };
718    goodput_floor.max(recent_sample_floor).max(delivery_floor)
719}
720
721fn pacing_fps(client: &ClientState) -> f32 {
722    let frame_bytes = client.avg_paced_frame_bytes.max(256.0);
723    let sustainable = bandwidth_floor_bps(client) / frame_bytes;
724    sustainable.min(browser_pacing_fps(client))
725}
726
727fn throughput_limited(client: &ClientState) -> bool {
728    let floor = bandwidth_floor_bps(client);
729    // Consider total demand: lead at cadence rate plus previews at their cap.
730    // The old check (pacing_fps < cadence * 0.9) only saw lead bandwidth,
731    // which is often tiny, so previews could starve the lead undetected.
732    let lead_bps = client.avg_paced_frame_bytes.max(256.0) * browser_pacing_fps(client);
733    let preview_bps = client.avg_preview_frame_bytes.max(256.0) * client.display_fps.max(1.0);
734    (lead_bps + preview_bps) > floor * 0.9
735}
736
737fn browser_pacing_fps(client: &ClientState) -> f32 {
738    let mut fps = client.display_fps.max(1.0);
739
740    // Backlog and ack-ahead are direct signals from the browser about
741    // whether it's keeping up.  No predictive apply-time bound — it
742    // consistently underestimates capacity and causes 30fps death spirals.
743    //
744    // The backoff is steep: at the block threshold (backlog>8) we've
745    // already dropped to display_fps/4.  A gentler schedule (4/backlog)
746    // held 48fps at backlog=10 for software-encoded 1080p, which is
747    // faster than the browser can decode → backlog never drains, the
748    // hard block stays latched, and encoding stalls entirely.
749    //
750    // Trigger threshold (backlog > 4) gives a few frames of transient
751    // headroom before backoff engages — at 120 Hz, a 30 fps source naturally
752    // queues 1-2 frames during decoder hiccups, and triggering backoff there
753    // chops the rate just to absorb normal jitter.
754    let backlog = client.browser_backlog_frames as f32;
755    if backlog > 4.0 {
756        fps = fps.min(fps * (2.0 / backlog));
757    }
758
759    if client.browser_ack_ahead_frames > 4 {
760        fps = fps.min(client.display_fps.max(1.0) * 0.5);
761    }
762    if client.browser_ack_ahead_frames > 8 {
763        fps = fps.min(client.display_fps.max(1.0) * 0.25);
764    }
765
766    fps.max(1.0)
767}
768
769fn browser_backlog_blocked(client: &ClientState) -> bool {
770    client.browser_backlog_frames > 8
771}
772
773fn byte_budget_for(client: &ClientState, budget_ms: f32) -> usize {
774    let budget_bps = if throughput_limited(client) {
775        bandwidth_floor_bps(client)
776    } else {
777        client.goodput_bps.max(bandwidth_floor_bps(client))
778    };
779    let bytes = budget_bps * budget_ms.max(1.0) / 1_000.0;
780    bytes.ceil().max(client.avg_frame_bytes.max(256.0)) as usize
781}
782
783fn target_byte_window(client: &ClientState) -> usize {
784    let budget = byte_budget_for(client, path_rtt_ms(client) + target_queue_ms(client));
785    let frame_bytes = client.avg_paced_frame_bytes.max(256.0).ceil() as usize;
786    let target_frames = target_frame_window(client);
787    let pipeline_bytes = frame_bytes.saturating_mul(target_frames);
788    // For small pipelines (e.g. idle terminals with 1KB frames), allow the
789    // full frame window worth of bytes so we pipeline across the RTT instead
790    // of stop-and-wait.  For large pipelines (e.g. 50KB frames × 5 frames =
791    // 250KB), the budget (BDP-based) is the binding constraint; fall back to
792    // a one-frame floor so we don't pile up many RTTs worth of large frames.
793    const PIPELINE_FLOOR_LIMIT: usize = 32_768; // 32 KB
794    let floor = if pipeline_bytes <= PIPELINE_FLOOR_LIMIT {
795        pipeline_bytes
796    } else {
797        frame_bytes // one-frame floor for large pipelines
798    };
799    budget.max(floor)
800}
801
802fn send_interval(client: &ClientState) -> Duration {
803    Duration::from_secs_f64(1.0 / browser_pacing_fps(client).max(1.0) as f64)
804}
805
806fn preview_fps(client: &ClientState) -> f32 {
807    let mut fps = client.display_fps.max(1.0);
808    if client.lead.is_some() && throughput_limited(client) {
809        // Only budget preview bandwidth when the link is actually saturated.
810        // Without this, large preview frames (e.g. 12 KB) at 30 fps consume
811        // 360 KB/s, starving the lead even when lead frames are tiny.
812        // On fast links (localhost, LAN), previews run at display_fps.
813        let avail = bandwidth_floor_bps(client);
814        let lead_bps = client.avg_paced_frame_bytes.max(256.0) * browser_pacing_fps(client);
815        let preview_budget = (avail - lead_bps).max(avail * 0.25).max(0.0);
816        let bw_cap = preview_budget / client.avg_preview_frame_bytes.max(256.0);
817        fps = fps.min(bw_cap.max(1.0));
818    }
819    fps.max(1.0)
820}
821
822fn preview_send_interval(client: &ClientState) -> Duration {
823    Duration::from_secs_f64(1.0 / preview_fps(client) as f64)
824}
825
826/// Surface frame rate: delegates to `browser_pacing_fps` so video backs off
827/// when the client reports it can't keep up (backlog / ack-ahead growth).
828/// Continuing at display_fps when the browser is behind just piles up video
829/// it will drop to catch up, which the user perceives as droppy playback.
830fn surface_pacing_fps(client: &ClientState) -> f32 {
831    browser_pacing_fps(client)
832}
833
834fn surface_send_interval(client: &ClientState) -> Duration {
835    Duration::from_secs_f64(1.0 / surface_pacing_fps(client).max(1.0) as f64)
836}
837
838/// Emit a pacing-metrics line for this client if 10s have elapsed since
839/// the last one.  Called both from the ACK handler and from `tick()` so
840/// an idle client (no ACK traffic) still gets periodic metrics.
841fn maybe_log_pacing_metrics(sess: &mut Session, client_id: u64, verbose: bool) {
842    let Some(c) = sess.clients.get_mut(&client_id) else {
843        return;
844    };
845    if c.last_log.elapsed().as_secs_f32() < 10.0 {
846        return;
847    }
848    let log_elapsed = c.last_log.elapsed().as_secs_f32().max(1.0e-3);
849    let paced_fps = pacing_fps(c);
850    let display_need_bps_v = display_need_bps(c);
851    let surface_fps = surface_pacing_fps(c);
852    let frames_sent = c.frames_sent;
853    let acks_recv = c.acks_recv;
854    let rtt_ms = c.rtt_ms;
855    let min_rtt_ms = path_rtt_ms(c);
856    let eff_rtt_ms = window_rtt_ms(c);
857    let inflight_bytes = c.inflight_bytes;
858    let delivery_bps = c.delivery_bps;
859    let goodput_ewma_bps = c.goodput_bps;
860    let goodput_jitter_bps = c.goodput_jitter_bps;
861    let max_goodput_jitter_bps = c.max_goodput_jitter_bps;
862    let avg_frame_bytes = c.avg_frame_bytes;
863    let avg_paced_frame_bytes = c.avg_paced_frame_bytes;
864    let avg_preview_frame_bytes = c.avg_preview_frame_bytes;
865    let display_fps = c.display_fps;
866    let probe_frames = c.probe_frames;
867    let goodput_bps = c.acked_bytes_since_log as f32 / log_elapsed;
868    let window_frames = target_frame_window(c);
869    let window_bytes = target_byte_window(c);
870    let outbox_frames = outbox_queued_frames(c);
871    let browser_backlog_frames = c.browser_backlog_frames;
872    let browser_ack_ahead_frames = c.browser_ack_ahead_frames;
873    let browser_apply_ms = c.browser_apply_ms;
874    let avg_surface_frame_bytes = c.avg_surface_frame_bytes;
875    let skip_same_gen = c.skip_same_gen_count;
876    let skip_in_flight = c.skip_in_flight_count;
877    let skip_pacing = c.skip_pacing_count;
878    let skip_vk_await = c.skip_vulkan_await_count;
879    let skip_no_subs = c.skip_no_subs_count;
880    let skip_not_subbed = c.skip_not_subbed_count;
881    let skip_mismatch = c.skip_last_pixels_mismatch_count;
882    let loop_iters = c.encode_loop_iters;
883    let own_subs: usize = c.surface_subscriptions.len();
884    let vk_surfs = c.vulkan_video_surfaces.len();
885    let in_flight_set_len = c
886        .surface_subs
887        .values()
888        .filter(|s| s.encode_in_flight)
889        .count();
890    let surface_burst: u8 = c
891        .surface_subs
892        .values()
893        .map(|s| s.burst_remaining)
894        .max()
895        .unwrap_or(0);
896
897    c.frames_sent = 0;
898    c.acks_recv = 0;
899    c.acked_bytes_since_log = 0;
900    c.skip_same_gen_count = 0;
901    c.skip_in_flight_count = 0;
902    c.skip_pacing_count = 0;
903    c.skip_vulkan_await_count = 0;
904    c.skip_no_subs_count = 0;
905    c.skip_not_subbed_count = 0;
906    c.skip_last_pixels_mismatch_count = 0;
907    c.encode_loop_iters = 0;
908    c.last_log = Instant::now();
909
910    if verbose {
911        let surf_info = sess.compositor.as_ref().map(|cs| {
912            let surfaces = cs.surfaces.len();
913            let pending = 0usize;
914            let subs: usize = sess
915                .clients
916                .values()
917                .map(|c| c.surface_subscriptions.len())
918                .sum();
919            (surfaces, pending, subs)
920        });
921        let (surf_count, surf_pending, surf_subs) = surf_info.unwrap_or((0, 0, 0));
922        eprintln!(
923            "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_v:.0}B/s display_fps={display_fps:.0} paced_fps={paced_fps:.0} surface_fps={surface_fps:.0} surface_frame={avg_surface_frame_bytes:.0}B backlog={browser_backlog_frames} ack_ahead={browser_ack_ahead_frames} apply={browser_apply_ms:.1}ms | tick_fires={} tick_snaps={} | surfaces={surf_count} subs={surf_subs} own_subs={own_subs} pending_req={surf_pending} commits={} encodes={} enc_bytes={} surf_sent={} px_empty_ticks={} px_snap_len={} loop_iters={loop_iters} skip_same_gen={skip_same_gen} skip_in_flight={skip_in_flight} skip_pacing={skip_pacing} skip_vk_await={skip_vk_await} skip_no_subs={skip_no_subs} skip_not_subbed={skip_not_subbed} skip_mismatch={skip_mismatch} vk_surfs={vk_surfs} enc_in_flight_set={in_flight_set_len} burst={surface_burst}",
924            sess.tick_fires,
925            sess.tick_snaps,
926            sess.surface_commits,
927            sess.surface_encodes,
928            sess.surface_encode_bytes,
929            sess.surface_frames_sent,
930            sess.ticks_pixel_snapshot_empty,
931            sess.pixel_snapshot_len,
932        );
933    }
934    sess.tick_fires = 0;
935    sess.tick_snaps = 0;
936    sess.surface_commits = 0;
937    sess.surface_encodes = 0;
938    sess.surface_encode_bytes = 0;
939    sess.surface_frames_sent = 0;
940    sess.ticks_pixel_snapshot_empty = 0;
941}
942
943fn advance_deadline(deadline: &mut Instant, now: Instant, interval: Duration) {
944    let scheduled = deadline.checked_add(interval).unwrap_or(now + interval);
945    *deadline = if scheduled + interval < now {
946        now + interval
947    } else {
948        scheduled
949    };
950}
951
952fn should_snapshot_pty(dirty: bool, needful: bool, synced_output: bool) -> bool {
953    dirty && needful && !synced_output
954}
955
956fn enqueue_ready_frame(queue: &mut VecDeque<FrameState>, frame: FrameState) -> bool {
957    if queue.len() >= READY_FRAME_QUEUE_CAP {
958        return false;
959    }
960    queue.push_back(frame);
961    true
962}
963
964fn pty_has_visual_update(pty: &Pty) -> bool {
965    pty.dirty || !pty.ready_frames.is_empty() || !pty.byte_rx.is_empty()
966}
967
968/// Find the first `\x1b[?2026l` in `bytes`, handling sequences that span
969/// the `prefix`/`bytes` boundary. Uses SIMD-accelerated memchr for the
970/// initial ESC scan.
971fn find_sync_output_end(prefix: &[u8], bytes: &[u8]) -> Option<usize> {
972    if bytes.is_empty() {
973        return None;
974    }
975    let needle = SYNC_OUTPUT_END;
976    let nlen = needle.len();
977
978    // Check for a match straddling the prefix/bytes boundary.
979    if !prefix.is_empty() {
980        let tail = if prefix.len() >= nlen - 1 {
981            &prefix[prefix.len() - (nlen - 1)..]
982        } else {
983            prefix
984        };
985        let combined_len = tail.len() + bytes.len().min(nlen);
986        if combined_len >= nlen {
987            // Small stack buffer to check the boundary region.
988            let mut buf = [0u8; 32]; // SYNC_OUTPUT_END is 8 bytes, so 32 is plenty
989            let blen = combined_len.min(buf.len());
990            let tlen = tail.len().min(blen);
991            buf[..tlen].copy_from_slice(&tail[..tlen]);
992            let rest = (blen - tlen).min(bytes.len());
993            buf[tlen..tlen + rest].copy_from_slice(&bytes[..rest]);
994            for i in 0..=(blen.saturating_sub(nlen)) {
995                if &buf[i..i + nlen] == needle {
996                    let end_in_bytes = (i + nlen).saturating_sub(tail.len());
997                    if end_in_bytes > 0 && end_in_bytes <= bytes.len() {
998                        return Some(end_in_bytes);
999                    }
1000                }
1001            }
1002        }
1003    }
1004
1005    // SIMD-scan for ESC (0x1b) then verify the full sequence.
1006    let mut offset = 0;
1007    while let Some(pos) = memchr::memchr(0x1b, &bytes[offset..]) {
1008        let abs = offset + pos;
1009        if abs + nlen <= bytes.len() && &bytes[abs..abs + nlen] == needle {
1010            return Some(abs + nlen);
1011        }
1012        offset = abs + 1;
1013    }
1014    None
1015}
1016
1017fn update_sync_scan_tail(tail: &mut Vec<u8>, bytes: &[u8]) {
1018    if bytes.is_empty() {
1019        return;
1020    }
1021    tail.extend_from_slice(bytes);
1022    let keep = SYNC_OUTPUT_END.len().saturating_sub(1);
1023    if tail.len() > keep {
1024        let drop = tail.len() - keep;
1025        tail.drain(..drop);
1026    }
1027}
1028
1029fn preview_deadline(client: &ClientState, pid: u16, now: Instant) -> Instant {
1030    client
1031        .preview_next_send_at
1032        .get(&pid)
1033        .copied()
1034        .unwrap_or(now)
1035}
1036
1037fn client_has_due_preview(sess: &Session, client: &ClientState, now: Instant) -> bool {
1038    if client.lead.is_none() {
1039        return false;
1040    }
1041    client.subscriptions.iter().copied().any(|pid| {
1042        Some(pid) != client.lead
1043            && preview_deadline(client, pid, now) <= now
1044            && sess
1045                .ptys
1046                .get(&pid)
1047                .map(pty_has_visual_update)
1048                .unwrap_or(false)
1049    })
1050}
1051
1052fn outbox_queued_frames(client: &ClientState) -> usize {
1053    client.outbox_queued_frames.load(Ordering::Relaxed)
1054}
1055
1056fn outbox_queued_bytes(client: &ClientState) -> usize {
1057    client.outbox_queued_bytes.load(Ordering::Relaxed)
1058}
1059
1060fn outbox_backpressured(client: &ClientState) -> bool {
1061    // Always allow at least one frame queued, even if it exceeds the byte
1062    // soft limit.  Large keyframes from software encoders can be larger than
1063    // OUTBOX_SOFT_QUEUE_LIMIT_BYTES; treating the first queued frame as
1064    // backpressure would permanently close surface_window_open and deadlock
1065    // encoding (the one queued frame cannot drain until the sender task
1066    // flushes it, but the sender was waiting for a new frame that we
1067    // refuse to produce — deadlock).
1068    let frames = outbox_queued_frames(client);
1069    if frames >= OUTBOX_SOFT_QUEUE_LIMIT_FRAMES {
1070        return true;
1071    }
1072    frames >= 2 && outbox_queued_bytes(client) >= OUTBOX_SOFT_QUEUE_LIMIT_BYTES
1073}
1074
1075fn mark_outbox_drained(
1076    queued_frames: &Arc<AtomicUsize>,
1077    queued_bytes: &Arc<AtomicUsize>,
1078    bytes: usize,
1079) {
1080    let _ = queued_frames.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
1081        Some(value.saturating_sub(1))
1082    });
1083    let _ = queued_bytes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
1084        Some(value.saturating_sub(bytes))
1085    });
1086}
1087
1088fn send_outbox_tracked(
1089    tx: &mpsc::UnboundedSender<Vec<u8>>,
1090    queued_frames: &Arc<AtomicUsize>,
1091    queued_bytes: &Arc<AtomicUsize>,
1092    msg: Vec<u8>,
1093) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
1094    let bytes = msg.len();
1095    tx.send(msg)?;
1096    queued_frames.fetch_add(1, Ordering::Relaxed);
1097    queued_bytes.fetch_add(bytes, Ordering::Relaxed);
1098    Ok(())
1099}
1100
1101fn send_outbox(client: &ClientState, msg: Vec<u8>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
1102    send_outbox_tracked(
1103        &client.tx,
1104        &client.outbox_queued_frames,
1105        &client.outbox_queued_bytes,
1106        msg,
1107    )
1108}
1109
1110fn can_send_preview(client: &ClientState, pid: u16, now: Instant) -> bool {
1111    window_open(client) && now >= preview_deadline(client, pid, now)
1112}
1113
1114fn record_preview_send(client: &mut ClientState, pid: u16, now: Instant) {
1115    let mut deadline = client
1116        .preview_next_send_at
1117        .get(&pid)
1118        .copied()
1119        .unwrap_or(now);
1120    advance_deadline(&mut deadline, now, preview_send_interval(client));
1121    client.preview_next_send_at.insert(pid, deadline);
1122}
1123
1124fn window_open(client: &ClientState) -> bool {
1125    !browser_backlog_blocked(client)
1126        && !outbox_backpressured(client)
1127        && client.inflight_frames.len() < target_frame_window(client)
1128        && client.inflight_bytes < target_byte_window(client)
1129}
1130
1131/// Surface send gate: outbox backpressure only.  Rate is governed by
1132/// `surface_send_interval`; per-surface encode concurrency by the
1133/// `encode_in_flight` flag on `SurfaceSubState`.
1134fn surface_window_open(client: &ClientState) -> bool {
1135    !outbox_backpressured(client)
1136}
1137
1138fn lead_window_open(client: &ClientState, reserve_preview_slot: bool) -> bool {
1139    if !reserve_preview_slot || client.lead.is_none() {
1140        return window_open(client);
1141    }
1142    if browser_backlog_blocked(client) || outbox_backpressured(client) {
1143        return false;
1144    }
1145    let target_frames = target_frame_window(client);
1146    let reserve_frames = PREVIEW_FRAME_RESERVE.min(target_frames.saturating_sub(1));
1147    let frame_limit = target_frames.saturating_sub(reserve_frames).max(1);
1148    let reserve_bytes = client.avg_preview_frame_bytes.max(256.0).ceil() as usize;
1149    let byte_limit = target_byte_window(client)
1150        .saturating_sub(reserve_bytes)
1151        .max(client.avg_paced_frame_bytes.max(256.0).ceil() as usize);
1152    client.inflight_frames.len() < frame_limit && client.inflight_bytes < byte_limit
1153}
1154
1155fn can_send_frame(client: &ClientState, now: Instant, reserve_preview_slot: bool) -> bool {
1156    lead_window_open(client, reserve_preview_slot) && now >= client.next_send_at
1157}
1158
1159fn record_send(client: &mut ClientState, bytes: usize, now: Instant, paced: bool) {
1160    client.inflight_bytes += bytes;
1161    client.inflight_frames.push_back(InFlightFrame {
1162        sent_at: now,
1163        bytes,
1164        paced,
1165    });
1166    if paced {
1167        let interval = send_interval(client);
1168        advance_deadline(&mut client.next_send_at, now, interval);
1169    }
1170}
1171
1172fn ewma_with_direction(old: f32, sample: f32, rise_alpha: f32, fall_alpha: f32) -> f32 {
1173    let alpha = if sample > old { rise_alpha } else { fall_alpha };
1174    old * (1.0 - alpha) + sample * alpha
1175}
1176
1177fn window_saturated(client: &ClientState, inflight_frames: usize, inflight_bytes: usize) -> bool {
1178    let target_frames = target_frame_window(client);
1179    let target_bytes = target_byte_window(client);
1180    inflight_frames.saturating_mul(10) >= target_frames.saturating_mul(9)
1181        || inflight_bytes.saturating_mul(10) >= target_bytes.saturating_mul(9)
1182}
1183
1184fn record_ack(client: &mut ClientState) {
1185    if let Some(frame) = client.inflight_frames.pop_front() {
1186        let prev_inflight_frames = client.inflight_frames.len() + 1;
1187        let prev_inflight_bytes = client.inflight_bytes;
1188        client.inflight_bytes = client.inflight_bytes.saturating_sub(frame.bytes);
1189        client.acked_bytes_since_log = client.acked_bytes_since_log.saturating_add(frame.bytes);
1190        let sample_ms = frame.sent_at.elapsed().as_secs_f32() * 1_000.0;
1191        client.rtt_ms = ewma_with_direction(client.rtt_ms, sample_ms, 0.125, 0.25);
1192        if client.min_rtt_ms > 0.0 {
1193            // Only update downward: min_rtt tracks the unloaded path RTT and
1194            // must not drift upward during congestion (queued RTT ≠ path RTT).
1195            client.min_rtt_ms = client.min_rtt_ms.min(sample_ms);
1196        } else {
1197            client.min_rtt_ms = sample_ms;
1198        }
1199        client.min_rtt_ms = client.min_rtt_ms.max(0.5);
1200        let sample_bps = frame.bytes as f32 / sample_ms.max(1.0e-3) * 1_000.0;
1201        client.delivery_bps = ewma_with_direction(client.delivery_bps, sample_bps, 0.5, 0.125);
1202        client.avg_frame_bytes =
1203            ewma_with_direction(client.avg_frame_bytes, frame.bytes as f32, 0.5, 0.125);
1204        if frame.paced {
1205            client.avg_paced_frame_bytes =
1206                ewma_with_direction(client.avg_paced_frame_bytes, frame.bytes as f32, 0.5, 0.125);
1207        } else {
1208            client.avg_preview_frame_bytes = ewma_with_direction(
1209                client.avg_preview_frame_bytes,
1210                frame.bytes as f32,
1211                0.5,
1212                0.125,
1213            );
1214        }
1215        let frame_ms = 1_000.0 / browser_pacing_fps(client).max(1.0);
1216        let path_rtt = path_rtt_ms(client);
1217        let likely_window_limited =
1218            window_saturated(client, prev_inflight_frames, prev_inflight_bytes);
1219        client.goodput_window_bytes = client.goodput_window_bytes.saturating_add(frame.bytes);
1220        let now = Instant::now();
1221        let goodput_elapsed = now
1222            .duration_since(client.goodput_window_start)
1223            .as_secs_f32();
1224        if goodput_elapsed >= 0.02 {
1225            let sample_goodput = client.goodput_window_bytes as f32 / goodput_elapsed.max(1.0e-3);
1226            if likely_window_limited || client.browser_backlog_frames > 0 {
1227                let prev_goodput_sample = if client.last_goodput_sample_bps > 0.0 {
1228                    client.last_goodput_sample_bps
1229                } else {
1230                    sample_goodput
1231                };
1232                let jitter_sample = (sample_goodput - prev_goodput_sample).abs();
1233                client.goodput_bps =
1234                    ewma_with_direction(client.goodput_bps, sample_goodput, 0.5, 0.125);
1235                // Only update jitter from windows with at least 2 frames.
1236                // Single-frame windows are pure measurement noise (0 or 1
1237                // frame per 25 ms is a Bernoulli trial, not a congestion
1238                // signal) and inflate jitter_bps, which in turn depresses
1239                // bandwidth_floor_bps and causes pacing to stall.
1240                let min_reliable = (client.avg_paced_frame_bytes.max(256.0) * 2.0) as usize;
1241                if client.goodput_window_bytes >= min_reliable {
1242                    client.goodput_jitter_bps =
1243                        ewma_with_direction(client.goodput_jitter_bps, jitter_sample, 0.5, 0.125);
1244                    let jitter_decay = if browser_ready(client) && sample_ms < path_rtt * 3.0 {
1245                        0.90
1246                    } else {
1247                        0.98
1248                    };
1249                    client.max_goodput_jitter_bps =
1250                        (client.max_goodput_jitter_bps * jitter_decay).max(jitter_sample);
1251                    // Cap jitter at 45% of goodput so jitter_ratio can never
1252                    // exceed 0.45 from measurement noise alone.  Real congestion
1253                    // will still drive goodput_bps down and widen the window.
1254                    client.max_goodput_jitter_bps =
1255                        client.max_goodput_jitter_bps.min(client.goodput_bps * 0.45);
1256                } else {
1257                    // Thin sample: gently decay jitter rather than updating it.
1258                    client.goodput_jitter_bps *= 0.9;
1259                    client.max_goodput_jitter_bps *= 0.95;
1260                }
1261                // Sticky-high: never let last_goodput_sample_bps drop abruptly.
1262                // A sudden drop (e.g. 1-frame window following a 2-frame window)
1263                // inflates jitter_sample on the next cycle, collapsing probe_frames.
1264                client.last_goodput_sample_bps =
1265                    (client.last_goodput_sample_bps * 0.99).max(sample_goodput);
1266            } else {
1267                // When the path is underfilled, ACK cadence mostly measures our
1268                // own pacing rather than network capacity.  Use a fall alpha
1269                // proportional to estimation error: when the estimate is 10x+
1270                // the sample, converge aggressively; when close, stay gentle.
1271                let ratio = client.goodput_bps / sample_goodput.max(1.0);
1272                let fall_alpha = if ratio > 10.0 {
1273                    0.5
1274                } else if ratio > 3.0 {
1275                    0.25
1276                } else {
1277                    0.03
1278                };
1279                client.goodput_bps =
1280                    ewma_with_direction(client.goodput_bps, sample_goodput, 0.5, fall_alpha);
1281                client.goodput_jitter_bps *= 0.5;
1282                client.max_goodput_jitter_bps *= 0.9;
1283                client.last_goodput_sample_bps =
1284                    (client.last_goodput_sample_bps * 0.99).max(sample_goodput);
1285            }
1286            client.goodput_window_bytes = 0;
1287            client.goodput_window_start = now;
1288        }
1289        let queue_baseline_ms = if throughput_limited(client) {
1290            window_rtt_ms(client)
1291        } else {
1292            path_rtt
1293        };
1294        let queue_delay_ms = (sample_ms - queue_baseline_ms).max(0.0);
1295        let max_probe_frames = (browser_pacing_fps(client) * 0.125).max(4.0);
1296        let jitter_ratio = client.max_goodput_jitter_bps / client.goodput_bps.max(1.0);
1297        let low_delay_frames = if throughput_limited(client) { 2.0 } else { 8.0 };
1298        let high_delay_frames = if throughput_limited(client) {
1299            4.0
1300        } else {
1301            12.0
1302        };
1303        if likely_window_limited
1304            && queue_delay_ms <= frame_ms * low_delay_frames
1305            && jitter_ratio < 0.25
1306        {
1307            client.probe_frames = (client.probe_frames + 1.0).min(max_probe_frames);
1308        } else if !likely_window_limited
1309            && browser_ready(client)
1310            && queue_delay_ms <= frame_ms * 2.0
1311            && jitter_ratio < 0.25
1312        {
1313            client.probe_frames = (client.probe_frames + 0.25).min(max_probe_frames * 0.5);
1314        } else if queue_delay_ms > frame_ms * high_delay_frames || jitter_ratio > 0.5 {
1315            client.probe_frames = (client.probe_frames * 0.5).max(1.0);
1316        } else if queue_delay_ms > frame_ms * 2.0 || !browser_ready(client) {
1317            client.probe_frames = (client.probe_frames - 0.5).max(0.0);
1318        }
1319    } else {
1320        client.inflight_bytes = 0;
1321    }
1322}
1323
1324/// Process a surface ACK.  Feeds delivery_bps and goodput estimates (same
1325/// pipe) from the surface inflight queue.  Does NOT update rtt_ms / min_rtt_ms
1326/// — surface frames are large and their wall-clock delivery time is dominated
1327/// by serialization and wire transfer, not network latency.  Feeding those
1328/// samples into the shared RTT inflates it by orders of magnitude and
1329/// destabilises terminal pacing and congestion control.
1330fn record_surface_ack(client: &mut ClientState) {
1331    if let Some(frame) = client.surface_inflight_frames.pop_front() {
1332        client.acked_bytes_since_log = client.acked_bytes_since_log.saturating_add(frame.bytes);
1333
1334        let sample_ms = frame.sent_at.elapsed().as_secs_f32() * 1_000.0;
1335
1336        // Shared delivery rate (bandwidth, not latency — safe to update).
1337        let sample_bps = frame.bytes as f32 / sample_ms.max(1.0e-3) * 1_000.0;
1338        client.delivery_bps = ewma_with_direction(client.delivery_bps, sample_bps, 0.5, 0.125);
1339
1340        // Shared goodput window — accumulate bytes, flush periodically.
1341        // Surface traffic at display_fps is sustained, so always use the
1342        // window-limited EWMA parameters (rise 0.5, fall 0.125).  No
1343        // jitter tracking — jitter is a terminal congestion-control signal
1344        // and large keyframe/P-frame variance would poison it.
1345        client.goodput_window_bytes = client.goodput_window_bytes.saturating_add(frame.bytes);
1346        let now = Instant::now();
1347        let goodput_elapsed = now
1348            .duration_since(client.goodput_window_start)
1349            .as_secs_f32();
1350        if goodput_elapsed >= 0.02 {
1351            let sample_goodput = client.goodput_window_bytes as f32 / goodput_elapsed.max(1.0e-3);
1352            client.goodput_bps =
1353                ewma_with_direction(client.goodput_bps, sample_goodput, 0.5, 0.125);
1354            client.last_goodput_sample_bps =
1355                (client.last_goodput_sample_bps * 0.99).max(sample_goodput);
1356            client.goodput_window_bytes = 0;
1357            client.goodput_window_start = now;
1358        }
1359    }
1360}
1361
1362fn reset_inflight(client: &mut ClientState) {
1363    client.inflight_bytes = 0;
1364    client.inflight_frames.clear();
1365    client.next_send_at = Instant::now();
1366    client.browser_backlog_frames = 0;
1367    client.browser_ack_ahead_frames = 0;
1368}
1369
1370fn is_unset_view_size(rows: u16, cols: u16) -> bool {
1371    rows == 0 && cols == 0
1372}
1373
1374fn subscribe_client_to(client: &mut ClientState, pty_id: u16) {
1375    if client.subscriptions.insert(pty_id) {
1376        client.last_sent.remove(&pty_id);
1377        client.preview_next_send_at.remove(&pty_id);
1378    }
1379}
1380
1381fn unsubscribe_client_from(client: &mut ClientState, pty_id: u16) -> bool {
1382    let removed_sub = client.subscriptions.remove(&pty_id);
1383    client.last_sent.remove(&pty_id);
1384    client.preview_next_send_at.remove(&pty_id);
1385    client.scroll_offsets.remove(&pty_id);
1386    client.scroll_caches.remove(&pty_id);
1387    let removed_view = client.view_sizes.remove(&pty_id).is_some();
1388    if client.lead == Some(pty_id) {
1389        client.lead = None;
1390    }
1391    removed_sub || removed_view
1392}
1393
1394fn update_client_scroll_state(client: &mut ClientState, pty_id: u16, next_offset: usize) -> bool {
1395    let prev_offset = client.scroll_offsets.get(&pty_id).copied().unwrap_or(0);
1396    if prev_offset == next_offset {
1397        return false;
1398    }
1399
1400    if prev_offset == 0 && next_offset > 0 {
1401        client.scroll_caches.insert(
1402            pty_id,
1403            client.last_sent.get(&pty_id).cloned().unwrap_or_default(),
1404        );
1405    } else if prev_offset > 0
1406        && next_offset == 0
1407        && let Some(cache) = client.scroll_caches.remove(&pty_id)
1408    {
1409        if cache.rows() > 0 && cache.cols() > 0 {
1410            client.last_sent.insert(pty_id, cache);
1411        } else {
1412            client.last_sent.remove(&pty_id);
1413        }
1414    }
1415
1416    if next_offset > 0 {
1417        client.scroll_offsets.insert(pty_id, next_offset);
1418    } else {
1419        client.scroll_offsets.remove(&pty_id);
1420    }
1421    reset_inflight(client);
1422    true
1423}
1424
1425struct Session {
1426    ptys: HashMap<u16, Pty>,
1427    compositor: Option<SharedCompositor>,
1428    next_client_id: u64,
1429    next_compositor_id: u16,
1430    next_pty_id: u16,
1431    tick_fires: u32,
1432    tick_snaps: u32,
1433    surface_commits: u32,
1434    surface_encodes: u32,
1435    surface_encode_bytes: u64,
1436    surface_frames_sent: u32,
1437    /// Ticks where pixel_snapshot was empty → entire encode loop skipped.
1438    ticks_pixel_snapshot_empty: u32,
1439    /// Number of (sid,w,h) tuples in the most recent non-empty pixel_snapshot.
1440    pixel_snapshot_len: usize,
1441    last_ping: Instant,
1442    clients: HashMap<u64, ClientState>,
1443}
1444
1445struct SearchResultRow {
1446    pty_id: u16,
1447    score: u32,
1448    primary_source: u8,
1449    matched_sources: u8,
1450    context: String,
1451    scroll_offset: Option<usize>,
1452}
1453
1454struct TickOutcome {
1455    next_deadline: Option<Instant>,
1456}
1457
1458impl Session {
1459    fn new() -> Self {
1460        Self {
1461            ptys: HashMap::new(),
1462            compositor: None,
1463            next_client_id: 1,
1464            next_compositor_id: 1,
1465            next_pty_id: 1,
1466            clients: HashMap::new(),
1467            tick_fires: 0,
1468            tick_snaps: 0,
1469            surface_commits: 0,
1470            surface_encodes: 0,
1471            surface_encode_bytes: 0,
1472            ticks_pixel_snapshot_empty: 0,
1473            pixel_snapshot_len: 0,
1474            last_ping: Instant::now(),
1475            surface_frames_sent: 0,
1476        }
1477    }
1478
1479    fn ensure_compositor(
1480        &mut self,
1481        verbose: bool,
1482        event_notify: Arc<dyn Fn() + Send + Sync>,
1483        gpu_device: &str,
1484    ) -> &str {
1485        if self.compositor.is_none() {
1486            let session_id = self.next_compositor_id;
1487            self.next_compositor_id = self.next_compositor_id.wrapping_add(1);
1488            // Create the epoch before spawning anything so audio and video
1489            // share the same time origin for A/V sync.
1490            let created_at = Instant::now();
1491            let handle = blit_compositor::spawn_compositor(verbose, event_notify, gpu_device);
1492            #[cfg(target_os = "linux")]
1493            let audio_pipeline = {
1494                let audio_disabled = std::env::var("BLIT_AUDIO")
1495                    .map(|v| v == "0")
1496                    .unwrap_or(false);
1497                if !audio_disabled && audio::pipewire_available() {
1498                    let runtime_dir = std::path::Path::new(&handle.socket_name)
1499                        .parent()
1500                        .unwrap_or(std::path::Path::new("/tmp"));
1501                    let bitrate = std::env::var("BLIT_AUDIO_BITRATE")
1502                        .ok()
1503                        .and_then(|v| v.parse::<i32>().ok())
1504                        .unwrap_or(0);
1505                    // Wrap in block_in_place so the thread::sleep calls
1506                    // inside spawn() don't stall the tokio runtime.
1507                    tokio::task::block_in_place(|| {
1508                        match audio::AudioPipeline::spawn(
1509                            runtime_dir,
1510                            session_id,
1511                            bitrate,
1512                            verbose,
1513                            created_at,
1514                        ) {
1515                            Ok(pipeline) => {
1516                                if verbose {
1517                                    eprintln!(
1518                                        "[audio] pipeline started, PULSE_SERVER={}",
1519                                        pipeline.pulse_server_path(),
1520                                    );
1521                                }
1522                                Some(pipeline)
1523                            }
1524                            Err(e) => {
1525                                eprintln!("[audio] failed to start pipeline: {e}");
1526                                None
1527                            }
1528                        }
1529                    })
1530                } else {
1531                    if verbose && !audio_disabled {
1532                        let missing = audio::missing_pipewire_binaries();
1533                        eprintln!(
1534                            "[audio] audio disabled: missing binaries on $PATH: {}",
1535                            missing.join(", ")
1536                        );
1537                    }
1538                    None
1539                }
1540            };
1541
1542            self.compositor = Some(SharedCompositor {
1543                handle,
1544                surfaces: HashMap::new(),
1545                last_pixels: HashMap::new(),
1546                last_frame_request: HashMap::new(),
1547                created_at,
1548                pixel_generation: 0,
1549                last_blanket_frame_request: Instant::now(),
1550                last_configured_size: HashMap::new(),
1551                #[cfg(target_os = "linux")]
1552                audio_pipeline,
1553                #[cfg(target_os = "linux")]
1554                audio_session_id: session_id,
1555                #[cfg(target_os = "linux")]
1556                last_audio_restart: None,
1557            });
1558        }
1559        &self.compositor.as_ref().unwrap().handle.socket_name
1560    }
1561
1562    /// Returns the `PULSE_SERVER` path if the audio pipeline is active.
1563    #[cfg(target_os = "linux")]
1564    fn pulse_server_path(&self) -> Option<String> {
1565        self.compositor
1566            .as_ref()
1567            .and_then(|cs| cs.audio_pipeline.as_ref())
1568            .map(|ap| ap.pulse_server_path())
1569    }
1570
1571    /// Returns the `PIPEWIRE_REMOTE` path if the audio pipeline is active.
1572    #[cfg(target_os = "linux")]
1573    fn pipewire_remote_path(&self) -> Option<String> {
1574        self.compositor
1575            .as_ref()
1576            .and_then(|cs| cs.audio_pipeline.as_ref())
1577            .map(|ap| ap.pipewire_remote_path())
1578    }
1579
1580    fn allocate_pty_id(&mut self, max_ptys: usize) -> Option<u16> {
1581        if max_ptys > 0 && self.ptys.len() >= max_ptys {
1582            return None;
1583        }
1584        let start = self.next_pty_id;
1585        let mut id = start;
1586        loop {
1587            if !self.ptys.contains_key(&id) {
1588                self.next_pty_id = if id == u16::MAX { 1 } else { id + 1 };
1589                return Some(id);
1590            }
1591            id = if id == u16::MAX { 1 } else { id + 1 };
1592            if id == start {
1593                return None;
1594            }
1595        }
1596    }
1597
1598    fn send_to_all(&self, msg: &[u8]) {
1599        for c in self.clients.values() {
1600            let _ = send_outbox(c, msg.to_vec());
1601        }
1602    }
1603
1604    fn mediated_size_for_pty(&self, pty_id: u16) -> Option<(u16, u16)> {
1605        let mut min_rows: Option<u16> = None;
1606        let mut min_cols: Option<u16> = None;
1607        for c in self.clients.values() {
1608            if let Some((r, cols)) = c.view_sizes.get(&pty_id).copied() {
1609                min_rows = Some(min_rows.map_or(r, |m: u16| m.min(r)));
1610                min_cols = Some(min_cols.map_or(cols, |m: u16| m.min(cols)));
1611            }
1612        }
1613        match (min_rows, min_cols) {
1614            (Some(r), Some(c)) => Some((r.max(1), c.max(1))),
1615            _ => None,
1616        }
1617    }
1618
1619    fn resize_pty(&mut self, pty_id: u16, rows: u16, cols: u16) -> bool {
1620        let pty = match self.ptys.get_mut(&pty_id) {
1621            Some(p) => p,
1622            None => return false,
1623        };
1624        let (cur_rows, cur_cols) = pty.driver.size();
1625        if cur_rows == rows && cur_cols == cols {
1626            return false;
1627        }
1628        pty.ready_frames.clear();
1629        pty.driver.resize(rows, cols);
1630        pty.mark_dirty();
1631        for c in self.clients.values_mut() {
1632            if c.subscriptions.contains(&pty_id) {
1633                c.last_sent.remove(&pty_id);
1634            }
1635            if c.scroll_caches.remove(&pty_id).is_some() {
1636                reset_inflight(c);
1637            }
1638        }
1639        if !pty.exited {
1640            pty::resize_pty_os(&pty.handle, rows, cols);
1641        }
1642        true
1643    }
1644
1645    fn resize_ptys_to_mediated_sizes<I>(&mut self, pty_ids: I) -> bool
1646    where
1647        I: IntoIterator<Item = u16>,
1648    {
1649        let mut changed = false;
1650        let mut seen = HashSet::new();
1651        for pty_id in pty_ids {
1652            if !seen.insert(pty_id) {
1653                continue;
1654            }
1655            if let Some((rows, cols)) = self.mediated_size_for_pty(pty_id) {
1656                changed |= self.resize_pty(pty_id, rows, cols);
1657            }
1658        }
1659        changed
1660    }
1661
1662    // ------------------------------------------------------------------
1663    // Surface sizing — same consumer-tracking model as PTY sizing.
1664    // Each client reports how large it can display a surface; the server
1665    // picks min(width), min(height) across all clients and configures the
1666    // compositor accordingly.
1667    // ------------------------------------------------------------------
1668
1669    /// Returns (width, height, scale_120) mediated across all clients.
1670    /// Resolution: min across clients.  DPI: max across clients.
1671    /// Clients with a fixed per-surface target size (scaled subscription)
1672    /// are excluded — they don't pull the compositor surface smaller for
1673    /// anyone else; the server scales the native frame for them.
1674    fn mediated_size_for_surface(
1675        &self,
1676        surface_id: u16,
1677        max: Option<(u16, u16)>,
1678    ) -> Option<(u16, u16, u16)> {
1679        let mut min_w: Option<u16> = None;
1680        let mut min_h: Option<u16> = None;
1681        let mut max_scale: u16 = 0;
1682        for c in self.clients.values() {
1683            if let Some(&(w, h, s)) = c.surface_view_sizes.get(&surface_id) {
1684                min_w = Some(min_w.map_or(w, |m: u16| m.min(w)));
1685                min_h = Some(min_h.map_or(h, |m: u16| m.min(h)));
1686                max_scale = max_scale.max(s);
1687            }
1688        }
1689        match (min_w, min_h) {
1690            (Some(w), Some(h)) => {
1691                let (w, h) = (w.max(1), h.max(1));
1692                let (w, h) = if let Some((mw, mh)) = max {
1693                    (w.min(mw), h.min(mh))
1694                } else {
1695                    (w, h)
1696                };
1697                Some((w, h, max_scale))
1698            }
1699            _ => None,
1700        }
1701    }
1702
1703    fn resize_surface(&mut self, surface_id: u16, width: u16, height: u16, scale_120: u16) -> bool {
1704        let cs = match self.compositor.as_mut() {
1705            Some(cs) => cs,
1706            None => return false,
1707        };
1708        // Dedup against the last *requested* dimensions, not the composited
1709        // output dimensions (`info.width`/`info.height`).  The composited
1710        // output may be smaller when the Wayland client sets xdg_geometry
1711        // (e.g. Chromium excludes the title bar), so comparing against it
1712        // would cause every resize to look like a change, flooding the
1713        // compositor with redundant configures and re-creating the encoder
1714        // (keyframe) on every tick during a drag-resize.
1715        if let Some(&(lw, lh, ls)) = cs.last_configured_size.get(&surface_id)
1716            && lw == width
1717            && lh == height
1718            && ls == scale_120
1719        {
1720            return false;
1721        }
1722        cs.last_configured_size
1723            .insert(surface_id, (width, height, scale_120));
1724        let _ = cs.handle.command_tx.send(CompositorCommand::SurfaceResize {
1725            surface_id,
1726            width,
1727            height,
1728            scale_120,
1729        });
1730        true
1731    }
1732
1733    fn resize_surfaces_to_mediated_sizes<I>(
1734        &mut self,
1735        surface_ids: I,
1736        encoder_preferences: &[SurfaceEncoderPreference],
1737    ) where
1738        I: IntoIterator<Item = u16>,
1739    {
1740        let max = SurfaceEncoderPreference::max_dimensions_for_list(encoder_preferences);
1741        let mut seen = HashSet::new();
1742        for sid in surface_ids {
1743            if !seen.insert(sid) {
1744                continue;
1745            }
1746            if let Some((w, h, scale_120)) = self.mediated_size_for_surface(sid, max) {
1747                self.resize_surface(sid, w, h, scale_120);
1748            }
1749        }
1750    }
1751
1752    fn pty_list_msg(&self) -> Vec<u8> {
1753        let mut msg = vec![S2C_LIST];
1754        let count = self.ptys.len() as u16;
1755        msg.extend_from_slice(&count.to_le_bytes());
1756        let mut ids: Vec<u16> = self.ptys.keys().copied().collect();
1757        ids.sort();
1758        for id in ids {
1759            let pty = &self.ptys[&id];
1760            let tag = pty.tag.as_bytes();
1761            msg.extend_from_slice(&id.to_le_bytes());
1762            msg.extend_from_slice(&(tag.len() as u16).to_le_bytes());
1763            msg.extend_from_slice(tag);
1764            let cmd = pty.command.as_deref().unwrap_or("").as_bytes();
1765            msg.extend_from_slice(&(cmd.len() as u16).to_le_bytes());
1766            msg.extend_from_slice(cmd);
1767        }
1768        msg
1769    }
1770
1771    fn surface_list_msg(&self) -> Vec<u8> {
1772        let cs = match self.compositor.as_ref() {
1773            Some(cs) => cs,
1774            None => {
1775                let mut msg = vec![S2C_SURFACE_LIST];
1776                msg.extend_from_slice(&0u16.to_le_bytes());
1777                return msg;
1778            }
1779        };
1780        let mut msg = vec![S2C_SURFACE_LIST];
1781        let count = cs.surfaces.len() as u16;
1782        msg.extend_from_slice(&count.to_le_bytes());
1783        let mut ids: Vec<u16> = cs.surfaces.keys().copied().collect();
1784        ids.sort();
1785        for id in ids {
1786            let info = &cs.surfaces[&id];
1787            let title = info.title.as_bytes();
1788            let app_id = info.app_id.as_bytes();
1789            msg.extend_from_slice(&info.surface_id.to_le_bytes());
1790            msg.extend_from_slice(&info.parent_id.to_le_bytes());
1791            msg.extend_from_slice(&info.width.to_le_bytes());
1792            msg.extend_from_slice(&info.height.to_le_bytes());
1793            msg.extend_from_slice(&(title.len() as u16).to_le_bytes());
1794            msg.extend_from_slice(title);
1795            msg.extend_from_slice(&(app_id.len() as u16).to_le_bytes());
1796            msg.extend_from_slice(app_id);
1797        }
1798        msg
1799    }
1800}
1801
1802struct AppStateInner {
1803    config: Config,
1804    session: Mutex<Session>,
1805    pty_fds: PtyFds,
1806    delivery_notify: Arc<Notify>,
1807    /// Signalled when a client sends C2S_QUIT to initiate server shutdown.
1808    shutdown_notify: Arc<Notify>,
1809    /// Tracks the number of currently connected clients for enforcing
1810    /// `config.max_connections`.
1811    active_connections: std::sync::atomic::AtomicUsize,
1812}
1813
1814type AppState = Arc<AppStateInner>;
1815
1816fn nudge_delivery(state: &AppState) {
1817    state.delivery_notify.notify_one();
1818}
1819
1820#[cfg(unix)]
1821#[allow(dead_code)]
1822fn spawn_compositor_child(
1823    command: &str,
1824    argv: Option<&[&str]>,
1825    wayland_socket: &str,
1826    dir: Option<&str>,
1827) -> libc::pid_t {
1828    use std::ffi::CString;
1829    let pid = unsafe { libc::fork() };
1830    if pid == 0 {
1831        if let Some(d) = dir {
1832            let c_dir = CString::new(d).unwrap();
1833            unsafe {
1834                libc::chdir(c_dir.as_ptr());
1835            }
1836        }
1837        unsafe {
1838            let wd_path = std::path::Path::new(wayland_socket);
1839            if let Some(dir) = wd_path.parent() {
1840                let xdg = std::env::var_os("XDG_RUNTIME_DIR");
1841                let needs_update = match &xdg {
1842                    Some(x) => std::path::Path::new(x) != dir,
1843                    None => true,
1844                };
1845                if needs_update {
1846                    std::env::set_var("XDG_RUNTIME_DIR", dir);
1847                }
1848            }
1849            std::env::set_var("WAYLAND_DISPLAY", wayland_socket);
1850            std::env::remove_var("DISPLAY");
1851            std::env::remove_var("DBUS_SESSION_BUS_ADDRESS");
1852            std::env::remove_var("DBUS_SYSTEM_BUS_ADDRESS");
1853        }
1854        if let Some(args) = argv {
1855            let prog = CString::new(args[0]).unwrap();
1856            let c_args: Vec<CString> = args.iter().map(|a| CString::new(*a).unwrap()).collect();
1857            let c_ptrs: Vec<*const libc::c_char> = c_args
1858                .iter()
1859                .map(|a| a.as_ptr())
1860                .chain(std::iter::once(std::ptr::null()))
1861                .collect();
1862            unsafe {
1863                libc::execvp(prog.as_ptr(), c_ptrs.as_ptr());
1864            }
1865        } else {
1866            let prog = CString::new(command).unwrap();
1867            let c_ptrs = [prog.as_ptr(), std::ptr::null()];
1868            unsafe {
1869                libc::execvp(prog.as_ptr(), c_ptrs.as_ptr());
1870                libc::_exit(1);
1871            }
1872        }
1873    }
1874    pid
1875}
1876
1877/// Map xterm-256 color index to (r, g, b) in 16-bit per channel.
1878fn xterm256_color(idx: u8) -> (u16, u16, u16) {
1879    // Standard 16 colors (0-15)
1880    const BASE16: [(u8, u8, u8); 16] = [
1881        (0, 0, 0),
1882        (128, 0, 0),
1883        (0, 128, 0),
1884        (128, 128, 0),
1885        (0, 0, 128),
1886        (128, 0, 128),
1887        (0, 128, 128),
1888        (192, 192, 192),
1889        (128, 128, 128),
1890        (255, 0, 0),
1891        (0, 255, 0),
1892        (255, 255, 0),
1893        (0, 0, 255),
1894        (255, 0, 255),
1895        (0, 255, 255),
1896        (255, 255, 255),
1897    ];
1898    let (r8, g8, b8) = if idx < 16 {
1899        BASE16[idx as usize]
1900    } else if idx < 232 {
1901        // 6x6x6 color cube (indices 16-231)
1902        let n = idx - 16;
1903        let ri = n / 36;
1904        let gi = (n % 36) / 6;
1905        let bi = n % 6;
1906        let to_val = |v: u8| if v == 0 { 0u8 } else { 55 + 40 * v };
1907        (to_val(ri), to_val(gi), to_val(bi))
1908    } else {
1909        // Grayscale ramp (indices 232-255)
1910        let v = 8 + 10 * (idx - 232);
1911        (v, v, v)
1912    };
1913    // Scale 8-bit to 16-bit (0xFF -> 0xFFFF)
1914    let scale = |v: u8| (v as u16) << 8 | v as u16;
1915    (scale(r8), scale(g8), scale(b8))
1916}
1917fn parse_terminal_queries(data: &[u8], size: (u16, u16), cursor: (u16, u16)) -> Vec<String> {
1918    const DA1_RESPONSE: &[u8] = b"\x1b[?64;1;2;6;9;15;18;21;22c";
1919
1920    let mut results = Vec::new();
1921    let mut i = 0;
1922    while i < data.len() {
1923        if data[i] != 0x1b || i + 1 >= data.len() {
1924            i += 1;
1925            continue;
1926        }
1927
1928        // Handle OSC sequences: \x1b] ... (ST or BEL)
1929        if data[i + 1] == b']' {
1930            let osc_start = i + 2;
1931            // Find the terminator: BEL (\x07) or ST (\x1b\\)
1932            let mut end = osc_start;
1933            while end < data.len() {
1934                if data[end] == 0x07 {
1935                    break;
1936                }
1937                if data[end] == 0x1b && end + 1 < data.len() && data[end + 1] == b'\\' {
1938                    break;
1939                }
1940                end += 1;
1941            }
1942            if end < data.len() {
1943                let payload = &data[osc_start..end];
1944                // OSC 11 ; ? — query background color
1945                if payload == b"11;?" {
1946                    // Respond with dark background (rgb:0000/0000/0000)
1947                    results.push("\x1b]11;rgb:0000/0000/0000\x1b\\".into());
1948                }
1949                // OSC 10 ; ? — query foreground color
1950                else if payload == b"10;?" {
1951                    results.push("\x1b]10;rgb:ffff/ffff/ffff\x1b\\".into());
1952                }
1953                // OSC 4 ; N ; ? — query palette color N
1954                else if payload.starts_with(b"4;") && payload.ends_with(b";?") {
1955                    let idx_bytes = &payload[2..payload.len() - 2];
1956                    if let Ok(idx_str) = std::str::from_utf8(idx_bytes)
1957                        && let Ok(idx) = idx_str.parse::<u8>()
1958                    {
1959                        let (r, g, b) = xterm256_color(idx);
1960                        results.push(format!("\x1b]4;{idx};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"));
1961                    }
1962                }
1963                i = end + if data[end] == 0x07 { 1 } else { 2 };
1964                continue;
1965            }
1966            i = end;
1967            continue;
1968        }
1969
1970        // Handle CSI sequences: \x1b[ ...
1971        if i + 2 >= data.len() || data[i + 1] != b'[' {
1972            i += 1;
1973            continue;
1974        }
1975        i += 2;
1976        let has_q = i < data.len() && data[i] == b'?';
1977        if has_q {
1978            i += 1;
1979        }
1980        let param_start = i;
1981        while i < data.len() && (data[i].is_ascii_digit() || data[i] == b';') {
1982            i += 1;
1983        }
1984        if i >= data.len() {
1985            break;
1986        }
1987        let final_byte = data[i];
1988        let params = &data[param_start..i];
1989        i += 1;
1990        if has_q {
1991            continue;
1992        }
1993        let resp: Option<String> = match final_byte {
1994            b'c' if params.is_empty() || params == b"0" => {
1995                Some(String::from_utf8_lossy(DA1_RESPONSE).into_owned())
1996            }
1997            b'n' if params == b"6" => Some(format!("\x1b[{};{}R", cursor.0 + 1, cursor.1 + 1)),
1998            b'n' if params == b"5" => Some("\x1b[0n".into()),
1999            b't' if params == b"18" => {
2000                let (rows, cols) = size;
2001                Some(format!("\x1b[8;{rows};{cols}t"))
2002            }
2003            b't' if params == b"14" => {
2004                let (rows, cols) = size;
2005                Some(format!("\x1b[4;{};{}t", rows * 16, cols * 8))
2006            }
2007            _ => None,
2008        };
2009        if let Some(r) = resp {
2010            results.push(r);
2011        }
2012    }
2013    results
2014}
2015
2016async fn cleanup_pty_internal(pty_id: u16, state: &AppState) {
2017    state.pty_fds.write().unwrap().remove(&pty_id);
2018    let mut sess = state.session.lock().await;
2019    if let Some(pty) = sess.ptys.get_mut(&pty_id) {
2020        if pty.exited {
2021            return;
2022        }
2023        pty.exited = true;
2024        pty::close_pty(&pty.handle);
2025        pty.exit_status = pty::collect_exit_status(&pty.handle);
2026        pty.mark_dirty();
2027        let msg = blit_remote::msg_exited(pty_id, pty.exit_status);
2028        sess.send_to_all(&msg);
2029    }
2030}
2031
2032fn take_snapshot(pty: &mut Pty) -> FrameState {
2033    if pty.lflag_last.elapsed() >= Duration::from_millis(250) {
2034        pty.lflag_cache = pty::pty_lflag(&pty.handle);
2035        pty.lflag_last = Instant::now();
2036    }
2037    let (echo, icanon) = pty.lflag_cache;
2038    pty.driver.snapshot(echo, icanon)
2039}
2040
2041fn build_scrollback_update(
2042    pty: &mut Pty,
2043    id: u16,
2044    offset: usize,
2045    prev_frame: &FrameState,
2046) -> Option<(Vec<u8>, FrameState)> {
2047    let frame = pty.driver.scrollback_frame(offset);
2048    let msg = build_update_msg(id, &frame, prev_frame);
2049    msg.map(|m| (m, frame))
2050}
2051
2052fn build_search_results_msg(request_id: u16, results: &[SearchResultRow]) -> Vec<u8> {
2053    let count = results.len().min(u16::MAX as usize);
2054    let payload_bytes: usize = results[..count]
2055        .iter()
2056        .map(|result| 14 + result.context.len().min(u16::MAX as usize))
2057        .sum();
2058    let mut msg = Vec::with_capacity(5 + payload_bytes);
2059    msg.push(S2C_SEARCH_RESULTS);
2060    msg.extend_from_slice(&request_id.to_le_bytes());
2061    msg.extend_from_slice(&(count as u16).to_le_bytes());
2062    for result in &results[..count] {
2063        msg.extend_from_slice(&result.pty_id.to_le_bytes());
2064        msg.extend_from_slice(&result.score.to_le_bytes());
2065        msg.push(result.primary_source);
2066        msg.push(result.matched_sources);
2067        let scroll_offset = result
2068            .scroll_offset
2069            .map(|offset| offset.min(u32::MAX as usize - 1) as u32)
2070            .unwrap_or(u32::MAX);
2071        msg.extend_from_slice(&scroll_offset.to_le_bytes());
2072        let context = result.context.as_bytes();
2073        let context_len = context.len().min(u16::MAX as usize);
2074        msg.extend_from_slice(&(context_len as u16).to_le_bytes());
2075        msg.extend_from_slice(&context[..context_len]);
2076    }
2077    msg
2078}
2079
2080enum SendOutcome {
2081    NoChange,
2082    Sent,
2083    Backpressured,
2084}
2085
2086fn try_send_update(
2087    client: &mut ClientState,
2088    pid: u16,
2089    current: FrameState,
2090    msg: Option<Vec<u8>>,
2091    now: Instant,
2092    paced: bool,
2093) -> SendOutcome {
2094    let Some(msg) = msg else {
2095        return SendOutcome::NoChange;
2096    };
2097    let bytes = msg.len();
2098    if send_outbox(client, msg).is_ok() {
2099        client.last_sent.insert(pid, current);
2100        record_send(client, bytes, now, paced);
2101        client.frames_sent = client.frames_sent.wrapping_add(1);
2102        SendOutcome::Sent
2103    } else {
2104        // Receiver dropped — client disconnected.  Advance last_sent so
2105        // the next diff (if any) is small rather than accumulating stale
2106        // changes.
2107        client.last_sent.insert(pid, current);
2108        SendOutcome::Backpressured
2109    }
2110}
2111
2112pub async fn run(config: Config) {
2113    let state: AppState = Arc::new(AppStateInner {
2114        config,
2115        session: Mutex::new(Session::new()),
2116        pty_fds: Arc::new(std::sync::RwLock::new(HashMap::new())),
2117        delivery_notify: Arc::new(Notify::new()),
2118        shutdown_notify: Arc::new(Notify::new()),
2119        active_connections: std::sync::atomic::AtomicUsize::new(0),
2120    });
2121
2122    // Start the compositor eagerly so it is ready before any client
2123    // connects or any terminal is created.
2124    if !state.config.skip_compositor {
2125        let notify = state.delivery_notify.clone();
2126        let event_notify = Arc::new(move || notify.notify_one()) as Arc<dyn Fn() + Send + Sync>;
2127        let mut sess = state.session.lock().await;
2128        sess.ensure_compositor(
2129            state.config.verbose,
2130            event_notify,
2131            &state.config.vaapi_device,
2132        );
2133    }
2134
2135    let delivery_state = state.clone();
2136    tokio::spawn(async move {
2137        let mut next_deadline: Option<Instant> = None;
2138        loop {
2139            if let Some(deadline) = next_deadline {
2140                tokio::select! {
2141                    _ = delivery_state.delivery_notify.notified() => {}
2142                    _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {}
2143                }
2144            } else {
2145                delivery_state.delivery_notify.notified().await;
2146            }
2147            let outcome = tick(&delivery_state).await;
2148            next_deadline = outcome.next_deadline;
2149        }
2150    });
2151
2152    tokio::spawn(async {
2153        loop {
2154            tokio::time::sleep(Duration::from_secs(5)).await;
2155            pty::reap_zombies();
2156        }
2157    });
2158
2159    #[cfg(unix)]
2160    if let Some(channel_fd) = state.config.fd_channel {
2161        ipc::run_fd_channel(channel_fd, state).await;
2162        return;
2163    }
2164
2165    #[cfg(unix)]
2166    let listener = {
2167        if let Some(l) = IpcListener::from_systemd_fd(state.config.verbose) {
2168            l
2169        } else {
2170            IpcListener::bind(&state.config.ipc_path, state.config.verbose)
2171        }
2172    };
2173    #[cfg(not(unix))]
2174    let mut listener = IpcListener::bind(&state.config.ipc_path, state.config.verbose);
2175
2176    // Broadcast S2C_QUIT on SIGTERM / SIGINT so clients can reconnect promptly
2177    // instead of waiting for a transport-level timeout.
2178    {
2179        let state = state.clone();
2180        tokio::spawn(async move {
2181            #[cfg(unix)]
2182            {
2183                use tokio::signal::unix::{SignalKind, signal};
2184                let mut sigterm = signal(SignalKind::terminate()).expect("signal handler");
2185                let mut sigint = signal(SignalKind::interrupt()).expect("signal handler");
2186                tokio::select! {
2187                    _ = sigterm.recv() => {}
2188                    _ = sigint.recv() => {}
2189                }
2190            }
2191            #[cfg(not(unix))]
2192            {
2193                let _ = tokio::signal::ctrl_c().await;
2194            }
2195            let sess = state.session.lock().await;
2196            sess.send_to_all(&[S2C_QUIT]);
2197            drop(sess);
2198            state.shutdown_notify.notify_one();
2199        });
2200    }
2201
2202    let shutdown = state.shutdown_notify.clone();
2203    loop {
2204        let stream = tokio::select! {
2205            result = listener.accept() => match result {
2206                Ok(s) => s,
2207                Err(e) => {
2208                    eprintln!("accept error: {e}");
2209                    tokio::time::sleep(Duration::from_millis(100)).await;
2210                    continue;
2211                }
2212            },
2213            _ = shutdown.notified() => break,
2214        };
2215        let max = state.config.max_connections;
2216        if max > 0 {
2217            let current = state
2218                .active_connections
2219                .load(std::sync::atomic::Ordering::Relaxed);
2220            if current >= max {
2221                eprintln!("max connections ({max}) reached, rejecting");
2222                drop(stream);
2223                continue;
2224            }
2225        }
2226        state
2227            .active_connections
2228            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2229        let state = state.clone();
2230        tokio::spawn(async move {
2231            handle_client(stream, state.clone()).await;
2232            state
2233                .active_connections
2234                .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
2235        });
2236    }
2237    // Brief grace period for S2C_QUIT to reach clients before the process exits.
2238    tokio::time::sleep(Duration::from_millis(100)).await;
2239}
2240
2241/// Minimum interval between blanket RequestFrame rounds.  Keeps video
2242/// players (mpv) and browsers ticking even when no client is consuming
2243/// frames.  Also used as the maximum tick-loop sleep so the loop never
2244/// blocks longer than this.
2245///
2246/// When any client has an active surface subscription, use 8 ms (~120 Hz)
2247/// so video players get consistent frame callbacks matching the display
2248/// rate.  Without active surfaces, 33 ms (30 Hz) is sufficient.
2249const BLANKET_FRAME_INTERVAL_IDLE: Duration = Duration::from_millis(250);
2250const BLANKET_FRAME_INTERVAL_SURFACE: Duration = Duration::from_micros(62_500);
2251
2252/// Returns the interval at which the tick loop must send blanket
2253/// `RequestFrame` events to keep Wayland apps (mpv, browsers, etc.)
2254/// making progress. Returns `None` when no clients are connected — in
2255/// that state the loop can sleep purely on event notifications, and
2256/// apps pause until a viewer reconnects (resuming within SURFACE).
2257fn blanket_frame_interval(sess: &Session) -> Option<Duration> {
2258    if sess.clients.is_empty() {
2259        return None;
2260    }
2261    let has_surface_subs = sess
2262        .clients
2263        .values()
2264        .any(|c| !c.surface_subscriptions.is_empty());
2265    if has_surface_subs {
2266        Some(BLANKET_FRAME_INTERVAL_SURFACE)
2267    } else {
2268        Some(BLANKET_FRAME_INTERVAL_IDLE)
2269    }
2270}
2271
2272async fn tick(state: &AppState) -> TickOutcome {
2273    let mut sess = state.session.lock().await;
2274    sess.tick_fires += 1;
2275    let mut next_deadline: Option<Instant> = None;
2276    let now = Instant::now();
2277
2278    // Emit pacing metrics every 10s for each client, even when no ACKs
2279    // are flowing (idle session): the ACK handler also calls this so the
2280    // first client with traffic still owns the tick-counter reset.
2281    let log_client_ids: Vec<u64> = sess.clients.keys().copied().collect();
2282    for cid in log_client_ids {
2283        maybe_log_pacing_metrics(&mut sess, cid, state.config.verbose);
2284    }
2285
2286    // Application-level keepalive. Only scheduled when a client is
2287    // connected — otherwise there's no one to ping and the timer would
2288    // be pure polling cost.
2289    let ping_interval = state.config.ping_interval;
2290    if !ping_interval.is_zero() && !sess.clients.is_empty() {
2291        if now.duration_since(sess.last_ping) >= ping_interval {
2292            sess.send_to_all(&[S2C_PING]);
2293            sess.last_ping = now;
2294        }
2295        let next_ping = sess.last_ping + ping_interval;
2296        next_deadline = Some(next_deadline.map_or(next_ping, |d: Instant| d.min(next_ping)));
2297    }
2298
2299    // Surface IDs whose per-client encoders need to be invalidated.
2300    let mut invalidate_client_encoders: Vec<u16> = Vec::new();
2301    // Surface IDs resized by the compositor this tick.  After the
2302    // compositor borrow is released we wake pacing for every client
2303    // subscribed to each sid so the first post-resize frame bypasses
2304    // the per-surface time gate.
2305    let mut resized_surface_ids: Vec<u16> = Vec::new();
2306
2307    let mut surface_commit_count = 0u32;
2308    if let Some(cs) = sess.compositor.as_mut() {
2309        let mut events = Vec::new();
2310        while let Ok(event) = cs.handle.event_rx.try_recv() {
2311            events.push(event);
2312        }
2313        let mut broadcast: Vec<Vec<u8>> = Vec::new();
2314        for event in events {
2315            match event {
2316                CompositorEvent::SurfaceCreated {
2317                    surface_id,
2318                    title,
2319                    app_id,
2320                    parent_id,
2321                    width,
2322                    height,
2323                } => {
2324                    broadcast.push(msg_surface_created(
2325                        surface_id, parent_id, width, height, &title, &app_id,
2326                    ));
2327                    cs.surfaces.insert(
2328                        surface_id,
2329                        CachedSurfaceInfo {
2330                            surface_id,
2331                            parent_id,
2332                            width,
2333                            height,
2334                            title,
2335                            app_id,
2336                        },
2337                    );
2338                    cs.last_pixels.remove(&surface_id);
2339                    invalidate_client_encoders.push(surface_id);
2340                }
2341                CompositorEvent::SurfaceDestroyed { surface_id } => {
2342                    cs.surfaces.remove(&surface_id);
2343                    cs.last_pixels.remove(&surface_id);
2344                    cs.last_configured_size.remove(&surface_id);
2345                    invalidate_client_encoders.push(surface_id);
2346                    broadcast.push(msg_surface_destroyed(surface_id));
2347                }
2348                CompositorEvent::SurfaceCommit {
2349                    surface_id,
2350                    width,
2351                    height,
2352                    pixels,
2353                    timestamp_ms,
2354                } => {
2355                    surface_commit_count += 1;
2356                    if let Some(info) = cs.surfaces.get_mut(&surface_id) {
2357                        info.width = width as u16;
2358                        info.height = height as u16;
2359                    }
2360                    cs.pixel_generation += 1;
2361                    cs.last_pixels.insert(
2362                        surface_id,
2363                        LastPixels {
2364                            width,
2365                            height,
2366                            pixels,
2367                            generation: cs.pixel_generation,
2368                            timestamp_ms,
2369                        },
2370                    );
2371                }
2372                CompositorEvent::SurfaceTitle { surface_id, title } => {
2373                    if let Some(info) = cs.surfaces.get_mut(&surface_id) {
2374                        info.title = title.clone();
2375                    }
2376                    broadcast.push(msg_surface_title(surface_id, &title));
2377                }
2378                CompositorEvent::SurfaceAppId { surface_id, app_id } => {
2379                    if let Some(info) = cs.surfaces.get_mut(&surface_id) {
2380                        info.app_id = app_id.clone();
2381                    }
2382                    broadcast.push(msg_surface_app_id(surface_id, &app_id));
2383                }
2384                CompositorEvent::SurfaceResized {
2385                    surface_id,
2386                    width,
2387                    height,
2388                } => {
2389                    if let Some(info) = cs.surfaces.get_mut(&surface_id) {
2390                        info.width = width;
2391                        info.height = height;
2392                    }
2393                    cs.last_pixels.remove(&surface_id);
2394                    // Don't eagerly invalidate client encoders here.  The
2395                    // encode path already checks for dimension mismatches
2396                    // (source_dimensions != pixel size) and recreates the
2397                    // encoder on demand.  Eagerly destroying encoders on
2398                    // every intermediate size during a drag-resize causes
2399                    // expensive encoder teardown+creation cycles for sizes
2400                    // that may never actually be encoded (because a newer
2401                    // SurfaceCommit arrives before the next encode tick).
2402                    broadcast.push(msg_surface_resized(surface_id, width, height));
2403                    resized_surface_ids.push(surface_id);
2404                }
2405                CompositorEvent::ClipboardContent {
2406                    mime_type, data, ..
2407                } => {
2408                    broadcast.push(msg_s2c_clipboard_content(&mime_type, &data));
2409                }
2410                CompositorEvent::SurfaceCursor { surface_id, cursor } => {
2411                    // Format: [0x29][surface_id:2][type:1][payload...]
2412                    // type 0 = named: [name_len:1][name:N]
2413                    // type 1 = hidden (no payload)
2414                    // type 2 = custom: [hotx:2][hoty:2][w:2][h:2][png:N]
2415                    let mut msg = Vec::new();
2416                    msg.push(blit_remote::S2C_SURFACE_CURSOR);
2417                    msg.extend_from_slice(&surface_id.to_le_bytes());
2418                    match &cursor {
2419                        blit_compositor::CursorImage::Named(name) => {
2420                            msg.push(0); // type = named
2421                            msg.push(name.len() as u8);
2422                            msg.extend_from_slice(name.as_bytes());
2423                        }
2424                        blit_compositor::CursorImage::Hidden => {
2425                            msg.push(1); // type = hidden
2426                        }
2427                        blit_compositor::CursorImage::Custom {
2428                            hotspot_x,
2429                            hotspot_y,
2430                            width,
2431                            height,
2432                            rgba,
2433                        } => {
2434                            // Encode as PNG to keep message small.
2435                            let mut png_buf = Vec::new();
2436                            {
2437                                let mut encoder =
2438                                    png::Encoder::new(&mut png_buf, *width as u32, *height as u32);
2439                                encoder.set_color(png::ColorType::Rgba);
2440                                encoder.set_depth(png::BitDepth::Eight);
2441                                if let Ok(mut writer) = encoder.write_header() {
2442                                    let _ = writer.write_image_data(rgba);
2443                                }
2444                            }
2445                            msg.push(2); // type = custom
2446                            msg.extend_from_slice(&hotspot_x.to_le_bytes());
2447                            msg.extend_from_slice(&hotspot_y.to_le_bytes());
2448                            msg.extend_from_slice(&width.to_le_bytes());
2449                            msg.extend_from_slice(&height.to_le_bytes());
2450                            msg.extend_from_slice(&png_buf);
2451                        }
2452                    }
2453                    broadcast.push(msg);
2454                }
2455            }
2456        }
2457        for msg in &broadcast {
2458            sess.send_to_all(msg);
2459        }
2460    }
2461    sess.surface_commits += surface_commit_count;
2462
2463    // Apply deferred per-client encoder invalidation (couldn't mutate
2464    // sess.clients while sess.compositor was borrowed above).  Any
2465    // surface event (resize, destroy, reconfigure) invalidates every
2466    // encoder bound to that sid's pixel stream.
2467    for sid in invalidate_client_encoders {
2468        for c in sess.clients.values_mut() {
2469            c.surface_subs.remove(&sid);
2470            c.vulkan_video_surfaces.remove(&sid);
2471        }
2472    }
2473
2474    // Wake pacing for every subscriber of a compositor-resized surface.
2475    // Reset the burst window and clear next_send_at so the first frame
2476    // at the new dimensions flows at wire speed instead of waiting for
2477    // the per-surface time gate (up to ~1/fps), and force a keyframe
2478    // so decoders recover cleanly after the dimension change.
2479    for sid in resized_surface_ids {
2480        for c in sess.clients.values_mut() {
2481            if !c.surface_subscriptions.contains(&sid) {
2482                continue;
2483            }
2484            if let Some(s) = c.surface_subs.get_mut(&sid) {
2485                s.burst_remaining = SURFACE_BURST_FRAMES;
2486                s.next_send_at = None;
2487                s.nal_none_streak = 0;
2488                s.nal_none_latched_at = None;
2489            }
2490            c.surface_needs_keyframe = true;
2491        }
2492    }
2493
2494    // Per-client surface encode + deliver.
2495    // Each client has its own encoder per surface.  We encode from
2496    // shared last_pixels into each client's encoder and deliver.
2497    //
2498    // Snapshot pixel metadata from the compositor first to avoid
2499    // holding an immutable borrow on sess.compositor while mutating
2500    // sess.clients.
2501    // Snapshot every surface entry so each client's per-surface encoder
2502    // can draw from the latest pixels without holding the compositor
2503    // borrow through the (lengthy) encoder-dispatch loop below.
2504    let pixel_snapshot: Vec<(u16, u32, u32, u64, u32)> = sess
2505        .compositor
2506        .as_ref()
2507        .map(|cs| {
2508            cs.last_pixels
2509                .iter()
2510                .map(|(&sid, lp)| (sid, lp.width, lp.height, lp.generation, lp.timestamp_ms))
2511                .collect()
2512        })
2513        .unwrap_or_default();
2514    if pixel_snapshot.is_empty() {
2515        sess.ticks_pixel_snapshot_empty = sess.ticks_pixel_snapshot_empty.saturating_add(1);
2516    } else {
2517        sess.pixel_snapshot_len = pixel_snapshot.len();
2518    }
2519
2520    // ---- Surface encode (off main thread) + deliver ----
2521    //
2522    // Collect encode jobs, drop the session lock, run encodes in
2523    // spawn_blocking, re-acquire the lock, and deliver.
2524
2525    struct EncodeJob {
2526        cid: u64,
2527        sid: u16,
2528        /// Dimensions the encoder operates at — always the compositor's
2529        /// native pixel size.
2530        px_w: u32,
2531        px_h: u32,
2532        /// Pixel data to encode.
2533        pixels: blit_compositor::PixelData,
2534        needs_keyframe: bool,
2535        encoder: SurfaceEncoder,
2536        generation: u64,
2537        /// CLOCK_MONOTONIC ms captured at compositor commit time.
2538        timestamp_ms: u32,
2539    }
2540    struct EncoderCreateParams {
2541        preferences: Vec<SurfaceEncoderPreference>,
2542        vaapi_device: String,
2543        quality: SurfaceQuality,
2544        verbose: bool,
2545        codec_support: u8,
2546        chroma: ChromaSubsampling,
2547    }
2548    /// A creation task runs `SurfaceEncoder::new` + GBM-buffer
2549    /// allocation on a blocking thread, then hands back the encoder
2550    /// and its external buffers to the main loop to register with the
2551    /// compositor.  No encoding happens here — the first encode runs
2552    /// on a subsequent tick after the compositor has committed into
2553    /// the new buffers.
2554    struct CreateJob {
2555        cid: u64,
2556        sid: u16,
2557        px_w: u32,
2558        px_h: u32,
2559        params: EncoderCreateParams,
2560    }
2561    struct CreateResult {
2562        cid: u64,
2563        sid: u16,
2564        /// None when `SurfaceEncoder::new` failed; the completion
2565        /// handler logs and latches a backoff so the tick loop doesn't
2566        /// spin on retries.
2567        encoder: Option<SurfaceEncoder>,
2568        fresh: Option<FreshEncoder>,
2569    }
2570    /// Metadata shipped with an encode result when the encoder was
2571    /// created this tick (deferred to spawn_blocking).  `Some` = the
2572    /// main loop should send S2C_SURFACE_ENCODER, register external
2573    /// GBM buffers with the compositor, and accept the encoder back.
2574    struct FreshEncoder {
2575        name: &'static str,
2576        codec_string: String,
2577        #[cfg(target_os = "linux")]
2578        external_bufs: Vec<blit_compositor::ExternalOutputBuffer>,
2579    }
2580    struct EncodeResult {
2581        cid: u64,
2582        sid: u16,
2583        /// Encoded frame dimensions (what goes on the wire).
2584        px_w: u32,
2585        px_h: u32,
2586        generation: u64,
2587        encoder: SurfaceEncoder,
2588        nal_data: Option<(Vec<u8>, bool)>, // (data, is_keyframe)
2589        codec_flag: u8,
2590        /// CLOCK_MONOTONIC ms from compositor commit time.
2591        timestamp_ms: u32,
2592    }
2593
2594    let mut encode_jobs: Vec<EncodeJob> = Vec::new();
2595    let mut create_jobs: Vec<CreateJob> = Vec::new();
2596    // Surfaces that had encode jobs dispatched this tick.  Used below to
2597    // eagerly pre-request the next frame so the compositor renders in
2598    // parallel with the in-flight encode (pipeline overlap).
2599    let mut encode_dispatched_surfaces: HashSet<u16> = HashSet::new();
2600
2601    // Collect (cid, subs, needs_kf) for clients that are due, then build
2602    // encode jobs in a second pass to avoid overlapping borrows.  `subs`
2603    // is the set of surface ids this client subscribes to.
2604    struct ClientWork {
2605        cid: u64,
2606        subs: HashSet<u16>,
2607        needs_keyframe: bool,
2608    }
2609    let mut client_work: Vec<ClientWork> = Vec::new();
2610
2611    if !pixel_snapshot.is_empty() {
2612        for (&cid, client) in sess.clients.iter_mut() {
2613            if !surface_window_open(client) {
2614                // Log persistent blockage so hangs are visible.
2615                let now_inst = Instant::now();
2616                if now_inst
2617                    .duration_since(client.last_window_blocked_log)
2618                    .as_secs_f32()
2619                    > 5.0
2620                {
2621                    client.last_window_blocked_log = now_inst;
2622                    let max_burst: u8 = client
2623                        .surface_subs
2624                        .values()
2625                        .map(|s| s.burst_remaining)
2626                        .max()
2627                        .unwrap_or(0);
2628                    eprintln!(
2629                        "[surface-gate] cid={cid} surface_window_open=false outbox={}f/{}B (limits {}f/{}B) burst={max_burst}",
2630                        outbox_queued_frames(client),
2631                        outbox_queued_bytes(client),
2632                        OUTBOX_SOFT_QUEUE_LIMIT_FRAMES,
2633                        OUTBOX_SOFT_QUEUE_LIMIT_BYTES,
2634                    );
2635                }
2636                continue;
2637            }
2638            // Per-surface pacing is checked in the inner loop below so
2639            // that each surface can run at full frame rate independently.
2640            if client.surface_subscriptions.is_empty() {
2641                client.skip_no_subs_count = client.skip_no_subs_count.saturating_add(1);
2642                continue;
2643            }
2644            let subs: HashSet<u16> = client.surface_subscriptions.iter().copied().collect();
2645            client_work.push(ClientWork {
2646                cid,
2647                subs,
2648                needs_keyframe: client.surface_needs_keyframe,
2649            });
2650            // Don't advance the deadline here — wait until we know an
2651            // encode job was actually collected (see below).  Advancing
2652            // eagerly wastes time slots when the encode is skipped due
2653            // to in-flight limits or unchanged pixel data.
2654        }
2655
2656        // Track which (client, surface) pairs actually had encode jobs
2657        // collected so we can advance per-surface deadlines afterwards.
2658        let mut encoded_client_surfaces: HashSet<(u64, u16)> = HashSet::new();
2659
2660        // Pre-extract compositor Vulkan Video capabilities so we don't
2661        // need to borrow sess.compositor inside the client-mutation loop.
2662        let vk_encode_available = sess
2663            .compositor
2664            .as_ref()
2665            .is_some_and(|cs| cs.handle.vulkan_video_encode);
2666        let vk_encode_av1_available = sess
2667            .compositor
2668            .as_ref()
2669            .is_some_and(|cs| cs.handle.vulkan_video_encode_av1);
2670
2671        // Vulkan Video encoder setup commands to send after the client loop.
2672        struct VulkanEncoderSetup {
2673            surface_id: u32,
2674            codec: u8,
2675            qp: u8,
2676            width: u32,
2677            height: u32,
2678        }
2679        let mut pending_vulkan_encoder_setups: Vec<VulkanEncoderSetup> = Vec::new();
2680        let mut pending_vulkan_keyframe_requests: Vec<u32> = Vec::new();
2681
2682        for work in &client_work {
2683            for &sid in &work.subs {
2684                let Some(&(_, px_w, px_h, px_gen, px_timestamp_ms)) =
2685                    pixel_snapshot.iter().find(|&&(s, _, _, _, _)| s == sid)
2686                else {
2687                    let client = sess.clients.get_mut(&work.cid).unwrap();
2688                    client.skip_last_pixels_mismatch_count =
2689                        client.skip_last_pixels_mismatch_count.saturating_add(1);
2690                    continue;
2691                };
2692                {
2693                    let client = sess.clients.get_mut(&work.cid).unwrap();
2694                    client.encode_loop_iters = client.encode_loop_iters.saturating_add(1);
2695                }
2696                let client = sess.clients.get_mut(&work.cid).unwrap();
2697
2698                // Per-surface pacing gate: during burst-start, skip the
2699                // time-based check so frames flow at wire speed; otherwise
2700                // each surface independently waits for its own deadline.
2701                {
2702                    let (burst, deadline) = client.surface_subs.get(&sid).map_or((0, now), |s| {
2703                        (s.burst_remaining, s.next_send_at.unwrap_or(now))
2704                    });
2705                    if burst == 0 && deadline > now {
2706                        // Safety clamp: the deadline should never be more
2707                        // than 2× the send interval ahead.  If it is, snap
2708                        // back to now so encoding doesn't stall permanently.
2709                        let interval = surface_send_interval(client);
2710                        if deadline > now + interval + interval {
2711                            client.surface_subs.entry(sid).or_default().next_send_at = Some(now);
2712                        } else {
2713                            next_deadline = Some(match next_deadline {
2714                                Some(existing) => existing.min(deadline),
2715                                None => deadline,
2716                            });
2717                            client.skip_pacing_count = client.skip_pacing_count.saturating_add(1);
2718                            continue;
2719                        }
2720                    }
2721                }
2722
2723                // Skip encoding if the pixel data hasn't changed since the
2724                // last encode for this surface, unless a keyframe is needed.
2725                if !work.needs_keyframe
2726                    && let Some(last_gen) = client
2727                        .surface_subs
2728                        .get(&sid)
2729                        .and_then(|s| s.last_encoded_gen)
2730                    && last_gen == px_gen
2731                {
2732                    client.skip_same_gen_count = client.skip_same_gen_count.saturating_add(1);
2733                    continue;
2734                }
2735
2736                let pixels: blit_compositor::PixelData = {
2737                    let cs = sess.compositor.as_ref().unwrap();
2738                    match cs.last_pixels.get(&sid) {
2739                        Some(lp) if lp.width == px_w && lp.height == px_h => lp.pixels.clone(),
2740                        _ => {
2741                            let client = sess.clients.get_mut(&work.cid).unwrap();
2742                            client.skip_last_pixels_mismatch_count =
2743                                client.skip_last_pixels_mismatch_count.saturating_add(1);
2744                            continue;
2745                        }
2746                    }
2747                };
2748                let client = sess.clients.get_mut(&work.cid).unwrap();
2749
2750                let (enc_w, enc_h) = (px_w, px_h);
2751
2752                // Fast path: if the compositor already produced an encoded
2753                // bitstream (Vulkan Video), skip the SurfaceEncoder entirely
2754                // and send the pre-encoded data directly to the client.
2755                if let blit_compositor::PixelData::Encoded {
2756                    ref data,
2757                    is_keyframe,
2758                    codec_flag,
2759                } = pixels
2760                {
2761                    let flags = codec_flag
2762                        | if is_keyframe {
2763                            SURFACE_FRAME_FLAG_KEYFRAME
2764                        } else {
2765                            0
2766                        };
2767                    let msg = msg_surface_frame(
2768                        sid,
2769                        px_timestamp_ms,
2770                        flags,
2771                        px_w as u16,
2772                        px_h as u16,
2773                        data,
2774                    );
2775                    let bytes = msg.len();
2776                    match send_outbox(client, msg) {
2777                        Err(_e) => {
2778                            client.surface_needs_keyframe = true;
2779                        }
2780                        Ok(()) => {
2781                            client.surface_inflight_frames.push_back(InFlightFrame {
2782                                sent_at: now,
2783                                bytes,
2784                                paced: true,
2785                            });
2786                            if !is_keyframe {
2787                                client.avg_surface_frame_bytes = ewma_with_direction(
2788                                    client.avg_surface_frame_bytes,
2789                                    bytes as f32,
2790                                    0.5,
2791                                    0.125,
2792                                );
2793                            }
2794                            client.frames_sent = client.frames_sent.wrapping_add(1);
2795                            if client.surface_needs_keyframe && is_keyframe {
2796                                client.surface_needs_keyframe = false;
2797                            }
2798                            if let Some(s) = client.surface_subs.get_mut(&sid) {
2799                                s.burst_remaining = s.burst_remaining.saturating_sub(1);
2800                            }
2801                        }
2802                    }
2803                    encoded_client_surfaces.insert((work.cid, sid));
2804                    encode_dispatched_surfaces.insert(sid);
2805                    client.surface_subs.entry(sid).or_default().last_encoded_gen = Some(px_gen);
2806                    continue;
2807                }
2808
2809                // Skip if an encode or creation job is already in
2810                // flight for this surface.  Creations also block encode
2811                // dispatch: the encoder is None while creation runs,
2812                // and we don't want to re-queue another creation until
2813                // the first one completes.
2814                if client
2815                    .surface_subs
2816                    .get(&sid)
2817                    .is_some_and(|s| s.encode_in_flight || s.creation_in_flight)
2818                {
2819                    client.skip_in_flight_count = client.skip_in_flight_count.saturating_add(1);
2820                    let now_inst = Instant::now();
2821                    if now_inst.duration_since(client.last_skip_log).as_secs_f32() > 5.0 {
2822                        client.last_skip_log = now_inst;
2823                        let burst = client
2824                            .surface_subs
2825                            .get(&sid)
2826                            .map_or(0, |s| s.burst_remaining);
2827                        eprintln!(
2828                            "[encode-skip] cid={} sid={sid} reason=in_flight same_gen={} in_flight={} burst={burst}",
2829                            work.cid, client.skip_same_gen_count, client.skip_in_flight_count,
2830                        );
2831                    }
2832                    continue;
2833                }
2834
2835                let has_vulkan_enc = client.vulkan_video_surfaces.contains_key(&sid);
2836                let needs_new_encoder = if has_vulkan_enc {
2837                    false
2838                } else {
2839                    client
2840                        .surface_subs
2841                        .get(&sid)
2842                        .and_then(|s| s.encoder.as_ref())
2843                        .is_none_or(|e| e.source_dimensions() != (enc_w, enc_h))
2844                };
2845
2846                // If the encoder was dropped due to persistent nal_data=None,
2847                // back off for a short window before retrying.  Each retry
2848                // allocates GBM fds, so we don't want a genuinely broken
2849                // encoder (GPU lost) to recreate at tick rate and exhaust
2850                // the process fd limit — but a warm-up burst (compositor
2851                // hasn't imported the freshly-allocated external output
2852                // buffers yet) should recover within seconds without
2853                // requiring a user-driven resize/resubscribe.
2854                const NAL_NONE_RETRY_BACKOFF: Duration = Duration::from_secs(2);
2855                if needs_new_encoder
2856                    && client
2857                        .surface_subs
2858                        .get(&sid)
2859                        .is_some_and(|s| s.nal_none_streak >= 10)
2860                {
2861                    let ready_to_retry = client
2862                        .surface_subs
2863                        .get(&sid)
2864                        .and_then(|s| s.nal_none_latched_at)
2865                        .is_some_and(|t| now.duration_since(t) >= NAL_NONE_RETRY_BACKOFF);
2866                    if ready_to_retry {
2867                        if let Some(s) = client.surface_subs.get_mut(&sid) {
2868                            s.nal_none_streak = 0;
2869                            s.nal_none_latched_at = None;
2870                        }
2871                    } else {
2872                        continue;
2873                    }
2874                }
2875
2876                // --- Try Vulkan Video first ---
2877                if needs_new_encoder {
2878                    let codec_support = client
2879                        .surface_subs
2880                        .get(&sid)
2881                        .map(|s| s.codec_override)
2882                        .filter(|&c| c != 0)
2883                        .unwrap_or(client.surface_codec_support);
2884                    let quality = client
2885                        .surface_subs
2886                        .get(&sid)
2887                        .and_then(|s| s.quality_override)
2888                        .unwrap_or(state.config.surface_quality);
2889
2890                    for &pref in &state.config.surface_encoders {
2891                        if !pref.is_vulkan_video() {
2892                            continue;
2893                        }
2894                        if !pref.supported_by_client(codec_support) {
2895                            continue;
2896                        }
2897                        // Check compositor capability (pre-extracted above).
2898                        let available = match pref {
2899                            SurfaceEncoderPreference::VulkanVideoH264 => vk_encode_available,
2900                            SurfaceEncoderPreference::VulkanVideoAV1 => vk_encode_av1_available,
2901                            _ => false,
2902                        };
2903                        if !available {
2904                            continue;
2905                        }
2906                        // Vulkan Video 4:4:4 requires a YUV444 compute pipeline
2907                        // and 3-plane DPB surfaces that are not yet implemented.
2908                        // Skip so the fallback chain tries NVENC/VA-API/software.
2909                        if state.config.chroma.is_444() {
2910                            continue;
2911                        }
2912                        let qp = match pref {
2913                            SurfaceEncoderPreference::VulkanVideoAV1 => quality.av1_qp_for_vulkan(),
2914                            _ => quality.h264_qp(),
2915                        };
2916                        let enc_name: &'static str = match (pref, state.config.chroma) {
2917                            (
2918                                SurfaceEncoderPreference::VulkanVideoH264,
2919                                ChromaSubsampling::Cs444,
2920                            ) => "h264-vulkan 4:4:4",
2921                            (SurfaceEncoderPreference::VulkanVideoH264, _) => "h264-vulkan",
2922                            (
2923                                SurfaceEncoderPreference::VulkanVideoAV1,
2924                                ChromaSubsampling::Cs444,
2925                            ) => "av1-vulkan 4:4:4",
2926                            (SurfaceEncoderPreference::VulkanVideoAV1, _) => "av1-vulkan",
2927                            (_, ChromaSubsampling::Cs444) => "vulkan 4:4:4",
2928                            _ => "vulkan",
2929                        };
2930                        // Queue commands to send after the client loop.
2931                        pending_vulkan_encoder_setups.push(VulkanEncoderSetup {
2932                            surface_id: sid as u32,
2933                            codec: pref.vulkan_codec(),
2934                            qp,
2935                            width: px_w,
2936                            height: px_h,
2937                        });
2938                        pending_vulkan_keyframe_requests.push(sid as u32);
2939                        if let Some(s) = client.surface_subs.get_mut(&sid) {
2940                            s.encoder = None;
2941                        }
2942                        client
2943                            .vulkan_video_surfaces
2944                            .insert(sid, (enc_name, pref.codec_flag()));
2945                        let codec_str = match pref {
2946                            SurfaceEncoderPreference::VulkanVideoH264 => {
2947                                if state.config.chroma.is_444() {
2948                                    "avc1.F4001f".to_string()
2949                                } else {
2950                                    "avc1.640034".to_string()
2951                                }
2952                            }
2953                            SurfaceEncoderPreference::VulkanVideoAV1 => {
2954                                let profile = if state.config.chroma.is_444() { 2 } else { 0 };
2955                                let level = surface_encoder::av1_level_for(px_w, px_h);
2956                                format!("av01.{profile}.{level}M.08")
2957                            }
2958                            _ => String::new(),
2959                        };
2960                        let enc_msg = msg_surface_encoder(sid, enc_name, &codec_str);
2961                        let _ = send_outbox(client, enc_msg);
2962                        if state.config.verbose {
2963                            eprintln!(
2964                                "[surface-encoder] cid={} sid={sid} {px_w}x{px_h}: using {enc_name}",
2965                                work.cid,
2966                            );
2967                        }
2968                        break;
2969                    }
2970
2971                    // Defer encoder creation to spawn_blocking so the
2972                    // tick loop isn't blocked by slow VA-API init.
2973                    // The creation task allocates GBM buffers and
2974                    // returns the encoder; the first encode runs on a
2975                    // subsequent tick, after the main loop forwards
2976                    // the buffers to the compositor and the compositor
2977                    // commits a new frame through them.
2978                    {
2979                        let state = client.surface_subs.entry(sid).or_default();
2980                        state.encoder = None;
2981                        state.creation_in_flight = true;
2982                    }
2983                    create_jobs.push(CreateJob {
2984                        cid: work.cid,
2985                        sid,
2986                        px_w: enc_w,
2987                        px_h: enc_h,
2988                        params: EncoderCreateParams {
2989                            preferences: state.config.surface_encoders.clone(),
2990                            vaapi_device: state.config.vaapi_device.clone(),
2991                            quality,
2992                            verbose: state.config.verbose,
2993                            codec_support,
2994                            chroma: state.config.chroma,
2995                        },
2996                    });
2997                    continue;
2998                }
2999
3000                // If using Vulkan Video, handle keyframe via compositor
3001                // command and skip local encode — the fast path above
3002                // handles delivery.
3003                if client.vulkan_video_surfaces.contains_key(&sid) {
3004                    if work.needs_keyframe {
3005                        pending_vulkan_keyframe_requests.push(sid as u32);
3006                    }
3007                    client.skip_vulkan_await_count =
3008                        client.skip_vulkan_await_count.saturating_add(1);
3009                    let now_inst = Instant::now();
3010                    if now_inst.duration_since(client.last_skip_log).as_secs_f32() > 5.0 {
3011                        client.last_skip_log = now_inst;
3012                        eprintln!(
3013                            "[encode-skip] cid={} sid={sid} reason=vulkan_await \
3014                             (compositor not producing PixelData::Encoded) count={}",
3015                            work.cid, client.skip_vulkan_await_count,
3016                        );
3017                    }
3018                    continue;
3019                }
3020
3021                let encoder = client
3022                    .surface_subs
3023                    .get_mut(&sid)
3024                    .and_then(|s| s.encoder.take())
3025                    .unwrap();
3026                client.surface_subs.entry(sid).or_default().encode_in_flight = true;
3027                let needs_kf = work.needs_keyframe || needs_new_encoder;
3028                encoded_client_surfaces.insert((work.cid, sid));
3029                encode_dispatched_surfaces.insert(sid);
3030                encode_jobs.push(EncodeJob {
3031                    cid: work.cid,
3032                    sid,
3033                    px_w: enc_w,
3034                    px_h: enc_h,
3035                    pixels,
3036                    needs_keyframe: needs_kf,
3037                    encoder,
3038                    generation: px_gen,
3039                    timestamp_ms: px_timestamp_ms,
3040                });
3041            }
3042        }
3043
3044        // Send Vulkan Video encoder setup commands to compositor.
3045        if (!pending_vulkan_encoder_setups.is_empty()
3046            || !pending_vulkan_keyframe_requests.is_empty())
3047            && let Some(cs) = sess.compositor.as_ref()
3048        {
3049            for setup in pending_vulkan_encoder_setups {
3050                eprintln!(
3051                    "[vulkan-video] sending SetVulkanEncoder sid={} codec={} {}x{} qp={}",
3052                    setup.surface_id, setup.codec, setup.width, setup.height, setup.qp,
3053                );
3054                let _ = cs.handle.command_tx.send(
3055                    blit_compositor::CompositorCommand::SetVulkanEncoder {
3056                        surface_id: setup.surface_id,
3057                        codec: setup.codec,
3058                        qp: setup.qp,
3059                        width: setup.width,
3060                        height: setup.height,
3061                    },
3062                );
3063            }
3064            for surface_id in pending_vulkan_keyframe_requests {
3065                let _ = cs
3066                    .handle
3067                    .command_tx
3068                    .send(blit_compositor::CompositorCommand::RequestVulkanKeyframe { surface_id });
3069            }
3070            cs.handle.wake();
3071        }
3072
3073        // Advance per-surface pacing deadlines only for surfaces that
3074        // actually had an encode job collected.  Surfaces skipped due to
3075        // in-flight limits or unchanged pixels keep their current
3076        // deadline so the next tick retries without burning a time slot.
3077        for work in &client_work {
3078            if let Some(client) = sess.clients.get_mut(&work.cid) {
3079                let interval = surface_send_interval(client);
3080                for &sid in &work.subs {
3081                    if encoded_client_surfaces.contains(&(work.cid, sid)) {
3082                        let deadline = client
3083                            .surface_subs
3084                            .entry(sid)
3085                            .or_default()
3086                            .next_send_at
3087                            .get_or_insert(now);
3088                        advance_deadline(deadline, now, interval);
3089                    }
3090                }
3091            }
3092        }
3093    }
3094
3095    if !encode_jobs.is_empty() {
3096        // Fire-and-forget: spawn the encode and deliver asynchronously
3097        // so the tick loop is never blocked by slow encoders.
3098        let state2 = state.clone();
3099        tokio::spawn(async move {
3100            // Track (cid, sid) for each job so we can clear the sub's
3101            // `encode_in_flight` flag if the blocking task panics or
3102            // times out (otherwise that surface is permanently blocked).
3103            let job_ids: Vec<(u64, u16)> = encode_jobs.iter().map(|j| (j.cid, j.sid)).collect();
3104
3105            let handles: Vec<_> = encode_jobs
3106                .into_iter()
3107                .map(|job| {
3108                    tokio::task::spawn_blocking(move || {
3109                        let mut encoder = job.encoder;
3110                        if job.needs_keyframe {
3111                            encoder.request_keyframe();
3112                        }
3113                        let nal_data = encoder.encode_pixels(&job.pixels);
3114                        let codec_flag = encoder.codec_flag();
3115                        EncodeResult {
3116                            cid: job.cid,
3117                            sid: job.sid,
3118                            px_w: job.px_w,
3119                            px_h: job.px_h,
3120                            generation: job.generation,
3121                            encoder,
3122                            nal_data,
3123                            codec_flag,
3124                            timestamp_ms: job.timestamp_ms,
3125                        }
3126                    })
3127                })
3128                .collect();
3129
3130            // Timeout: if a hardware encoder hangs (e.g. vaSyncSurface on
3131            // AMD), don't block delivery of other surfaces' results forever.
3132            const ENCODE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
3133
3134            let mut results = Vec::with_capacity(handles.len());
3135            let mut failed: Vec<(u64, u16)> = Vec::new();
3136            for (i, h) in handles.into_iter().enumerate() {
3137                // Wrap the timeout in a nested tokio::spawn so that
3138                // panics from tokio::time::timeout during runtime
3139                // shutdown ("A Tokio 1.x context was found, but it is
3140                // being shutdown") are caught as JoinErrors instead of
3141                // crashing the outer task.
3142                let wrapper =
3143                    tokio::spawn(async move { tokio::time::timeout(ENCODE_TIMEOUT, h).await });
3144                match wrapper.await {
3145                    Ok(Ok(Ok(r))) => results.push(r),
3146                    Ok(Ok(Err(_join_err))) => {
3147                        // spawn_blocking panicked — encoder is lost.
3148                        let (cid, sid) = job_ids[i];
3149                        eprintln!("[surface-encoder] encode task panicked: cid={cid} sid={sid}",);
3150                        failed.push(job_ids[i]);
3151                    }
3152                    Ok(Err(_timeout)) => {
3153                        // Encoder hung (e.g. GPU hang in vaSyncSurface).
3154                        // The blocking thread is leaked but we must not
3155                        // let it stall all other surfaces forever.
3156                        let (cid, sid) = job_ids[i];
3157                        eprintln!(
3158                            "[surface-encoder] encode timed out ({}s): cid={cid} sid={sid}",
3159                            ENCODE_TIMEOUT.as_secs(),
3160                        );
3161                        failed.push(job_ids[i]);
3162                    }
3163                    Err(_join_err) => {
3164                        // Runtime shutting down — abandon remaining work.
3165                        eprintln!("[surface-encoder] runtime shutting down, aborting delivery");
3166                        return;
3167                    }
3168                }
3169            }
3170
3171            // Deliver encoded frames.
3172            let mut sess = state2.session.lock().await;
3173            let now = Instant::now();
3174            let mut local_encodes = 0u32;
3175            let mut local_encode_bytes = 0u64;
3176            let mut local_frames_sent = 0u32;
3177
3178            // Clean up in-flight tracking for panicked/timed-out encodes.
3179            // Without this, the surface is permanently blocked from
3180            // future encode jobs and frame delivery stops for it.
3181            for (cid, sid) in failed {
3182                if let Some(client) = sess.clients.get_mut(&cid) {
3183                    if let Some(s) = client.surface_subs.get_mut(&sid) {
3184                        s.encode_in_flight = false;
3185                    }
3186                    // The encoder was moved into the spawn_blocking closure
3187                    // and is now lost.  A fresh encoder will be created on
3188                    // the next tick when the sub's encoder is None.  Force
3189                    // a keyframe so the new encoder starts with a clean
3190                    // reference chain.
3191                    client.surface_needs_keyframe = true;
3192                }
3193            }
3194
3195            for result in results {
3196                // Return the encoder to the client, but only if its
3197                // dimensions still match the current surface.  A resize
3198                // that arrived while the encode was in flight will have
3199                // invalidated the old encoder; reinserting the stale one
3200                // would force the next tick to discard and recreate it,
3201                // wasting work and risking feeding a C encoder (openh264)
3202                // frames at the wrong resolution.
3203                let expected_dims: Option<(u32, u32)> = sess
3204                    .compositor
3205                    .as_ref()
3206                    .and_then(|cs| cs.last_pixels.get(&result.sid))
3207                    .map(|lp| (lp.width, lp.height));
3208                let dims_match =
3209                    expected_dims.is_some_and(|d| result.encoder.source_dimensions() == d);
3210
3211                if let Some(client) = sess.clients.get_mut(&result.cid) {
3212                    let state = client.surface_subs.entry(result.sid).or_default();
3213                    state.encode_in_flight = false;
3214                    let invalidated = std::mem::replace(&mut state.encoder_invalidated, false);
3215                    if dims_match && !invalidated {
3216                        state.encoder = Some(result.encoder);
3217                    }
3218                    // Record the generation we just encoded so we don't
3219                    // re-encode identical pixel data on subsequent ticks.
3220                    state.last_encoded_gen = Some(result.generation);
3221                }
3222
3223                let Some((nal_data, is_keyframe)) = result.nal_data else {
3224                    if let Some(client) = sess.clients.get_mut(&result.cid) {
3225                        let state = client.surface_subs.entry(result.sid).or_default();
3226                        state.nal_none_streak += 1;
3227                        let streak = state.nal_none_streak;
3228                        if streak == 10 {
3229                            state.encoder = None;
3230                            state.nal_none_latched_at = Some(now);
3231                            client.surface_needs_keyframe = true;
3232                            eprintln!(
3233                                "[encode] nal_data=None x{streak} sid={} cid={} {}x{} — dropping encoder, backing off retry",
3234                                result.sid, result.cid, result.px_w, result.px_h,
3235                            );
3236                        } else if streak < 10 {
3237                            eprintln!(
3238                                "[encode] nal_data=None sid={} cid={} {}x{}",
3239                                result.sid, result.cid, result.px_w, result.px_h,
3240                            );
3241                        }
3242                        // streak >= 10: suppress the log spam
3243                    }
3244                    continue;
3245                };
3246                // Encoder produced output — reset the None streak.
3247                if let Some(client) = sess.clients.get_mut(&result.cid)
3248                    && let Some(s) = client.surface_subs.get_mut(&result.sid)
3249                {
3250                    s.nal_none_streak = 0;
3251                }
3252
3253                {
3254                    static EC: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3255                    let n = EC.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3256                    if n < 5 || n.is_multiple_of(1000) {
3257                        eprintln!(
3258                            "[encode #{n}] sid={} {}x{} kf={is_keyframe} bytes={}",
3259                            result.sid,
3260                            result.px_w,
3261                            result.px_h,
3262                            nal_data.len(),
3263                        );
3264                    }
3265                }
3266
3267                local_encodes += 1;
3268                local_encode_bytes += nal_data.len() as u64;
3269
3270                let flags = result.codec_flag
3271                    | if is_keyframe {
3272                        SURFACE_FRAME_FLAG_KEYFRAME
3273                    } else {
3274                        0
3275                    };
3276                let msg = msg_surface_frame(
3277                    result.sid,
3278                    result.timestamp_ms,
3279                    flags,
3280                    result.px_w as u16,
3281                    result.px_h as u16,
3282                    &nal_data,
3283                );
3284                let bytes = msg.len();
3285
3286                let Some(client) = sess.clients.get_mut(&result.cid) else {
3287                    continue;
3288                };
3289
3290                // Don't check window_open here — we already checked before
3291                // starting the encode job.  Dropping an encoded P-frame
3292                // breaks the decoder's reference chain and causes glitches.
3293                // With the per-sub `encode_in_flight` flag limiting to 1
3294                // concurrent encode per surface, at most 1 frame arrives
3295                // after the window closes, which is acceptable.
3296                match send_outbox(client, msg) {
3297                    Err(_e) => {
3298                        // Receiver dropped (client disconnected during encode).
3299                        // Request keyframe so the next encoder starts clean.
3300                        client.surface_needs_keyframe = true;
3301                    }
3302                    Ok(()) => {
3303                        // Track surface frames in their own inflight queue
3304                        // so surface ACKs feed shared goodput / RTT without
3305                        // polluting terminal frame-size averages or probing.
3306                        client.surface_inflight_frames.push_back(InFlightFrame {
3307                            sent_at: now,
3308                            bytes,
3309                            paced: true,
3310                        });
3311                        // Prefer updating avg_surface_frame_bytes from delta
3312                        // (non-keyframe) frames — keyframes are 5-10× larger
3313                        // than P-frames and would inflate the average, dragging
3314                        // surface_pacing_fps below the sustainable rate.
3315                        //
3316                        // However, we must still update from keyframes with a
3317                        // very slow alpha: all-intra encoders (e.g. AV1 VAAPI
3318                        // before P-frame support) only produce keyframes, so
3319                        // skipping them entirely leaves the average stuck at
3320                        // the 8 KB initial value, causing the pacer to wildly
3321                        // overshoot the send rate and saturate the transport.
3322                        if !is_keyframe {
3323                            client.avg_surface_frame_bytes = ewma_with_direction(
3324                                client.avg_surface_frame_bytes,
3325                                bytes as f32,
3326                                0.5,
3327                                0.125,
3328                            );
3329                        } else if client.avg_surface_frame_bytes <= 16_384.0 {
3330                            // First keyframe while the estimate is still at or
3331                            // near the initial 8 KB seed.  No P-frame data has
3332                            // been seen yet, so the seed is pure fiction.  Use a
3333                            // realistic P-frame estimate: keyframes are typically
3334                            // 3-8× larger than P-frames, so divide by 4.  This
3335                            // prevents surface_pacing_fps from being wildly
3336                            // optimistic (8 KB → 32 fps at 256 KB/s) when the
3337                            // actual frames are 50-200 KB keyframes.
3338                            client.avg_surface_frame_bytes = (bytes as f32 / 4.0).max(4_096.0);
3339                        } else {
3340                            // Slow convergence so one keyframe doesn't wreck
3341                            // the estimate for dozens of subsequent P-frames.
3342                            client.avg_surface_frame_bytes = ewma_with_direction(
3343                                client.avg_surface_frame_bytes,
3344                                bytes as f32,
3345                                0.05,
3346                                0.05,
3347                            );
3348                        }
3349                        client.frames_sent = client.frames_sent.wrapping_add(1);
3350                        local_frames_sent += 1;
3351                        if client.surface_needs_keyframe && is_keyframe {
3352                            client.surface_needs_keyframe = false;
3353                        }
3354                        if let Some(s) = client.surface_subs.get_mut(&result.sid) {
3355                            s.burst_remaining = s.burst_remaining.saturating_sub(1);
3356                        }
3357                    }
3358                }
3359            }
3360            sess.surface_encodes += local_encodes;
3361            sess.surface_encode_bytes += local_encode_bytes;
3362            sess.surface_frames_sent += local_frames_sent;
3363            drop(sess);
3364            // Wake the tick loop so it can request the next frame.
3365            state2.delivery_notify.notify_one();
3366        });
3367    }
3368
3369    if !create_jobs.is_empty() {
3370        // Encoder creation runs on spawn_blocking so VA-API device open
3371        // and context allocation don't stall the tick loop.  When the
3372        // task lands, the main loop installs the encoder into the sub's
3373        // `encoder` slot, forwards the GBM buffers to the compositor
3374        // (`SetExternalOutputBuffers`), and sends S2C_SURFACE_ENCODER
3375        // to the client.  Encoding starts on the NEXT tick — once the
3376        // compositor has committed a frame through the new buffers.
3377        let state2 = state.clone();
3378        tokio::spawn(async move {
3379            // Track (cid, sid) for each job so we can clear
3380            // `creation_in_flight` if a task panics or times out.
3381            let job_ids: Vec<(u64, u16)> = create_jobs.iter().map(|j| (j.cid, j.sid)).collect();
3382
3383            let handles: Vec<_> = create_jobs
3384                .into_iter()
3385                .map(|job| {
3386                    tokio::task::spawn_blocking(move || {
3387                        let params = job.params;
3388                        let mut encoder = match SurfaceEncoder::new(
3389                            &params.preferences,
3390                            job.px_w,
3391                            job.px_h,
3392                            &params.vaapi_device,
3393                            params.quality,
3394                            params.verbose,
3395                            params.codec_support,
3396                            params.chroma,
3397                        ) {
3398                            Ok(enc) => enc,
3399                            Err(err) => {
3400                                if params.verbose {
3401                                    eprintln!(
3402                                        "[surface-encoder] cid={} sid={} {}x{}: {err}",
3403                                        job.cid, job.sid, job.px_w, job.px_h,
3404                                    );
3405                                }
3406                                return CreateResult {
3407                                    cid: job.cid,
3408                                    sid: job.sid,
3409                                    encoder: None,
3410                                    fresh: None,
3411                                };
3412                            }
3413                        };
3414
3415                        #[cfg(target_os = "linux")]
3416                        let external_bufs = {
3417                            {
3418                                let drm_fd = encoder.drm_fd_raw();
3419                                let count = encoder.gbm_buffers().len();
3420                                if count > 0 {
3421                                    encoder.allocate_nv12_buffers(drm_fd, count);
3422                                }
3423                            }
3424                            let gbm_bufs = encoder.gbm_buffers();
3425                            if gbm_bufs.is_empty() {
3426                                Vec::new()
3427                            } else {
3428                                let nv12_bufs = encoder.gbm_nv12_buffers();
3429                                let (enc_w, enc_h) = encoder.encoder_dimensions();
3430                                let bufs: Result<Vec<_>, std::io::Error> = gbm_bufs
3431                                    .iter()
3432                                    .enumerate()
3433                                    .map(|(i, b)| {
3434                                        let nv12 = nv12_bufs.get(i);
3435                                        Ok(blit_compositor::ExternalOutputBuffer {
3436                                            fd: std::sync::Arc::new(b.fd.try_clone()?),
3437                                            fourcc: 0x34325241,
3438                                            modifier: 0,
3439                                            stride: b.stride,
3440                                            offset: 0,
3441                                            width: b.width,
3442                                            height: b.height,
3443                                            va_surface_id: 0,
3444                                            va_display: 0,
3445                                            planes: vec![blit_compositor::ExternalOutputPlane {
3446                                                offset: 0,
3447                                                pitch: b.stride,
3448                                            }],
3449                                            nv12_fd: nv12.map(|n| n.fd.clone()),
3450                                            nv12_stride: nv12.map_or(0, |n| n.stride),
3451                                            nv12_uv_offset: nv12.map_or(0, |n| n.uv_offset),
3452                                            nv12_modifier: nv12.map_or(0, |n| n.modifier),
3453                                            nv12_width: enc_w,
3454                                            nv12_height: enc_h,
3455                                        })
3456                                    })
3457                                    .collect();
3458                                match bufs {
3459                                    Ok(b) => b,
3460                                    Err(e) => {
3461                                        eprintln!("[encode] dup gbm fd failed: {e}");
3462                                        Vec::new()
3463                                    }
3464                                }
3465                            }
3466                        };
3467                        let fresh = FreshEncoder {
3468                            name: encoder.encoder_name(),
3469                            codec_string: encoder.webcodecs_codec_string(),
3470                            #[cfg(target_os = "linux")]
3471                            external_bufs,
3472                        };
3473                        CreateResult {
3474                            cid: job.cid,
3475                            sid: job.sid,
3476                            encoder: Some(encoder),
3477                            fresh: Some(fresh),
3478                        }
3479                    })
3480                })
3481                .collect();
3482
3483            const CREATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
3484            let mut results: Vec<CreateResult> = Vec::with_capacity(handles.len());
3485            let mut failed: Vec<(u64, u16)> = Vec::new();
3486            for (i, h) in handles.into_iter().enumerate() {
3487                let wrapper =
3488                    tokio::spawn(async move { tokio::time::timeout(CREATE_TIMEOUT, h).await });
3489                match wrapper.await {
3490                    Ok(Ok(Ok(r))) => results.push(r),
3491                    Ok(Ok(Err(_))) | Ok(Err(_)) => {
3492                        let (cid, sid) = job_ids[i];
3493                        eprintln!("[surface-encoder] create task failed: cid={cid} sid={sid}",);
3494                        failed.push(job_ids[i]);
3495                    }
3496                    Err(_) => return,
3497                }
3498            }
3499
3500            let mut sess = state2.session.lock().await;
3501            let now = Instant::now();
3502
3503            // Clear creation_in_flight for failed tasks; latch a brief
3504            // backoff so the next tick doesn't immediately retry.
3505            for (cid, sid) in failed {
3506                if let Some(client) = sess.clients.get_mut(&cid)
3507                    && let Some(s) = client.surface_subs.get_mut(&sid)
3508                {
3509                    s.creation_in_flight = false;
3510                    s.nal_none_streak = 10;
3511                    s.nal_none_latched_at = Some(now);
3512                }
3513            }
3514
3515            for result in results {
3516                let Some(encoder) = result.encoder else {
3517                    if let Some(client) = sess.clients.get_mut(&result.cid)
3518                        && let Some(s) = client.surface_subs.get_mut(&result.sid)
3519                    {
3520                        s.creation_in_flight = false;
3521                        s.nal_none_streak = 10;
3522                        s.nal_none_latched_at = Some(now);
3523                    }
3524                    continue;
3525                };
3526
3527                // Move the external buffers (and register them with the
3528                // compositor) BEFORE stashing the encoder, so subsequent
3529                // ticks see the encoder only once its buffers are live.
3530                let fresh = result.fresh;
3531                #[cfg(target_os = "linux")]
3532                {
3533                    if let Some(f) = &fresh
3534                        && !f.external_bufs.is_empty()
3535                        && let Some(cs) = sess.compositor.as_mut()
3536                    {
3537                        cs.last_pixels.remove(&result.sid);
3538                    }
3539                }
3540                #[cfg(target_os = "linux")]
3541                let (fresh_meta, external_bufs) = match fresh {
3542                    Some(f) => (Some((f.name, f.codec_string)), Some(f.external_bufs)),
3543                    None => (None, None),
3544                };
3545                #[cfg(not(target_os = "linux"))]
3546                let fresh_meta = fresh.map(|f| (f.name, f.codec_string));
3547
3548                #[cfg(target_os = "linux")]
3549                if let Some(bufs) = external_bufs
3550                    && !bufs.is_empty()
3551                    && let Some(cs) = sess.compositor.as_mut()
3552                {
3553                    let _ = cs.handle.command_tx.send(
3554                        blit_compositor::CompositorCommand::SetExternalOutputBuffers {
3555                            surface_id: result.sid as u32,
3556                            buffers: bufs,
3557                        },
3558                    );
3559                    cs.handle.wake();
3560                }
3561
3562                if let Some(client) = sess.clients.get_mut(&result.cid) {
3563                    let state = client.surface_subs.entry(result.sid).or_default();
3564                    state.creation_in_flight = false;
3565                    let invalidated = std::mem::replace(&mut state.encoder_invalidated, false);
3566                    if invalidated {
3567                        // Preferences changed mid-creation (codec/quality
3568                        // resubscribe).  Drop the encoder we just built;
3569                        // the next tick will dispatch a fresh creation
3570                        // with the new prefs.
3571                        continue;
3572                    }
3573                    state.encoder = Some(encoder);
3574                    state.nal_none_streak = 0;
3575                    state.nal_none_latched_at = None;
3576                    if let Some((name, codec_string)) = fresh_meta {
3577                        let enc_msg = msg_surface_encoder(result.sid, name, &codec_string);
3578                        let _ = send_outbox(client, enc_msg);
3579                    }
3580                }
3581            }
3582            drop(sess);
3583            state2.delivery_notify.notify_one();
3584        });
3585    }
3586
3587    // Request frames from the compositor for surfaces that have at least
3588    // one subscriber whose pacing says it can accept a new frame.  This
3589    // fires the surface's pending wl_surface.frame callback so the
3590    // Wayland client will paint and commit its next frame.
3591    //
3592    // Demand-driven with pipeline overlap:
3593    //   When an encode job is dispatched, we eagerly pre-request the next
3594    //   frame so the Wayland client paints in parallel with the encode.
3595    //   Fresh pixels are ready when the encode completes, turning the
3596    //   serial   encode + round_trip   into   max(encode, round_trip).
3597    {
3598        // Only request frames for surfaces where at least one client is
3599        // ready to consume the result.  Without this check, apps that are
3600        // always ready to paint (video players like mpv) cause a hot loop:
3601        // RequestFrame → commit → SurfaceCommit wakes tick → no client
3602        // ready → RequestFrame again → 100% CPU.
3603        let mut wanted: HashSet<u16> = HashSet::new();
3604
3605        // Pre-request: surfaces with an encode just dispatched.  The
3606        // compositor will render the next frame while the encode runs,
3607        // so pixels are ready when the next pacing window opens.
3608        for &sid in &encode_dispatched_surfaces {
3609            wanted.insert(sid);
3610        }
3611        let mut blanket_requested = false;
3612        // Request frames for all known surfaces so Wayland apps can make
3613        // rendering progress.  Video players (mpv) need frequent callbacks
3614        // to advance their presentation clock; browsers need them for
3615        // page loads and animations.
3616        if let Some(cs) = sess.compositor.as_ref()
3617            && let Some(interval) = blanket_frame_interval(&sess)
3618            && now.duration_since(cs.last_blanket_frame_request) >= interval
3619        {
3620            for &sid in cs.surfaces.keys() {
3621                wanted.insert(sid);
3622            }
3623            blanket_requested = true;
3624        }
3625        for client in sess.clients.values() {
3626            // Don't gate frame requests on surface_window_open — the
3627            // compositor should keep producing pixels even when the
3628            // inflight window is closed.  Otherwise, recovery after a
3629            // wifi stall has to wait for the full render pipeline to
3630            // flush (request → paint → commit → encode) before the
3631            // first frame can be sent, causing a visible hang.
3632            if client.surface_subscriptions.is_empty() {
3633                continue;
3634            }
3635            for &sid in &client.surface_subscriptions {
3636                let (burst, deadline) = client.surface_subs.get(&sid).map_or((0, now), |s| {
3637                    (s.burst_remaining, s.next_send_at.unwrap_or(now))
3638                });
3639                if deadline <= now || burst > 0 {
3640                    wanted.insert(sid);
3641                } else {
3642                    next_deadline = Some(match next_deadline {
3643                        Some(existing) => existing.min(deadline),
3644                        None => deadline,
3645                    });
3646                }
3647            }
3648        }
3649
3650        if let Some(cs) = sess.compositor.as_mut() {
3651            if blanket_requested {
3652                cs.last_blanket_frame_request = now;
3653            }
3654
3655            // Gate: at most one RequestFrame per surface per millisecond.
3656            // This ensures each wl_callback.done carries a distinct
3657            // elapsed_ms timestamp (video players like mpv use these to
3658            // pace their presentation clock).  Supports up to 1 kHz.
3659            // The gate auto-expires: if the app doesn't commit, the next
3660            // tick ≥1 ms later will send a fresh request.
3661            const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(1);
3662            let mut sent_any = false;
3663            for sid in &wanted {
3664                let dominated = cs
3665                    .last_frame_request
3666                    .get(sid)
3667                    .is_some_and(|&t| now.duration_since(t) < MIN_REQUEST_INTERVAL);
3668                if !dominated {
3669                    cs.last_frame_request.insert(*sid, now);
3670                    let _ = cs
3671                        .handle
3672                        .command_tx
3673                        .send(CompositorCommand::RequestFrame { surface_id: *sid });
3674                    sent_any = true;
3675                }
3676            }
3677            if sent_any {
3678                cs.handle.wake();
3679            }
3680        }
3681    }
3682
3683    // Yield the session lock briefly so pending encode deliveries from
3684    // previous ticks can acquire the lock and send their frames without
3685    // waiting for terminal processing to complete.  This reduces the
3686    // latency between encode completion and frame-on-wire.
3687    drop(sess);
3688    tokio::task::yield_now().await;
3689    sess = state.session.lock().await;
3690
3691    let max_fps = sess
3692        .clients
3693        .values()
3694        .map(browser_pacing_fps)
3695        .fold(1.0_f32, f32::max);
3696    let title_interval = Duration::from_secs_f64(1.0 / max_fps as f64);
3697    let ids: Vec<u16> = sess.ptys.keys().copied().collect();
3698    for &id in &ids {
3699        let Some(pty) = sess.ptys.get_mut(&id) else {
3700            continue;
3701        };
3702        if pty.driver.take_title_dirty() {
3703            pty.mark_dirty();
3704            pty.title_pending = true;
3705        }
3706        if pty.title_pending && now.duration_since(pty.last_title_send) >= title_interval {
3707            let msg = {
3708                let title_bytes = pty.driver.title().as_bytes();
3709                let mut msg = Vec::with_capacity(3 + title_bytes.len());
3710                msg.push(S2C_TITLE);
3711                msg.extend_from_slice(&id.to_le_bytes());
3712                msg.extend_from_slice(title_bytes);
3713                msg
3714            };
3715            pty.last_title_send = now;
3716            pty.title_pending = false;
3717            sess.send_to_all(&msg);
3718        }
3719    }
3720
3721    // Drain bytes from PTY reader channels. This is the only place
3722    // process() is called, so there is no contention with the readers.
3723    //
3724    // End-to-end flow control: when at least one client is subscribed to
3725    // a PTY and its `ready_frames` queue is full, stop draining `byte_rx`
3726    // for that PTY.  `byte_rx` then fills to its bounded capacity, which
3727    // blocks the reader task's `byte_tx.blocking_send`, which fills the
3728    // kernel's PTY master buffer, which blocks the child process's
3729    // `write(stdout, ...)`.  Sync-bracketed frames are never silently
3730    // dropped; the producer is slowed instead.  PTYs with no subscribers
3731    // drain unconditionally so background processes aren't throttled by
3732    // nobody-watching.
3733    let ptys_with_subscribers: HashSet<u16> = sess
3734        .clients
3735        .values()
3736        .flat_map(|c| c.subscriptions.iter().copied())
3737        .collect();
3738    let mut eof_ptys: Vec<u16> = Vec::with_capacity(ids.len());
3739    for &id in &ids {
3740        let Some(pty) = sess.ptys.get_mut(&id) else {
3741            continue;
3742        };
3743        let has_subscriber = ptys_with_subscribers.contains(&id);
3744        loop {
3745            if has_subscriber && pty.ready_frames.len() >= READY_FRAME_QUEUE_CAP {
3746                break;
3747            }
3748            let Ok(input) = pty.byte_rx.try_recv() else {
3749                break;
3750            };
3751            match input {
3752                PtyInput::Data(data) => {
3753                    pty::respond_to_queries(
3754                        &pty.handle,
3755                        &data,
3756                        pty.driver.size(),
3757                        pty.driver.cursor_position(),
3758                    );
3759                    pty.driver.process(&data);
3760                    pty.mark_dirty();
3761                }
3762                PtyInput::SyncBoundary { before } => {
3763                    if !before.is_empty() {
3764                        pty::respond_to_queries(
3765                            &pty.handle,
3766                            &before,
3767                            pty.driver.size(),
3768                            pty.driver.cursor_position(),
3769                        );
3770                        pty.driver.process(&before);
3771                        pty.mark_dirty();
3772                    }
3773                    if !pty.driver.synced_output() {
3774                        let frame = take_snapshot(pty);
3775                        enqueue_ready_frame(&mut pty.ready_frames, frame);
3776                        pty.clear_dirty();
3777                    }
3778                }
3779                PtyInput::Eof => {
3780                    eof_ptys.push(id);
3781                }
3782            }
3783        }
3784    }
3785    // Handle EOF outside the borrow loop.
3786    drop(sess);
3787    for id in eof_ptys {
3788        tokio::time::sleep(Duration::from_millis(50)).await;
3789        cleanup_pty_internal(id, state).await;
3790    }
3791    let mut sess = state.session.lock().await;
3792
3793    // Only snapshot PTYs that have at least one client ready to consume a fresh
3794    // frame right now. This avoids burning CPU on snapshot+diff+compress work
3795    // while the lead is merely waiting for its next pacing deadline.
3796    let needful_ptys: HashSet<u16> = sess
3797        .clients
3798        .values()
3799        .flat_map(|c| {
3800            let reserve_preview_slot = client_has_due_preview(&sess, c, now);
3801            c.subscriptions.iter().copied().filter(move |pid| {
3802                let scrolled = c.scroll_offsets.get(pid).copied().unwrap_or(0) > 0;
3803                if Some(*pid) == c.lead {
3804                    !scrolled && can_send_frame(c, now, reserve_preview_slot)
3805                } else {
3806                    !scrolled && can_send_preview(c, *pid, now)
3807                }
3808            })
3809        })
3810        .collect();
3811
3812    let mut snapshots: HashMap<u16, FrameState> = HashMap::new();
3813    for &id in &ids {
3814        let Some(pty) = sess.ptys.get_mut(&id) else {
3815            continue;
3816        };
3817        if needful_ptys.contains(&id)
3818            && let Some(frame) = pty.ready_frames.pop_front()
3819        {
3820            snapshots.insert(id, frame);
3821            sess.tick_snaps += 1;
3822            continue;
3823        }
3824        if !should_snapshot_pty(
3825            pty.dirty,
3826            needful_ptys.contains(&id),
3827            pty.driver.synced_output(),
3828        ) {
3829            continue;
3830        }
3831        // Applications that care about complete-frame boundaries should
3832        // use DEC synchronized output (?2026). Outside that bracket we
3833        // snapshot immediately instead of heuristically coalescing reads.
3834        snapshots.insert(id, take_snapshot(pty));
3835        pty.clear_dirty();
3836        sess.tick_snaps += 1;
3837    }
3838
3839    let client_ids: Vec<u64> = sess.clients.keys().copied().collect();
3840    for cid in client_ids {
3841        // When the pipe is idle (nothing in flight), RTT cannot be measured
3842        // and the last observed value stales.  Decay it toward min_rtt so
3843        // a stale congested RTT doesn't permanently suppress the send window
3844        // after congestion clears or traffic patterns change (e.g. switching
3845        // from a large-frame burst to idle small-frame updates).
3846        if let Some(c) = sess.clients.get_mut(&cid) {
3847            if c.inflight_bytes == 0 && c.min_rtt_ms > 0.0 && c.rtt_ms > c.min_rtt_ms {
3848                c.rtt_ms = (c.rtt_ms * 0.99 + c.min_rtt_ms * 0.01).max(c.min_rtt_ms);
3849            }
3850            // Decay stale browser metrics so a missed/delayed metrics update
3851            // can't permanently block the delivery loop.
3852            if c.last_metrics_update.elapsed() > Duration::from_secs(1) {
3853                c.browser_backlog_frames = 0;
3854                c.browser_ack_ahead_frames = 0;
3855            }
3856        }
3857        let (
3858            lead,
3859            subscriptions,
3860            scrolled_ptys,
3861            can_send_lead,
3862            lead_has_window,
3863            any_send_window,
3864            lead_deadline,
3865        ) = {
3866            let Some(c) = sess.clients.get(&cid) else {
3867                continue;
3868            };
3869            let reserve_preview_slot = client_has_due_preview(&sess, c, now);
3870            (
3871                c.lead,
3872                c.subscriptions.iter().copied().collect::<Vec<_>>(),
3873                c.scroll_offsets
3874                    .iter()
3875                    .map(|(&k, &v)| (k, v))
3876                    .collect::<Vec<_>>(),
3877                can_send_frame(c, now, reserve_preview_slot),
3878                lead_window_open(c, reserve_preview_slot),
3879                lead_window_open(c, reserve_preview_slot) || window_open(c),
3880                c.next_send_at,
3881            )
3882        };
3883
3884        if subscriptions.is_empty() {
3885            continue;
3886        }
3887
3888        // Send scrollback frames for any scrolled PTY.
3889        for &(scroll_pid, scroll_offset) in &scrolled_ptys {
3890            if scroll_offset == 0 {
3891                continue;
3892            }
3893            let is_lead = lead == Some(scroll_pid);
3894            let can_send = if is_lead { can_send_lead } else { true };
3895            if can_send {
3896                let prev_frame = {
3897                    let Some(c) = sess.clients.get(&cid) else {
3898                        continue;
3899                    };
3900                    c.scroll_caches
3901                        .get(&scroll_pid)
3902                        .cloned()
3903                        .unwrap_or_default()
3904                };
3905                let outcome = if let Some(pty) = sess.ptys.get_mut(&scroll_pid) {
3906                    if let Some((msg, new_frame)) =
3907                        build_scrollback_update(pty, scroll_pid, scroll_offset, &prev_frame)
3908                    {
3909                        let Some(c) = sess.clients.get_mut(&cid) else {
3910                            break;
3911                        };
3912                        let bytes = msg.len();
3913                        if send_outbox(c, msg).is_ok() {
3914                            c.scroll_caches.insert(scroll_pid, new_frame);
3915                            record_send(c, bytes, now, is_lead);
3916                            c.frames_sent += 1;
3917                            SendOutcome::Sent
3918                        } else {
3919                            SendOutcome::Backpressured
3920                        }
3921                    } else {
3922                        SendOutcome::NoChange
3923                    }
3924                } else {
3925                    SendOutcome::NoChange
3926                };
3927                match outcome {
3928                    SendOutcome::Sent => {}
3929                    SendOutcome::Backpressured => {
3930                        if let Some(pty) = sess.ptys.get_mut(&scroll_pid) {
3931                            pty.mark_dirty();
3932                        }
3933                    }
3934                    SendOutcome::NoChange => {}
3935                }
3936            } else if is_lead && lead_has_window {
3937                next_deadline = Some(match next_deadline {
3938                    Some(existing) => existing.min(lead_deadline),
3939                    None => lead_deadline,
3940                });
3941            }
3942        }
3943
3944        let lead_scroll_offset = lead
3945            .and_then(|pid| {
3946                scrolled_ptys
3947                    .iter()
3948                    .find(|&&(k, _)| k == pid)
3949                    .map(|&(_, v)| v)
3950            })
3951            .unwrap_or(0);
3952
3953        if let Some(pid) = lead {
3954            if lead_scroll_offset == 0 && can_send_lead {
3955                if let Some(cur) = snapshots.get(&pid).cloned() {
3956                    let previous = sess
3957                        .clients
3958                        .get(&cid)
3959                        .and_then(|c| c.last_sent.get(&pid).cloned())
3960                        .unwrap_or_default();
3961                    drop(sess);
3962                    let msg = build_update_msg(pid, &cur, &previous);
3963                    sess = state.session.lock().await;
3964                    let Some(c) = sess.clients.get_mut(&cid) else {
3965                        continue;
3966                    };
3967                    match try_send_update(c, pid, cur, msg, now, true) {
3968                        SendOutcome::Sent => {}
3969                        SendOutcome::Backpressured => {
3970                            if let Some(pty) = sess.ptys.get_mut(&pid) {
3971                                pty.mark_dirty();
3972                            }
3973                        }
3974                        SendOutcome::NoChange => {}
3975                    }
3976                } else {
3977                    let has_pending = sess
3978                        .ptys
3979                        .get(&pid)
3980                        .map(pty_has_visual_update)
3981                        .unwrap_or(false);
3982                    let _ = has_pending;
3983                }
3984            } else {
3985                let has_pending = sess
3986                    .ptys
3987                    .get(&pid)
3988                    .map(pty_has_visual_update)
3989                    .unwrap_or(false);
3990                if has_pending && lead_has_window {
3991                    next_deadline = Some(match next_deadline {
3992                        Some(existing) => existing.min(lead_deadline),
3993                        None => lead_deadline,
3994                    });
3995                }
3996            }
3997        }
3998
3999        if !any_send_window {
4000            continue;
4001        }
4002
4003        let mut preview_ids = subscriptions;
4004        preview_ids.retain(|pid| Some(*pid) != lead);
4005        preview_ids.sort_unstable();
4006
4007        for pid in preview_ids {
4008            let (preview_can_send, preview_due_at, preview_has_window) =
4009                match sess.clients.get(&cid) {
4010                    Some(c) => (
4011                        can_send_preview(c, pid, now),
4012                        preview_deadline(c, pid, now),
4013                        window_open(c),
4014                    ),
4015                    None => (false, now, false),
4016                };
4017            if !preview_has_window {
4018                break;
4019            }
4020            if !preview_can_send {
4021                let has_pending = sess
4022                    .ptys
4023                    .get(&pid)
4024                    .map(pty_has_visual_update)
4025                    .unwrap_or(false);
4026                // Only set a deadline when the reason is *timing* (deadline
4027                // in the future), not capacity (preview window closed).
4028                // A past deadline here spins the delivery loop because
4029                // sleep_until(past) returns immediately.
4030                if has_pending && preview_due_at > now {
4031                    next_deadline = Some(match next_deadline {
4032                        Some(existing) => existing.min(preview_due_at),
4033                        None => preview_due_at,
4034                    });
4035                }
4036                continue;
4037            }
4038            let Some(cur) = snapshots.get(&pid) else {
4039                let has_pending = sess
4040                    .ptys
4041                    .get(&pid)
4042                    .map(pty_has_visual_update)
4043                    .unwrap_or(false);
4044                let _ = has_pending;
4045                continue;
4046            };
4047            let cur = cur.clone();
4048            let previous = sess
4049                .clients
4050                .get(&cid)
4051                .and_then(|c| c.last_sent.get(&pid).cloned())
4052                .unwrap_or_default();
4053            drop(sess);
4054            let msg = build_update_msg(pid, &cur, &previous);
4055            sess = state.session.lock().await;
4056            let Some(c) = sess.clients.get_mut(&cid) else {
4057                break;
4058            };
4059            match try_send_update(c, pid, cur, msg, now, false) {
4060                SendOutcome::Sent => {
4061                    record_preview_send(c, pid, now);
4062                }
4063                SendOutcome::Backpressured => {
4064                    if let Some(pty) = sess.ptys.get_mut(&pid) {
4065                        pty.mark_dirty();
4066                    }
4067                    break;
4068                }
4069                SendOutcome::NoChange => {}
4070            }
4071        }
4072    }
4073
4074    // -- Audio frame delivery -----------------------------------------------
4075    #[cfg(target_os = "linux")]
4076    let any_audio_subscribed = sess.clients.values().any(|c| c.audio_subscribed);
4077    // Propagate the subscription state to the encoder task so it can skip
4078    // the Opus encode step when no one is listening.
4079    #[cfg(target_os = "linux")]
4080    if let Some(ref cs) = sess.compositor
4081        && let Some(ref ap) = cs.audio_pipeline
4082    {
4083        ap.set_has_listener(any_audio_subscribed);
4084    }
4085    #[cfg(target_os = "linux")]
4086    if any_audio_subscribed
4087        && let Some(ref mut cs) = sess.compositor
4088        && let Some(ref mut ap) = cs.audio_pipeline
4089        && ap.is_alive()
4090    {
4091        let new_frames = ap.poll_frames();
4092        if !new_frames.is_empty() {
4093            for client in sess.clients.values_mut() {
4094                if client.audio_subscribed {
4095                    for frame in &new_frames {
4096                        let msg = audio::msg_audio_frame(frame);
4097                        let bytes = msg.len();
4098                        // Send via the dedicated audio channel so audio is
4099                        // never blocked behind large video/terminal messages
4100                        // in the shared outbox.  The writer task interleaves
4101                        // audio between write syscalls of bulk messages.
4102                        if client.audio_tx.send(msg).is_ok() {
4103                            // Count audio bytes into the goodput window so
4104                            // the shared bandwidth estimate reflects total
4105                            // pipe utilisation (terminals + video + audio).
4106                            client.goodput_window_bytes += bytes;
4107                        }
4108                    }
4109                }
4110            }
4111        }
4112        // Schedule the next tick soon so audio frames don't accumulate.
4113        // 20 ms matches the Opus frame interval.
4114        let next_audio = now + Duration::from_millis(20);
4115        next_deadline = Some(next_deadline.map_or(next_audio, |d: Instant| d.min(next_audio)));
4116    }
4117
4118    // -- Audio pipeline auto-restart ----------------------------------------
4119    // If the pipeline died (pw-cat exited, encoder crashed, PipeWire gone),
4120    // drop it, wait for a cooldown, and respawn.  This avoids permanent
4121    // audio loss that previously required a full client reconnect.
4122    //
4123    // Bitrate is pre-computed here to avoid borrowing sess.clients inside
4124    // the sess.compositor mutable borrow (they're the same MutexGuard).
4125    #[cfg(target_os = "linux")]
4126    let audio_restart_bitrate: i32 = sess
4127        .clients
4128        .values()
4129        .filter(|c| c.audio_subscribed)
4130        .map(|c| c.audio_bitrate_kbps)
4131        .max()
4132        .map(|kbps| kbps as i32 * 1000)
4133        .unwrap_or(0);
4134    #[cfg(target_os = "linux")]
4135    if let Some(ref mut cs) = sess.compositor {
4136        let pipeline_dead = cs.audio_pipeline.as_mut().is_some_and(|ap| !ap.is_alive());
4137        if pipeline_dead {
4138            const RESTART_COOLDOWN: Duration = Duration::from_secs(5);
4139            let can_restart = cs
4140                .last_audio_restart
4141                .is_none_or(|t| now.duration_since(t) >= RESTART_COOLDOWN);
4142            if can_restart {
4143                cs.last_audio_restart = Some(now);
4144                // Drop the dead pipeline — triggers shutdown() which kills
4145                // orphaned child processes and cleans up the runtime dir.
4146                cs.audio_pipeline = None;
4147                let runtime_dir = std::path::Path::new(&cs.handle.socket_name)
4148                    .parent()
4149                    .unwrap_or(std::path::Path::new("/tmp"));
4150                let session_id = cs.audio_session_id;
4151                let epoch = cs.created_at;
4152                let verbose = state.config.verbose;
4153                eprintln!("[audio] pipeline died, restarting...");
4154                let pipeline = tokio::task::block_in_place(|| {
4155                    audio::AudioPipeline::spawn(
4156                        runtime_dir,
4157                        session_id,
4158                        audio_restart_bitrate,
4159                        verbose,
4160                        epoch,
4161                    )
4162                });
4163                match pipeline {
4164                    Ok(p) => {
4165                        eprintln!(
4166                            "[audio] pipeline restarted, PULSE_SERVER={}",
4167                            p.pulse_server_path(),
4168                        );
4169                        cs.audio_pipeline = Some(p);
4170                    }
4171                    Err(e) => {
4172                        eprintln!("[audio] failed to restart pipeline: {e}");
4173                    }
4174                }
4175            }
4176        }
4177    }
4178
4179    // Guarantee the tick loop wakes up at least every blanket interval
4180    // even when other time-based work isn't pending.  When no client is
4181    // connected the interval is `None` and the loop sleeps purely on
4182    // delivery_notify, so a truly-idle server consumes ~zero CPU until
4183    // a client connects or the compositor emits an event.
4184    if let Some(interval) = blanket_frame_interval(&sess) {
4185        let blanket_deadline = now + interval;
4186        next_deadline = Some(next_deadline.map_or(blanket_deadline, |d| d.min(blanket_deadline)));
4187    }
4188
4189    TickOutcome { next_deadline }
4190}
4191
4192async fn handle_client<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(
4193    stream: S,
4194    state: AppState,
4195) {
4196    let config = &state.config;
4197    let notify_for_compositor = {
4198        let n = state.delivery_notify.clone();
4199        Arc::new(move || n.notify_one()) as Arc<dyn Fn() + Send + Sync>
4200    };
4201    let (mut reader, mut writer) = tokio::io::split(stream);
4202
4203    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
4204    let (audio_tx, mut audio_rx) = mpsc::unbounded_channel::<Vec<u8>>();
4205    let outbox_frame_counter = Arc::new(AtomicUsize::new(0));
4206    let outbox_byte_counter = Arc::new(AtomicUsize::new(0));
4207    let sender_outbox_queued_frames = outbox_frame_counter.clone();
4208    let sender_outbox_queued_bytes = outbox_byte_counter.clone();
4209    let sender = tokio::spawn(async move {
4210        loop {
4211            // Drain all pending audio before waiting for the next message.
4212            // Audio frames are tiny (~160 B) so this is near-instant.
4213            while let Ok(audio_msg) = audio_rx.try_recv() {
4214                if !write_frame(&mut writer, &audio_msg).await {
4215                    return;
4216                }
4217            }
4218
4219            // Wait for the next message from either channel.  Prefer audio
4220            // so that audio frames queued while we were writing are sent
4221            // before the next bulk message.
4222            let msg = tokio::select! {
4223                biased;
4224                msg = audio_rx.recv() => {
4225                    // Pure audio message — write it directly (tiny).
4226                    match msg {
4227                        Some(m) => {
4228                            if !write_frame(&mut writer, &m).await {
4229                                break;
4230                            }
4231                            continue;
4232                        }
4233                        None => break,
4234                    }
4235                }
4236                msg = out_rx.recv() => msg,
4237            };
4238
4239            // Non-audio message: may be large (video keyframe, terminal
4240            // snapshot).  Use interleaved write so audio frames that arrive
4241            // while the kernel TCP buffer drains are written between write
4242            // syscalls rather than piling up and being dropped.
4243            match msg {
4244                Some(m) => {
4245                    let bytes = m.len();
4246                    let write_start = Instant::now();
4247                    let wrote = write_frame_interleaved(&mut writer, &m, &mut audio_rx).await;
4248                    let write_elapsed = write_start.elapsed();
4249                    if write_elapsed.as_millis() > 100 {
4250                        eprintln!(
4251                            "[sender] slow write: bytes={bytes} elapsed={}ms wrote={wrote}",
4252                            write_elapsed.as_millis(),
4253                        );
4254                    }
4255                    mark_outbox_drained(
4256                        &sender_outbox_queued_frames,
4257                        &sender_outbox_queued_bytes,
4258                        bytes,
4259                    );
4260                    if !wrote {
4261                        break;
4262                    }
4263                }
4264                None => break,
4265            }
4266        }
4267    });
4268    let client_id;
4269
4270    {
4271        let mut sess = state.session.lock().await;
4272        client_id = sess.next_client_id;
4273        sess.next_client_id += 1;
4274        sess.clients.insert(
4275            client_id,
4276            ClientState {
4277                tx: out_tx,
4278                outbox_queued_frames: outbox_frame_counter,
4279                outbox_queued_bytes: outbox_byte_counter,
4280                audio_tx,
4281                lead: None,
4282                subscriptions: HashSet::new(),
4283                surface_subscriptions: HashSet::new(),
4284                audio_subscribed: false,
4285                #[cfg(target_os = "linux")]
4286                audio_bitrate_kbps: 0,
4287                view_sizes: HashMap::new(),
4288                scroll_offsets: HashMap::new(),
4289                scroll_caches: HashMap::new(),
4290                last_sent: HashMap::new(),
4291                preview_next_send_at: HashMap::new(),
4292                rtt_ms: 50.0,
4293                min_rtt_ms: 0.0,
4294                display_fps: 60.0,
4295                // Conservative seed — the rise alpha (0.5) converges up to
4296                // multi-MB/s in a handful of samples on low-latency paths. Starting
4297                // high causes catastrophic bufferbloat on slow links because
4298                // target_byte_window scales with the goodput estimate.
4299                delivery_bps: 262_144.0,
4300                goodput_bps: 262_144.0,
4301                goodput_jitter_bps: 0.0,
4302                max_goodput_jitter_bps: 0.0,
4303                last_goodput_sample_bps: 0.0,
4304                avg_frame_bytes: 1_024.0,
4305                avg_paced_frame_bytes: 1_024.0,
4306                avg_preview_frame_bytes: 1_024.0,
4307                avg_surface_frame_bytes: 8_192.0,
4308                inflight_bytes: 0,
4309                inflight_frames: VecDeque::new(),
4310                next_send_at: Instant::now(),
4311                probe_frames: 0.0,
4312                frames_sent: 0,
4313                acks_recv: 0,
4314                acked_bytes_since_log: 0,
4315                browser_backlog_frames: 0,
4316                browser_ack_ahead_frames: 0,
4317                browser_apply_ms: 0.0,
4318                last_metrics_update: Instant::now(),
4319                last_log: Instant::now(),
4320                last_window_blocked_log: Instant::now(),
4321                last_skip_log: Instant::now(),
4322                skip_same_gen_count: 0,
4323                skip_in_flight_count: 0,
4324                skip_pacing_count: 0,
4325                skip_vulkan_await_count: 0,
4326                skip_no_subs_count: 0,
4327                skip_not_subbed_count: 0,
4328                skip_last_pixels_mismatch_count: 0,
4329                encode_loop_iters: 0,
4330                goodput_window_bytes: 0,
4331                goodput_window_start: Instant::now(),
4332                surface_subs: HashMap::new(),
4333                surface_needs_keyframe: true,
4334                surface_inflight_frames: VecDeque::new(),
4335                vulkan_video_surfaces: HashMap::new(),
4336                surface_view_sizes: HashMap::new(),
4337                surface_codec_support: 0,
4338                pressed_surface_keys: HashSet::new(),
4339            },
4340        );
4341        // Wake the tick loop so the new client gets its first frame.
4342        state.delivery_notify.notify_one();
4343        if let Some(c) = sess.clients.get(&client_id) {
4344            let mut features = FEATURE_CREATE_NONCE
4345                | FEATURE_RESTART
4346                | FEATURE_RESIZE_BATCH
4347                | FEATURE_COPY_RANGE
4348                | FEATURE_COMPOSITOR;
4349            #[cfg(target_os = "linux")]
4350            {
4351                let audio_disabled = std::env::var("BLIT_AUDIO")
4352                    .map(|v| v == "0")
4353                    .unwrap_or(false);
4354                if !audio_disabled && audio::pipewire_available() {
4355                    features |= FEATURE_AUDIO;
4356                }
4357            }
4358            let _ = send_outbox(c, msg_hello(1, features));
4359        }
4360        let mut initial_msgs = Vec::with_capacity(2 + sess.ptys.len() * 2);
4361        // Send surface-created messages BEFORE the PTY list so that
4362        // the client's surface store is populated before `ready` is
4363        // set — otherwise the BSP reconciliation runs with an empty
4364        // surface list and wipes restored surface assignments.
4365        if let Some(cs) = sess.compositor.as_ref() {
4366            for info in cs.surfaces.values() {
4367                // Use the latest known pixel dimensions if the stored
4368                // width/height is still 0 (surface created before first commit).
4369                let (w, h) = if info.width == 0 && info.height == 0 {
4370                    cs.last_pixels
4371                        .get(&info.surface_id)
4372                        .map(|lp| (lp.width as u16, lp.height as u16))
4373                        .unwrap_or((0, 0))
4374                } else {
4375                    (info.width, info.height)
4376                };
4377                initial_msgs.push(msg_surface_created(
4378                    info.surface_id,
4379                    info.parent_id,
4380                    w,
4381                    h,
4382                    &info.title,
4383                    &info.app_id,
4384                ));
4385                // Also send a resize message so the client gets the
4386                // correct dimensions even if surface_created carried 0x0.
4387                if w > 0 && h > 0 {
4388                    initial_msgs.push(msg_surface_resized(info.surface_id, w, h));
4389                }
4390            }
4391        }
4392        initial_msgs.push(sess.pty_list_msg());
4393        for (&id, pty) in &sess.ptys {
4394            let title = pty.driver.title();
4395            if !title.is_empty() {
4396                let title_bytes = title.as_bytes();
4397                let mut msg = Vec::with_capacity(3 + title_bytes.len());
4398                msg.push(S2C_TITLE);
4399                msg.extend_from_slice(&id.to_le_bytes());
4400                msg.extend_from_slice(title_bytes);
4401                initial_msgs.push(msg);
4402            }
4403            if pty.exited {
4404                initial_msgs.push(blit_remote::msg_exited(id, pty.exit_status));
4405            }
4406        }
4407        initial_msgs.push(vec![S2C_READY]);
4408        let tx = sess.clients.get(&client_id).map(|c| {
4409            (
4410                c.tx.clone(),
4411                c.outbox_queued_frames.clone(),
4412                c.outbox_queued_bytes.clone(),
4413            )
4414        });
4415        drop(sess);
4416        if let Some((tx, queued_frames, queued_bytes)) = tx {
4417            for msg in initial_msgs {
4418                if send_outbox_tracked(&tx, &queued_frames, &queued_bytes, msg).is_err() {
4419                    break;
4420                }
4421            }
4422        }
4423    }
4424
4425    if state.config.verbose {
4426        eprintln!("client connected");
4427    }
4428
4429    while let Some(data) = read_frame(&mut reader).await {
4430        if data.is_empty() {
4431            continue;
4432        }
4433
4434        if data[0] == C2S_ACK {
4435            let mut sess = state.session.lock().await;
4436            if let Some(c) = sess.clients.get_mut(&client_id) {
4437                c.acks_recv += 1;
4438                record_ack(c);
4439            } else {
4440                continue;
4441            }
4442            maybe_log_pacing_metrics(&mut sess, client_id, config.verbose);
4443            nudge_delivery(&state);
4444            continue;
4445        }
4446
4447        if data[0] == C2S_PING {
4448            // Application-level keepalive — no-op.  Its arrival is enough
4449            // to keep the connection alive (any received data resets
4450            // transport-level timeouts).
4451            continue;
4452        }
4453
4454        if data[0] == C2S_DISPLAY_RATE && data.len() >= 3 {
4455            let fps = u16::from_le_bytes([data[1], data[2]]) as f32;
4456            if fps > 0.0 {
4457                let mut sess = state.session.lock().await;
4458                if let Some(c) = sess.clients.get_mut(&client_id) {
4459                    c.display_fps = fps;
4460                }
4461                // Advertise the highest refresh rate across all clients
4462                // to the compositor so Wayland apps render at full speed.
4463                let max_fps = sess
4464                    .clients
4465                    .values()
4466                    .map(|c| c.display_fps)
4467                    .fold(0.0f32, f32::max);
4468                let mhz = (max_fps * 1000.0).round() as u32;
4469                if mhz > 0
4470                    && let Some(cs) = &sess.compositor
4471                {
4472                    let _ = cs
4473                        .handle
4474                        .command_tx
4475                        .send(blit_compositor::CompositorCommand::SetRefreshRate { mhz });
4476                }
4477            }
4478            nudge_delivery(&state);
4479            continue;
4480        }
4481
4482        if data[0] == C2S_CLIENT_METRICS && data.len() >= 7 {
4483            let backlog_frames = u16::from_le_bytes([data[1], data[2]]);
4484            let ack_ahead_frames = u16::from_le_bytes([data[3], data[4]]);
4485            let apply_ms = u16::from_le_bytes([data[5], data[6]]) as f32 * 0.1;
4486            let mut sess = state.session.lock().await;
4487            if let Some(c) = sess.clients.get_mut(&client_id) {
4488                c.browser_backlog_frames = backlog_frames;
4489                c.browser_ack_ahead_frames = ack_ahead_frames;
4490                c.browser_apply_ms = apply_ms;
4491                c.last_metrics_update = Instant::now();
4492            }
4493            nudge_delivery(&state);
4494            continue;
4495        }
4496
4497        // Server-side mouse: client sends structured mouse data, server generates
4498        // the correct escape sequence using the terminal's current mouse mode/encoding.
4499        if data[0] == C2S_MOUSE && data.len() >= 9 {
4500            let pid = u16::from_le_bytes([data[1], data[2]]);
4501            let type_ = data[3];
4502            let button = data[4];
4503            let col = u16::from_le_bytes([data[5], data[6]]);
4504            let row = u16::from_le_bytes([data[7], data[8]]);
4505            let sess = state.session.lock().await;
4506            if let Some(pty) = sess.ptys.get(&pid) {
4507                let (echo, icanon) = pty.lflag_cache;
4508                if let Some(seq) = pty
4509                    .driver
4510                    .mouse_event(type_, button, col, row, echo, icanon)
4511                    && let Some(&fd) = state.pty_fds.read().unwrap().get(&pid)
4512                {
4513                    pty::pty_write_all(fd, &seq);
4514                }
4515            }
4516            continue;
4517        }
4518
4519        if data[0] == C2S_INPUT && data.len() >= 3 {
4520            let pid = u16::from_le_bytes([data[1], data[2]]);
4521            let mut need_nudge = false;
4522            {
4523                let mut sess = state.session.lock().await;
4524                if let Some(c) = sess.clients.get_mut(&client_id)
4525                    && update_client_scroll_state(c, pid, 0)
4526                    && let Some(pty) = sess.ptys.get_mut(&pid)
4527                {
4528                    pty.mark_dirty();
4529                    need_nudge = true;
4530                }
4531            }
4532            if need_nudge {
4533                nudge_delivery(&state);
4534            }
4535            if let Some(&fd) = state.pty_fds.read().unwrap().get(&pid) {
4536                pty::pty_write_all(fd, &data[3..]);
4537            }
4538            continue;
4539        }
4540
4541        if data[0] == C2S_SEARCH && data.len() >= 3 {
4542            let request_id = u16::from_le_bytes([data[1], data[2]]);
4543            let query = std::str::from_utf8(&data[3..]).unwrap_or("").trim();
4544            let mut sess = state.session.lock().await;
4545            let lead = sess.clients.get(&client_id).and_then(|c| c.lead);
4546            let mut ranked: Vec<SearchResultRow> = if query.is_empty() {
4547                Vec::new()
4548            } else {
4549                sess.ptys
4550                    .iter()
4551                    .filter_map(|(&pty_id, pty)| {
4552                        pty.driver
4553                            .search_result(query)
4554                            .map(|result| SearchResultRow {
4555                                pty_id,
4556                                score: result.score,
4557                                primary_source: result.primary_source,
4558                                matched_sources: result.matched_sources,
4559                                context: result.context,
4560                                scroll_offset: result.scroll_offset,
4561                            })
4562                    })
4563                    .collect()
4564            };
4565            ranked.sort_by(|a, b| {
4566                b.score
4567                    .cmp(&a.score)
4568                    .then_with(|| (Some(b.pty_id) == lead).cmp(&(Some(a.pty_id) == lead)))
4569                    .then_with(|| a.pty_id.cmp(&b.pty_id))
4570            });
4571            if let Some(client) = sess.clients.get_mut(&client_id) {
4572                let _ = send_outbox(client, build_search_results_msg(request_id, &ranked));
4573            }
4574            continue;
4575        }
4576
4577        if data[0] == C2S_SURFACE_CAPTURE && data.len() >= 3 {
4578            let surface_id = u16::from_le_bytes([data[1], data[2]]);
4579            // Extended message includes format and quality bytes.
4580            let format = data.get(3).copied().unwrap_or(CAPTURE_FORMAT_PNG);
4581            let quality = data.get(4).copied().unwrap_or(0);
4582            let scale_120 = if data.len() >= 7 {
4583                u16::from_le_bytes([data[5], data[6]])
4584            } else {
4585                0
4586            };
4587
4588            let mut reply_msg = vec![S2C_SURFACE_CAPTURE];
4589            reply_msg.extend_from_slice(&surface_id.to_le_bytes());
4590
4591            eprintln!("[capture] acquiring lock for surface {surface_id}");
4592            let (snapshot, command_tx) = {
4593                let sess = state.session.lock().await;
4594                eprintln!("[capture] lock acquired");
4595                let snap = sess
4596                    .compositor
4597                    .as_ref()
4598                    .and_then(|cs| cs.last_pixels.get(&surface_id))
4599                    .map(|lp| (lp.width, lp.height, lp.pixels.clone()));
4600                let cmd_tx = sess
4601                    .compositor
4602                    .as_ref()
4603                    .map(|cs| cs.handle.command_tx.clone());
4604                (snap, cmd_tx)
4605            };
4606
4607            // Compositor direct capture (CPU compositing from the per-surface
4608            // pixel cache).  This is the primary path — it produces correct
4609            // lossless results for clients that use CPU-mappable DMA-BUFs
4610            // (Chromium/Brave) or SHM buffers.
4611            let mut captured: Option<(u32, u32, Vec<u8>)> = None;
4612            if let Some(ctx) = command_tx {
4613                captured = request_surface_capture_with_timeout(
4614                    ctx,
4615                    surface_id,
4616                    scale_120,
4617                    Duration::from_secs(5),
4618                )
4619                .await;
4620            }
4621
4622            // Fallback: last_pixels from the video pipeline.  Used when
4623            // the compositor capture returns nothing (no cached buffers).
4624            if captured.is_none() {
4625                captured = snapshot.and_then(|(w, h, pixels)| {
4626                    if pixels.is_dmabuf() {
4627                        return None;
4628                    }
4629                    let rgba = pixels.to_rgba(w, h);
4630                    if rgba.is_empty() {
4631                        None
4632                    } else {
4633                        Some((w, h, rgba))
4634                    }
4635                });
4636            }
4637
4638            eprintln!("[capture] acquiring client_tx lock");
4639            let client_tx = {
4640                let sess = state.session.lock().await;
4641                eprintln!("[capture] client_tx lock acquired");
4642                sess.clients.get(&client_id).map(|c| {
4643                    (
4644                        c.tx.clone(),
4645                        c.outbox_queued_frames.clone(),
4646                        c.outbox_queued_bytes.clone(),
4647                    )
4648                })
4649            };
4650
4651            if let Some((w, h, rgba_pixels)) = captured {
4652                let image_data = encode_capture(&rgba_pixels, w, h, format, quality);
4653                reply_msg.extend_from_slice(&w.to_le_bytes());
4654                reply_msg.extend_from_slice(&h.to_le_bytes());
4655                reply_msg.extend_from_slice(&image_data);
4656            } else {
4657                reply_msg.extend_from_slice(&0u32.to_le_bytes());
4658                reply_msg.extend_from_slice(&0u32.to_le_bytes());
4659            }
4660
4661            if let Some((client_tx, queued_frames, queued_bytes)) = client_tx {
4662                eprintln!("[capture] sending reply: {} bytes", reply_msg.len());
4663                match send_outbox_tracked(&client_tx, &queued_frames, &queued_bytes, reply_msg) {
4664                    Ok(()) => eprintln!("[capture] sent OK"),
4665                    Err(e) => eprintln!("[capture] send failed: {e}"),
4666                }
4667            } else {
4668                eprintln!("[capture] no client_tx");
4669            }
4670            continue;
4671        }
4672
4673        if data[0] == C2S_QUIT {
4674            let sess = state.session.lock().await;
4675            sess.send_to_all(&[S2C_QUIT]);
4676            drop(sess);
4677            state.shutdown_notify.notify_one();
4678            break;
4679        }
4680
4681        let mut sess = state.session.lock().await;
4682        let mut need_nudge = false;
4683        match data[0] {
4684            C2S_SCROLL if data.len() >= 7 => {
4685                let pid = u16::from_le_bytes([data[1], data[2]]);
4686                let offset = u32::from_le_bytes([data[3], data[4], data[5], data[6]]) as usize;
4687                if sess.ptys.contains_key(&pid) {
4688                    if let Some(c) = sess.clients.get_mut(&client_id) {
4689                        update_client_scroll_state(c, pid, offset);
4690                    }
4691                    if let Some(pty) = sess.ptys.get_mut(&pid) {
4692                        pty.mark_dirty();
4693                        need_nudge = true;
4694                    }
4695                }
4696            }
4697            C2S_RESIZE if data.len() >= 7 => {
4698                let entries = data[1..].chunks_exact(6);
4699                if !entries.remainder().is_empty() {
4700                    continue;
4701                }
4702                let mut touched = Vec::with_capacity((data.len() - 1) / 6);
4703                for entry in entries {
4704                    let pid = u16::from_le_bytes([entry[0], entry[1]]);
4705                    if !sess.ptys.contains_key(&pid) {
4706                        continue;
4707                    }
4708                    let rows = u16::from_le_bytes([entry[2], entry[3]]);
4709                    let cols = u16::from_le_bytes([entry[4], entry[5]]);
4710                    if let Some(c) = sess.clients.get_mut(&client_id) {
4711                        if is_unset_view_size(rows, cols) {
4712                            if c.view_sizes.remove(&pid).is_some() {
4713                                touched.push(pid);
4714                            }
4715                        } else if rows == 0 || cols == 0 {
4716                            continue;
4717                        } else {
4718                            c.view_sizes.insert(pid, (rows, cols));
4719                            touched.push(pid);
4720                        }
4721                    }
4722                }
4723                if sess.resize_ptys_to_mediated_sizes(touched) {
4724                    need_nudge = true;
4725                }
4726            }
4727            C2S_CREATE => {
4728                // Format: [opcode][rows:2][cols:2][tag_len:2][tag:N][command...]
4729                let (rows, cols) = if data.len() >= 5 {
4730                    (
4731                        u16::from_le_bytes([data[1], data[2]]),
4732                        u16::from_le_bytes([data[3], data[4]]),
4733                    )
4734                } else {
4735                    (24, 80)
4736                };
4737                let tag_len = if data.len() >= 7 {
4738                    u16::from_le_bytes([data[5], data[6]]) as usize
4739                } else {
4740                    0
4741                };
4742                let tag = if data.len() >= 7 + tag_len {
4743                    std::str::from_utf8(&data[7..7 + tag_len]).unwrap_or_default()
4744                } else {
4745                    ""
4746                };
4747                let cmd_start = 7 + tag_len;
4748                let dir: Option<String> = None;
4749                let create_payload = data
4750                    .get(cmd_start..)
4751                    .and_then(|bytes| std::str::from_utf8(bytes).ok());
4752                let command = create_payload
4753                    .filter(|payload| !payload.contains('\0'))
4754                    .map(str::trim)
4755                    .filter(|payload| !payload.is_empty());
4756                let argv: Option<Vec<&str>> = create_payload
4757                    .filter(|payload| payload.contains('\0'))
4758                    .map(|payload| {
4759                        payload
4760                            .split('\0')
4761                            .filter(|arg| !arg.is_empty())
4762                            .collect::<Vec<_>>()
4763                    })
4764                    .filter(|args| !args.is_empty());
4765                let Some(id) = sess.allocate_pty_id(config.max_ptys) else {
4766                    continue;
4767                };
4768                let socket_name = sess
4769                    .ensure_compositor(
4770                        config.verbose,
4771                        notify_for_compositor.clone(),
4772                        &config.vaapi_device,
4773                    )
4774                    .to_string();
4775                #[cfg(target_os = "linux")]
4776                let pulse_server = sess.pulse_server_path();
4777                #[cfg(not(target_os = "linux"))]
4778                let pulse_server: Option<String> = None;
4779                #[cfg(target_os = "linux")]
4780                let pipewire_remote = sess.pipewire_remote_path();
4781                #[cfg(not(target_os = "linux"))]
4782                let pipewire_remote: Option<String> = None;
4783                if let Some(pty) = pty::spawn_pty(
4784                    &config.shell,
4785                    &config.shell_flags,
4786                    rows,
4787                    cols,
4788                    id,
4789                    tag,
4790                    command,
4791                    argv.as_deref(),
4792                    dir.as_deref(),
4793                    config.scrollback,
4794                    state.clone(),
4795                    Some(&socket_name),
4796                    pulse_server.as_deref(),
4797                    pipewire_remote.as_deref(),
4798                ) {
4799                    let mut msg = Vec::with_capacity(3 + pty.tag.len());
4800                    msg.push(S2C_CREATED);
4801                    msg.extend_from_slice(&id.to_le_bytes());
4802                    msg.extend_from_slice(pty.tag.as_bytes());
4803                    sess.ptys.insert(id, pty);
4804                    if let Some(c) = sess.clients.get_mut(&client_id) {
4805                        c.lead = Some(id);
4806                        c.view_sizes.insert(id, (rows, cols));
4807                        subscribe_client_to(c, id);
4808                        reset_inflight(c);
4809                    }
4810                    sess.send_to_all(&msg);
4811                    need_nudge = true;
4812                }
4813            }
4814            C2S_CREATE_N => {
4815                // Format: [opcode][nonce:2][rows:2][cols:2][tag_len:2][tag:N][command...]
4816                let nonce = if data.len() >= 3 {
4817                    u16::from_le_bytes([data[1], data[2]])
4818                } else {
4819                    0
4820                };
4821                let (rows, cols) = if data.len() >= 7 {
4822                    (
4823                        u16::from_le_bytes([data[3], data[4]]),
4824                        u16::from_le_bytes([data[5], data[6]]),
4825                    )
4826                } else {
4827                    (24, 80)
4828                };
4829                let tag_len = if data.len() >= 9 {
4830                    u16::from_le_bytes([data[7], data[8]]) as usize
4831                } else {
4832                    0
4833                };
4834                let tag = if data.len() >= 9 + tag_len {
4835                    std::str::from_utf8(&data[9..9 + tag_len]).unwrap_or_default()
4836                } else {
4837                    ""
4838                };
4839                let cmd_start = 9 + tag_len;
4840                let dir: Option<String> = None;
4841                let create_payload = data
4842                    .get(cmd_start..)
4843                    .and_then(|bytes| std::str::from_utf8(bytes).ok());
4844                let command = create_payload
4845                    .filter(|payload| !payload.contains('\0'))
4846                    .map(str::trim)
4847                    .filter(|payload| !payload.is_empty());
4848                let argv: Option<Vec<&str>> = create_payload
4849                    .filter(|payload| payload.contains('\0'))
4850                    .map(|payload| {
4851                        payload
4852                            .split('\0')
4853                            .filter(|arg| !arg.is_empty())
4854                            .collect::<Vec<_>>()
4855                    })
4856                    .filter(|args| !args.is_empty());
4857                let Some(id) = sess.allocate_pty_id(config.max_ptys) else {
4858                    continue;
4859                };
4860                let socket_name = sess
4861                    .ensure_compositor(
4862                        config.verbose,
4863                        notify_for_compositor.clone(),
4864                        &config.vaapi_device,
4865                    )
4866                    .to_string();
4867                #[cfg(target_os = "linux")]
4868                let pulse_server = sess.pulse_server_path();
4869                #[cfg(not(target_os = "linux"))]
4870                let pulse_server: Option<String> = None;
4871                #[cfg(target_os = "linux")]
4872                let pipewire_remote = sess.pipewire_remote_path();
4873                #[cfg(not(target_os = "linux"))]
4874                let pipewire_remote: Option<String> = None;
4875                if let Some(pty) = pty::spawn_pty(
4876                    &config.shell,
4877                    &config.shell_flags,
4878                    rows,
4879                    cols,
4880                    id,
4881                    tag,
4882                    command,
4883                    argv.as_deref(),
4884                    dir.as_deref(),
4885                    config.scrollback,
4886                    state.clone(),
4887                    Some(&socket_name),
4888                    pulse_server.as_deref(),
4889                    pipewire_remote.as_deref(),
4890                ) {
4891                    let tag_bytes = pty.tag.as_bytes();
4892                    let mut nonce_msg = Vec::with_capacity(5 + tag_bytes.len());
4893                    nonce_msg.push(S2C_CREATED_N);
4894                    nonce_msg.extend_from_slice(&nonce.to_le_bytes());
4895                    nonce_msg.extend_from_slice(&id.to_le_bytes());
4896                    nonce_msg.extend_from_slice(tag_bytes);
4897                    let mut broadcast_msg = Vec::with_capacity(3 + tag_bytes.len());
4898                    broadcast_msg.push(S2C_CREATED);
4899                    broadcast_msg.extend_from_slice(&id.to_le_bytes());
4900                    broadcast_msg.extend_from_slice(tag_bytes);
4901                    sess.ptys.insert(id, pty);
4902                    if let Some(c) = sess.clients.get_mut(&client_id) {
4903                        c.lead = Some(id);
4904                        c.view_sizes.insert(id, (rows, cols));
4905                        subscribe_client_to(c, id);
4906                        reset_inflight(c);
4907                        let _ = send_outbox(c, nonce_msg);
4908                    }
4909                    for (&cid, c) in sess.clients.iter() {
4910                        if cid != client_id {
4911                            let _ = send_outbox(c, broadcast_msg.clone());
4912                        }
4913                    }
4914                    need_nudge = true;
4915                }
4916            }
4917            C2S_CREATE_AT => {
4918                // Format: [opcode][rows:2][cols:2][tag_len:2][tag:N][src_pty_id:2]
4919                let (rows, cols) = if data.len() >= 5 {
4920                    (
4921                        u16::from_le_bytes([data[1], data[2]]),
4922                        u16::from_le_bytes([data[3], data[4]]),
4923                    )
4924                } else {
4925                    (24, 80)
4926                };
4927                let tag_len = if data.len() >= 7 {
4928                    u16::from_le_bytes([data[5], data[6]]) as usize
4929                } else {
4930                    0
4931                };
4932                let tag = if data.len() >= 7 + tag_len {
4933                    std::str::from_utf8(&data[7..7 + tag_len]).unwrap_or_default()
4934                } else {
4935                    ""
4936                };
4937                let src_start = 7 + tag_len;
4938                let dir = if data.len() >= src_start + 2 {
4939                    let src_id = u16::from_le_bytes([data[src_start], data[src_start + 1]]);
4940                    sess.ptys.get(&src_id).and_then(|p| pty::pty_cwd(&p.handle))
4941                } else {
4942                    None
4943                };
4944                let Some(id) = sess.allocate_pty_id(config.max_ptys) else {
4945                    continue;
4946                };
4947                let socket_name = sess
4948                    .ensure_compositor(
4949                        config.verbose,
4950                        notify_for_compositor.clone(),
4951                        &config.vaapi_device,
4952                    )
4953                    .to_string();
4954                #[cfg(target_os = "linux")]
4955                let pulse_server = sess.pulse_server_path();
4956                #[cfg(not(target_os = "linux"))]
4957                let pulse_server: Option<String> = None;
4958                #[cfg(target_os = "linux")]
4959                let pipewire_remote = sess.pipewire_remote_path();
4960                #[cfg(not(target_os = "linux"))]
4961                let pipewire_remote: Option<String> = None;
4962                if let Some(pty) = pty::spawn_pty(
4963                    &config.shell,
4964                    &config.shell_flags,
4965                    rows,
4966                    cols,
4967                    id,
4968                    tag,
4969                    None,
4970                    None,
4971                    dir.as_deref(),
4972                    config.scrollback,
4973                    state.clone(),
4974                    Some(&socket_name),
4975                    pulse_server.as_deref(),
4976                    pipewire_remote.as_deref(),
4977                ) {
4978                    let mut msg = Vec::with_capacity(3 + pty.tag.len());
4979                    msg.push(S2C_CREATED);
4980                    msg.extend_from_slice(&id.to_le_bytes());
4981                    msg.extend_from_slice(pty.tag.as_bytes());
4982                    sess.ptys.insert(id, pty);
4983                    if let Some(c) = sess.clients.get_mut(&client_id) {
4984                        c.lead = Some(id);
4985                        c.view_sizes.insert(id, (rows, cols));
4986                        subscribe_client_to(c, id);
4987                        reset_inflight(c);
4988                    }
4989                    sess.send_to_all(&msg);
4990                    need_nudge = true;
4991                }
4992            }
4993            C2S_CREATE2 => {
4994                if data.len() < 10 {
4995                    continue;
4996                }
4997                let nonce = u16::from_le_bytes([data[1], data[2]]);
4998                let rows = u16::from_le_bytes([data[3], data[4]]);
4999                let cols = u16::from_le_bytes([data[5], data[6]]);
5000                let features = data[7];
5001                let tag_len = u16::from_le_bytes([data[8], data[9]]) as usize;
5002                let tag = if data.len() >= 10 + tag_len {
5003                    std::str::from_utf8(&data[10..10 + tag_len]).unwrap_or_default()
5004                } else {
5005                    ""
5006                };
5007                let mut cursor = 10 + tag_len;
5008                let dir = if features & CREATE2_HAS_SRC_PTY != 0 && data.len() >= cursor + 2 {
5009                    let src_id = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
5010                    cursor += 2;
5011                    sess.ptys.get(&src_id).and_then(|p| pty::pty_cwd(&p.handle))
5012                } else {
5013                    None
5014                };
5015                let create_payload = if features & CREATE2_HAS_COMMAND != 0 {
5016                    data.get(cursor..).and_then(|b| std::str::from_utf8(b).ok())
5017                } else {
5018                    None
5019                };
5020                let command = create_payload
5021                    .filter(|p| !p.contains('\0'))
5022                    .map(str::trim)
5023                    .filter(|p| !p.is_empty());
5024                let argv: Option<Vec<&str>> = create_payload
5025                    .filter(|p| p.contains('\0'))
5026                    .map(|p| p.split('\0').filter(|a| !a.is_empty()).collect::<Vec<_>>())
5027                    .filter(|a| !a.is_empty());
5028                let Some(id) = sess.allocate_pty_id(config.max_ptys) else {
5029                    continue;
5030                };
5031                let socket_name = sess
5032                    .ensure_compositor(
5033                        config.verbose,
5034                        notify_for_compositor.clone(),
5035                        &config.vaapi_device,
5036                    )
5037                    .to_string();
5038                #[cfg(target_os = "linux")]
5039                let pulse_server = sess.pulse_server_path();
5040                #[cfg(not(target_os = "linux"))]
5041                let pulse_server: Option<String> = None;
5042                #[cfg(target_os = "linux")]
5043                let pipewire_remote = sess.pipewire_remote_path();
5044                #[cfg(not(target_os = "linux"))]
5045                let pipewire_remote: Option<String> = None;
5046                if let Some(pty) = pty::spawn_pty(
5047                    &config.shell,
5048                    &config.shell_flags,
5049                    rows,
5050                    cols,
5051                    id,
5052                    tag,
5053                    command,
5054                    argv.as_deref(),
5055                    dir.as_deref(),
5056                    config.scrollback,
5057                    state.clone(),
5058                    Some(&socket_name),
5059                    pulse_server.as_deref(),
5060                    pipewire_remote.as_deref(),
5061                ) {
5062                    let tag_bytes = pty.tag.as_bytes();
5063                    let mut nonce_msg = Vec::with_capacity(5 + tag_bytes.len());
5064                    nonce_msg.push(S2C_CREATED_N);
5065                    nonce_msg.extend_from_slice(&nonce.to_le_bytes());
5066                    nonce_msg.extend_from_slice(&id.to_le_bytes());
5067                    nonce_msg.extend_from_slice(tag_bytes);
5068                    let mut broadcast_msg = Vec::with_capacity(3 + tag_bytes.len());
5069                    broadcast_msg.push(S2C_CREATED);
5070                    broadcast_msg.extend_from_slice(&id.to_le_bytes());
5071                    broadcast_msg.extend_from_slice(tag_bytes);
5072                    sess.ptys.insert(id, pty);
5073                    if let Some(c) = sess.clients.get_mut(&client_id) {
5074                        c.lead = Some(id);
5075                        c.view_sizes.insert(id, (rows, cols));
5076                        subscribe_client_to(c, id);
5077                        reset_inflight(c);
5078                        let _ = send_outbox(c, nonce_msg);
5079                    }
5080                    for (&cid, c) in sess.clients.iter() {
5081                        if cid != client_id {
5082                            let _ = send_outbox(c, broadcast_msg.clone());
5083                        }
5084                    }
5085                    need_nudge = true;
5086                }
5087            }
5088            C2S_SURFACE_INPUT if data.len() >= 8 => {
5089                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5090                let keycode = u32::from_le_bytes([data[3], data[4], data[5], data[6]]);
5091                let pressed = data[7] != 0;
5092                if let Some(client) = sess.clients.get_mut(&client_id) {
5093                    if pressed {
5094                        client.pressed_surface_keys.insert(keycode);
5095                    } else {
5096                        client.pressed_surface_keys.remove(&keycode);
5097                    }
5098                }
5099                if let Some(cs) = sess.compositor.as_mut() {
5100                    let _ = cs.handle.command_tx.send(CompositorCommand::KeyInput {
5101                        surface_id,
5102                        keycode,
5103                        pressed,
5104                    });
5105                    cs.handle.wake();
5106                    state.delivery_notify.notify_one();
5107                }
5108            }
5109            C2S_SURFACE_TEXT if data.len() >= 3 => {
5110                let _surface_id = u16::from_le_bytes([data[1], data[2]]);
5111                if let Ok(text) = std::str::from_utf8(&data[3..])
5112                    && let Some(cs) = sess.compositor.as_mut()
5113                {
5114                    let _ = cs.handle.command_tx.send(CompositorCommand::TextInput {
5115                        text: text.to_string(),
5116                    });
5117                    cs.handle.wake();
5118                    state.delivery_notify.notify_one();
5119                }
5120            }
5121            C2S_SURFACE_POINTER if data.len() >= 9 => {
5122                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5123                let ptype = data[3];
5124                let button = data[4];
5125                let x = u16::from_le_bytes([data[5], data[6]]) as f64;
5126                let y = u16::from_le_bytes([data[7], data[8]]) as f64;
5127                if let Some(cs) = sess.compositor.as_mut() {
5128                    match ptype {
5129                        0 | 1 => {
5130                            let _ = cs.handle.command_tx.send(CompositorCommand::PointerMotion {
5131                                surface_id,
5132                                x,
5133                                y,
5134                            });
5135                            let _ = cs.handle.command_tx.send(CompositorCommand::PointerButton {
5136                                surface_id,
5137                                button: match button {
5138                                    1 => 0x112,
5139                                    2 => 0x111,
5140                                    _ => 0x110,
5141                                },
5142                                pressed: ptype == 0,
5143                            });
5144                        }
5145                        2 => {
5146                            let _ = cs.handle.command_tx.send(CompositorCommand::PointerMotion {
5147                                surface_id,
5148                                x,
5149                                y,
5150                            });
5151                        }
5152                        _ => {}
5153                    }
5154                    cs.handle.wake();
5155                }
5156                state.delivery_notify.notify_one();
5157            }
5158            C2S_SURFACE_POINTER_AXIS if data.len() >= 8 => {
5159                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5160                let axis = data[3];
5161                let value_x100 = i32::from_le_bytes([data[4], data[5], data[6], data[7]]);
5162                let value = value_x100 as f64 / 100.0;
5163                if let Some(cs) = sess.compositor.as_mut() {
5164                    let _ = cs.handle.command_tx.send(CompositorCommand::PointerAxis {
5165                        surface_id,
5166                        axis,
5167                        value,
5168                    });
5169                    cs.handle.wake();
5170                }
5171                state.delivery_notify.notify_one();
5172            }
5173            C2S_SURFACE_RESIZE if data.len() >= 9 => {
5174                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5175                let width = u16::from_le_bytes([data[3], data[4]]);
5176                let height = u16::from_le_bytes([data[5], data[6]]);
5177                // Scale in 1/120th units (Wayland convention): 240 = 2×.
5178                let scale_120 = u16::from_le_bytes([data[7], data[8]]);
5179                if state.config.verbose {
5180                    eprintln!(
5181                        "C2S_SURFACE_RESIZE: cid={client_id} sid={surface_id} {width}x{height} scale={scale_120}"
5182                    );
5183                }
5184                if let Some(c) = sess.clients.get_mut(&client_id) {
5185                    if is_unset_view_size(width, height) {
5186                        c.surface_view_sizes.remove(&surface_id);
5187                    } else if width > 0 && height > 0 {
5188                        c.surface_view_sizes
5189                            .insert(surface_id, (width, height, scale_120));
5190                    }
5191                    // Clear latched nal_data=None streak for this
5192                    // surface so the encoder can be recreated.  The
5193                    // streak is designed to stop infinite recreation
5194                    // loops (GBM fd leak), not to permanently black out
5195                    // a surface across a client-driven resize.
5196                    //
5197                    // Also wake the pacing gate: reset the burst window
5198                    // and clear next_send_at so the first frame after
5199                    // resize bypasses time-based pacing and flows at
5200                    // wire speed.  Without this, the client waits up to
5201                    // one send interval (~1/fps) after the encoder is
5202                    // recreated before seeing the first new frame.
5203                    if let Some(s) = c.surface_subs.get_mut(&surface_id) {
5204                        s.nal_none_streak = 0;
5205                        s.nal_none_latched_at = None;
5206                        s.burst_remaining = SURFACE_BURST_FRAMES;
5207                        s.next_send_at = None;
5208                    }
5209                    c.surface_needs_keyframe = true;
5210                }
5211                sess.resize_surfaces_to_mediated_sizes(
5212                    std::iter::once(surface_id),
5213                    &state.config.surface_encoders,
5214                );
5215            }
5216            C2S_SURFACE_FOCUS if data.len() >= 3 => {
5217                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5218                if state.config.verbose {
5219                    eprintln!("C2S_SURFACE_FOCUS: cid={client_id} sid={surface_id}");
5220                }
5221                if let Some(cs) = sess.compositor.as_ref() {
5222                    let _ = cs
5223                        .handle
5224                        .command_tx
5225                        .send(CompositorCommand::SurfaceFocus { surface_id });
5226                }
5227            }
5228            C2S_SURFACE_CLOSE if data.len() >= 3 => {
5229                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5230                if let Some(cs) = sess.compositor.as_ref() {
5231                    let _ = cs
5232                        .handle
5233                        .command_tx
5234                        .send(CompositorCommand::SurfaceClose { surface_id });
5235                    cs.handle.wake();
5236                }
5237            }
5238            C2S_SURFACE_SUBSCRIBE if data.len() >= 3 => {
5239                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5240                // Extended fields (backward-compatible: absent = 0 = defaults).
5241                let codec_support = if data.len() >= 4 { data[3] } else { 0 };
5242                let quality_wire = if data.len() >= 5 { data[4] } else { 0 };
5243                if state.config.verbose {
5244                    eprintln!(
5245                        "C2S_SURFACE_SUBSCRIBE: cid={client_id} surface={surface_id} codec={codec_support:#04x} quality={quality_wire}"
5246                    );
5247                }
5248                let mut destroy_vulkan_enc_sid = None;
5249                let mut first_subscribe = false;
5250                if let Some(c) = sess.clients.get_mut(&client_id) {
5251                    let was_subscribed = !c.surface_subscriptions.insert(surface_id);
5252                    let new_quality = SurfaceQuality::from_wire(quality_wire);
5253
5254                    let state = c.surface_subs.entry(surface_id).or_default();
5255                    let old_codec = state.codec_override;
5256                    let old_quality = state.quality_override;
5257
5258                    // A no-op resubscribe (same codec/quality, already
5259                    // subscribed) should not disturb the steady encode
5260                    // stream — resetting needs_keyframe/burst on every
5261                    // repeated subscribe makes keyframes churn and skews
5262                    // pacing.
5263                    let meaningful_change =
5264                        !was_subscribed || codec_support != old_codec || new_quality != old_quality;
5265                    state.codec_override = codec_support;
5266                    state.quality_override = new_quality;
5267                    let task_in_flight = state.encode_in_flight || state.creation_in_flight;
5268                    if meaningful_change {
5269                        // Reset burst window so the first frames after a
5270                        // (re)subscribe bypass time-based pacing and flow
5271                        // at wire speed.  Clear the nal_data=None streak
5272                        // too: a fresh subscription is a valid signal to
5273                        // retry a previously-latched encoder.
5274                        state.burst_remaining = SURFACE_BURST_FRAMES;
5275                        state.nal_none_streak = 0;
5276                        state.nal_none_latched_at = None;
5277                    }
5278                    // Force encoder recreation when preferences change on
5279                    // resubscribe.  If an encode OR creation is in flight,
5280                    // flag the completion handler to discard its encoder
5281                    // instead of installing the stale one.
5282                    if was_subscribed && (codec_support != old_codec || new_quality != old_quality)
5283                    {
5284                        state.encoder = None;
5285                        if task_in_flight {
5286                            state.encoder_invalidated = true;
5287                        }
5288                    }
5289                    if meaningful_change {
5290                        c.surface_needs_keyframe = true;
5291                    }
5292                    first_subscribe = !was_subscribed;
5293                    if was_subscribed
5294                        && (codec_support != old_codec || new_quality != old_quality)
5295                        && c.vulkan_video_surfaces.remove(&surface_id).is_some()
5296                    {
5297                        destroy_vulkan_enc_sid = Some(surface_id);
5298                    }
5299                }
5300                if let Some(sid) = destroy_vulkan_enc_sid
5301                    && let Some(cs) = sess.compositor.as_ref()
5302                {
5303                    let _ = cs.handle.command_tx.send(
5304                        blit_compositor::CompositorCommand::DestroyVulkanEncoder {
5305                            surface_id: sid as u32,
5306                        },
5307                    );
5308                    cs.handle.wake();
5309                }
5310                if first_subscribe {
5311                    sess.resize_surfaces_to_mediated_sizes(
5312                        std::iter::once(surface_id),
5313                        &state.config.surface_encoders,
5314                    );
5315                }
5316                state.delivery_notify.notify_one();
5317            }
5318            C2S_SURFACE_UNSUBSCRIBE if data.len() >= 3 => {
5319                let surface_id = u16::from_le_bytes([data[1], data[2]]);
5320                let mut removed_vulkan = false;
5321                if let Some(c) = sess.clients.get_mut(&client_id) {
5322                    c.surface_subscriptions.remove(&surface_id);
5323                    c.surface_subs.remove(&surface_id);
5324                    removed_vulkan = c.vulkan_video_surfaces.remove(&surface_id).is_some();
5325                    c.surface_view_sizes.remove(&surface_id);
5326                }
5327                // Destroy Vulkan Video encoder if no remaining client needs it.
5328                if removed_vulkan {
5329                    let still_needed = sess
5330                        .clients
5331                        .values()
5332                        .any(|other| other.vulkan_video_surfaces.contains_key(&surface_id));
5333                    if !still_needed && let Some(cs) = sess.compositor.as_ref() {
5334                        let _ = cs.handle.command_tx.send(
5335                            blit_compositor::CompositorCommand::DestroyVulkanEncoder {
5336                                surface_id: surface_id as u32,
5337                            },
5338                        );
5339                        cs.handle.wake();
5340                    }
5341                }
5342                sess.resize_surfaces_to_mediated_sizes(
5343                    std::iter::once(surface_id),
5344                    &state.config.surface_encoders,
5345                );
5346            }
5347            #[cfg(target_os = "linux")]
5348            C2S_AUDIO_SUBSCRIBE if data.len() >= 3 => {
5349                let bitrate_kbps = u16::from_le_bytes([data[1], data[2]]);
5350                if let Some(c) = sess.clients.get_mut(&client_id) {
5351                    c.audio_subscribed = true;
5352                    c.audio_bitrate_kbps = bitrate_kbps;
5353                    if state.config.verbose {
5354                        eprintln!(
5355                            "C2S_AUDIO_SUBSCRIBE: cid={client_id} bitrate_kbps={bitrate_kbps}"
5356                        );
5357                    }
5358                    // Send ring buffer catch-up frames via the audio channel.
5359                    if let Some(cs) = sess.compositor.as_mut()
5360                        && let Some(ref ap) = cs.audio_pipeline
5361                    {
5362                        let msgs: Vec<_> = ap.ring_frames().map(audio::msg_audio_frame).collect();
5363                        if let Some(c) = sess.clients.get(&client_id) {
5364                            for msg in msgs {
5365                                let _ = c.audio_tx.send(msg);
5366                            }
5367                        }
5368                    }
5369                }
5370                // Recompute the effective audio bitrate across all
5371                // subscribed clients (use the max requested bitrate).
5372                if let Some(cs) = sess.compositor.as_ref()
5373                    && let Some(ref ap) = cs.audio_pipeline
5374                {
5375                    let max_kbps = sess
5376                        .clients
5377                        .values()
5378                        .filter(|c| c.audio_subscribed)
5379                        .map(|c| c.audio_bitrate_kbps)
5380                        .max()
5381                        .unwrap_or(0);
5382                    let bitrate = if max_kbps > 0 {
5383                        max_kbps as i32 * 1000
5384                    } else {
5385                        audio::DEFAULT_BITRATE
5386                    };
5387                    ap.set_bitrate(bitrate);
5388                }
5389                state.delivery_notify.notify_one();
5390            }
5391            #[cfg(target_os = "linux")]
5392            C2S_AUDIO_UNSUBSCRIBE if !data.is_empty() => {
5393                if let Some(c) = sess.clients.get_mut(&client_id) {
5394                    c.audio_subscribed = false;
5395                    c.audio_bitrate_kbps = 0;
5396                    if state.config.verbose {
5397                        eprintln!("C2S_AUDIO_UNSUBSCRIBE: cid={client_id}");
5398                    }
5399                }
5400                // Recompute effective bitrate after unsubscribe.
5401                if let Some(cs) = sess.compositor.as_ref()
5402                    && let Some(ref ap) = cs.audio_pipeline
5403                {
5404                    let max_kbps = sess
5405                        .clients
5406                        .values()
5407                        .filter(|c| c.audio_subscribed)
5408                        .map(|c| c.audio_bitrate_kbps)
5409                        .max()
5410                        .unwrap_or(0);
5411                    let bitrate = if max_kbps > 0 {
5412                        max_kbps as i32 * 1000
5413                    } else {
5414                        audio::DEFAULT_BITRATE
5415                    };
5416                    ap.set_bitrate(bitrate);
5417                }
5418            }
5419            C2S_SURFACE_ACK if data.len() >= 3 => {
5420                // Surface ACKs feed shared RTT / delivery_bps / goodput_bps
5421                // from a separate inflight queue so they don't corrupt
5422                // terminal frame-size averages or probe_frames.
5423                if let Some(c) = sess.clients.get_mut(&client_id) {
5424                    c.acks_recv += 1;
5425                    record_surface_ack(c);
5426                }
5427                state.delivery_notify.notify_one();
5428            }
5429            C2S_CLIENT_FEATURES if data.len() >= 2 => {
5430                // Byte 0: codec_support bitmask.  Future bytes are ignored
5431                // if unknown, defaulting to 0 when absent.
5432                let codec_support = data[1];
5433                if let Some(c) = sess.clients.get_mut(&client_id) {
5434                    c.surface_codec_support = codec_support;
5435                }
5436            }
5437            C2S_CLIPBOARD_SET if data.len() >= 5 => {
5438                let mime_len = u16::from_le_bytes([data[1], data[2]]) as usize;
5439                if data.len() >= 3 + mime_len + 4 {
5440                    let mime = std::str::from_utf8(&data[3..3 + mime_len])
5441                        .unwrap_or("text/plain")
5442                        .to_string();
5443                    let data_len = u32::from_le_bytes([
5444                        data[3 + mime_len],
5445                        data[4 + mime_len],
5446                        data[5 + mime_len],
5447                        data[6 + mime_len],
5448                    ]) as usize;
5449                    let payload_start = 7 + mime_len;
5450                    if data.len() >= payload_start + data_len {
5451                        let payload = data[payload_start..payload_start + data_len].to_vec();
5452                        if let Some(cs) = sess.compositor.as_ref() {
5453                            let _ = cs
5454                                .handle
5455                                .command_tx
5456                                .send(CompositorCommand::ClipboardOffer {
5457                                    mime_type: mime,
5458                                    data: payload,
5459                                });
5460                        }
5461                    }
5462                }
5463            }
5464            C2S_CLIPBOARD_LIST if !data.is_empty() => {
5465                if let Some(cs) = sess.compositor.as_ref() {
5466                    let command_tx = cs.handle.command_tx.clone();
5467                    let client_tx = sess.clients.get(&client_id).map(|c| {
5468                        (
5469                            c.tx.clone(),
5470                            c.outbox_queued_frames.clone(),
5471                            c.outbox_queued_bytes.clone(),
5472                        )
5473                    });
5474                    if let Some((client_tx, queued_frames, queued_bytes)) = client_tx {
5475                        tokio::task::spawn_blocking(move || {
5476                            let (tx, rx) = std::sync::mpsc::sync_channel(1);
5477                            if command_tx
5478                                .send(CompositorCommand::ClipboardListMimes { reply: tx })
5479                                .is_ok()
5480                                && let Ok(mimes) = rx.recv_timeout(Duration::from_secs(2))
5481                            {
5482                                let _ = send_outbox_tracked(
5483                                    &client_tx,
5484                                    &queued_frames,
5485                                    &queued_bytes,
5486                                    msg_s2c_clipboard_list(&mimes),
5487                                );
5488                            }
5489                        });
5490                    }
5491                } else {
5492                    // No compositor — respond with empty list.
5493                    if let Some(c) = sess.clients.get(&client_id) {
5494                        let _ = send_outbox(c, msg_s2c_clipboard_list(&[]));
5495                    }
5496                }
5497            }
5498            C2S_CLIPBOARD_GET if data.len() >= 3 => {
5499                let mime_len = u16::from_le_bytes([data[1], data[2]]) as usize;
5500                if data.len() >= 3 + mime_len {
5501                    let mime = std::str::from_utf8(&data[3..3 + mime_len])
5502                        .unwrap_or("text/plain")
5503                        .to_string();
5504                    if let Some(cs) = sess.compositor.as_ref() {
5505                        let command_tx = cs.handle.command_tx.clone();
5506                        let client_tx = sess.clients.get(&client_id).map(|c| {
5507                            (
5508                                c.tx.clone(),
5509                                c.outbox_queued_frames.clone(),
5510                                c.outbox_queued_bytes.clone(),
5511                            )
5512                        });
5513                        if let Some((client_tx, queued_frames, queued_bytes)) = client_tx {
5514                            tokio::task::spawn_blocking(move || {
5515                                let (tx, rx) = std::sync::mpsc::sync_channel(1);
5516                                if command_tx
5517                                    .send(CompositorCommand::ClipboardGet {
5518                                        mime_type: mime.clone(),
5519                                        reply: tx,
5520                                    })
5521                                    .is_ok()
5522                                    && let Ok(content) = rx.recv_timeout(Duration::from_secs(2))
5523                                {
5524                                    let data = content.unwrap_or_default();
5525                                    let _ = send_outbox_tracked(
5526                                        &client_tx,
5527                                        &queued_frames,
5528                                        &queued_bytes,
5529                                        msg_s2c_clipboard_content(&mime, &data),
5530                                    );
5531                                }
5532                            });
5533                        }
5534                    } else {
5535                        // No compositor — respond with empty clipboard.
5536                        if let Some(c) = sess.clients.get(&client_id) {
5537                            let _ = send_outbox(c, msg_s2c_clipboard_content(&mime, &[]));
5538                        }
5539                    }
5540                }
5541            }
5542            C2S_SURFACE_LIST if !data.is_empty() => {
5543                let msg = sess.surface_list_msg();
5544                if let Some(c) = sess.clients.get(&client_id) {
5545                    let _ = send_outbox(c, msg);
5546                }
5547            }
5548            C2S_FOCUS if data.len() >= 3 => {
5549                let pid = u16::from_le_bytes([data[1], data[2]]);
5550                if sess.ptys.contains_key(&pid) {
5551                    let old_pid = sess.clients.get(&client_id).and_then(|c| c.lead);
5552                    if let Some(c) = sess.clients.get_mut(&client_id) {
5553                        c.lead = Some(pid);
5554                        subscribe_client_to(c, pid);
5555                        if old_pid == Some(pid) {
5556                            update_client_scroll_state(c, pid, 0);
5557                        } else {
5558                            reset_inflight(c);
5559                        }
5560                    }
5561                    if let Some(pty) = sess.ptys.get_mut(&pid) {
5562                        pty.mark_dirty();
5563                        need_nudge = true;
5564                    }
5565                }
5566            }
5567            C2S_SUBSCRIBE if data.len() >= 3 => {
5568                let pid = u16::from_le_bytes([data[1], data[2]]);
5569                if sess.ptys.contains_key(&pid) {
5570                    if let Some(c) = sess.clients.get_mut(&client_id) {
5571                        subscribe_client_to(c, pid);
5572                    }
5573                    if let Some(pty) = sess.ptys.get_mut(&pid) {
5574                        pty.mark_dirty();
5575                    }
5576                    need_nudge = true;
5577                }
5578            }
5579            C2S_UNSUBSCRIBE if data.len() >= 3 => {
5580                let pid = u16::from_le_bytes([data[1], data[2]]);
5581                if sess.ptys.contains_key(&pid) {
5582                    let mut touched = Vec::new();
5583                    if let Some(c) = sess.clients.get_mut(&client_id) {
5584                        if unsubscribe_client_from(c, pid) {
5585                            touched.push(pid);
5586                        }
5587                        reset_inflight(c);
5588                    }
5589                    if sess.resize_ptys_to_mediated_sizes(touched) {
5590                        need_nudge = true;
5591                    }
5592                }
5593            }
5594            C2S_RESTART if data.len() >= 3 => {
5595                let pid = u16::from_le_bytes([data[1], data[2]]);
5596                let restart_info = sess
5597                    .ptys
5598                    .get(&pid)
5599                    .filter(|p| p.exited)
5600                    .map(|p| (p.driver.size(), p.command.clone(), p.tag.clone()));
5601                if let Some(((rows, cols), command, tag)) = restart_info {
5602                    let wayland_display = sess
5603                        .compositor
5604                        .as_ref()
5605                        .map(|cs| cs.handle.socket_name.clone());
5606                    #[cfg(target_os = "linux")]
5607                    let pulse_server = sess.pulse_server_path();
5608                    #[cfg(not(target_os = "linux"))]
5609                    let pulse_server: Option<String> = None;
5610                    #[cfg(target_os = "linux")]
5611                    let pipewire_remote = sess.pipewire_remote_path();
5612                    #[cfg(not(target_os = "linux"))]
5613                    let pipewire_remote: Option<String> = None;
5614                    if let Some((new_handle, reader, byte_rx)) = pty::respawn_child(
5615                        &state.config.shell,
5616                        &state.config.shell_flags,
5617                        rows,
5618                        cols,
5619                        pid,
5620                        command.as_deref(),
5621                        state.clone(),
5622                        wayland_display.as_deref(),
5623                        pulse_server.as_deref(),
5624                        pipewire_remote.as_deref(),
5625                    ) {
5626                        let Some(pty) = sess.ptys.get_mut(&pid) else {
5627                            break;
5628                        };
5629                        pty.handle = new_handle;
5630                        pty.reader_handle = reader;
5631                        pty.byte_rx = byte_rx;
5632                        pty.driver.reset_modes();
5633                        pty.exited = false;
5634                        pty.exit_status = blit_remote::EXIT_STATUS_UNKNOWN;
5635                        pty.lflag_cache = pty::pty_lflag(&pty.handle);
5636                        pty.lflag_last = Instant::now();
5637                        pty.mark_dirty();
5638                        if let Some(c) = sess.clients.get_mut(&client_id) {
5639                            c.lead = Some(pid);
5640                            subscribe_client_to(c, pid);
5641                            update_client_scroll_state(c, pid, 0);
5642                            reset_inflight(c);
5643                        }
5644                        let mut msg = Vec::with_capacity(3 + tag.len());
5645                        msg.push(S2C_CREATED);
5646                        msg.extend_from_slice(&pid.to_le_bytes());
5647                        msg.extend_from_slice(tag.as_bytes());
5648                        sess.send_to_all(&msg);
5649                        need_nudge = true;
5650                    }
5651                }
5652            }
5653            C2S_READ if data.len() >= 13 => {
5654                let nonce = u16::from_le_bytes([data[1], data[2]]);
5655                let pid = u16::from_le_bytes([data[3], data[4]]);
5656                let req_offset = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
5657                let req_limit =
5658                    u32::from_le_bytes([data[9], data[10], data[11], data[12]]) as usize;
5659                let flags = data.get(13).copied().unwrap_or(0);
5660                let ansi = flags & READ_ANSI != 0;
5661                let tail = flags & READ_TAIL != 0;
5662
5663                if let Some(pty) = sess.ptys.get_mut(&pid) {
5664                    let (rows, _cols) = pty.driver.size();
5665                    let viewport = take_snapshot(pty);
5666                    let scrollback_lines = viewport.scrollback_lines() as usize;
5667                    let total_lines = scrollback_lines + rows as usize;
5668
5669                    let extract = |f: &FrameState| -> String {
5670                        if ansi {
5671                            f.get_ansi_text()
5672                        } else {
5673                            f.get_all_text()
5674                        }
5675                    };
5676
5677                    let mut all_lines: Vec<String> =
5678                        Vec::with_capacity(scrollback_lines + rows as usize);
5679
5680                    let mut scroll_offset = scrollback_lines;
5681                    while scroll_offset > 0 {
5682                        let frame = pty.driver.scrollback_frame(scroll_offset);
5683                        let page = extract(&frame);
5684                        let page_lines: Vec<&str> = page.lines().collect();
5685                        let take = if scroll_offset < rows as usize {
5686                            scroll_offset.min(page_lines.len())
5687                        } else {
5688                            page_lines.len()
5689                        };
5690                        for line in &page_lines[..take] {
5691                            all_lines.push(line.to_string());
5692                        }
5693                        if scroll_offset <= rows as usize {
5694                            break;
5695                        }
5696                        scroll_offset = scroll_offset.saturating_sub(rows as usize);
5697                    }
5698
5699                    for line in extract(&viewport).lines() {
5700                        all_lines.push(line.to_string());
5701                    }
5702
5703                    let (start, end) = if tail {
5704                        let end = all_lines.len().saturating_sub(req_offset);
5705                        let start = if req_limit == 0 {
5706                            0
5707                        } else {
5708                            end.saturating_sub(req_limit)
5709                        };
5710                        (start, end)
5711                    } else {
5712                        let start = req_offset.min(all_lines.len());
5713                        let end = if req_limit == 0 {
5714                            all_lines.len()
5715                        } else {
5716                            (start + req_limit).min(all_lines.len())
5717                        };
5718                        (start, end)
5719                    };
5720                    let text = all_lines[start..end].join("\n");
5721
5722                    let mut msg = Vec::with_capacity(13 + text.len());
5723                    msg.push(S2C_TEXT);
5724                    msg.extend_from_slice(&nonce.to_le_bytes());
5725                    msg.extend_from_slice(&pid.to_le_bytes());
5726                    msg.extend_from_slice(&(total_lines as u32).to_le_bytes());
5727                    msg.extend_from_slice(&(start as u32).to_le_bytes());
5728                    msg.extend_from_slice(text.as_bytes());
5729                    if let Some(client) = sess.clients.get(&client_id) {
5730                        let _ = send_outbox(client, msg);
5731                    }
5732                }
5733            }
5734            C2S_COPY_RANGE if data.len() >= 18 => {
5735                let nonce = u16::from_le_bytes([data[1], data[2]]);
5736                let pid = u16::from_le_bytes([data[3], data[4]]);
5737                let start_tail = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
5738                let start_col = u16::from_le_bytes([data[9], data[10]]);
5739                let end_tail = u32::from_le_bytes([data[11], data[12], data[13], data[14]]);
5740                let end_col = u16::from_le_bytes([data[15], data[16]]);
5741
5742                if let Some(pty) = sess.ptys.get(&pid) {
5743                    let text = pty
5744                        .driver
5745                        .get_text_range(start_tail, start_col, end_tail, end_col);
5746                    let total_lines = pty.driver.total_lines();
5747
5748                    let mut msg = Vec::with_capacity(13 + text.len());
5749                    msg.push(S2C_TEXT);
5750                    msg.extend_from_slice(&nonce.to_le_bytes());
5751                    msg.extend_from_slice(&pid.to_le_bytes());
5752                    msg.extend_from_slice(&total_lines.to_le_bytes());
5753                    msg.extend_from_slice(&start_tail.to_le_bytes());
5754                    msg.extend_from_slice(text.as_bytes());
5755                    if let Some(client) = sess.clients.get(&client_id) {
5756                        let _ = send_outbox(client, msg);
5757                    }
5758                }
5759            }
5760            C2S_KILL if data.len() >= 7 => {
5761                let pid = u16::from_le_bytes([data[1], data[2]]);
5762                let signal = i32::from_le_bytes([data[3], data[4], data[5], data[6]]);
5763                if let Some(pty) = sess.ptys.get(&pid)
5764                    && !pty.exited
5765                {
5766                    pty::kill_pty(&pty.handle, signal);
5767                }
5768            }
5769            C2S_CLOSE if data.len() >= 3 => {
5770                let pid = u16::from_le_bytes([data[1], data[2]]);
5771                if let Some(pty) = sess.ptys.remove(&pid) {
5772                    if !pty.exited {
5773                        state.pty_fds.write().unwrap().remove(&pid);
5774                        drop(pty.reader_handle);
5775                        pty::close_pty(&pty.handle);
5776                    }
5777                    for client in sess.clients.values_mut() {
5778                        unsubscribe_client_from(client, pid);
5779                    }
5780                    let mut msg = vec![S2C_CLOSED];
5781                    msg.extend_from_slice(&pid.to_le_bytes());
5782                    sess.send_to_all(&msg);
5783                }
5784            }
5785            _ => {}
5786        }
5787        drop(sess);
5788        if need_nudge {
5789            nudge_delivery(&state);
5790        }
5791    }
5792
5793    {
5794        let mut sess = state.session.lock().await;
5795        let mut need_nudge = false;
5796        let client = sess.clients.remove(&client_id);
5797        let affected_ptys = client
5798            .as_ref()
5799            .map(|c| c.view_sizes.keys().copied().collect::<Vec<_>>())
5800            .unwrap_or_default();
5801        let affected_surfaces = client
5802            .as_ref()
5803            .map(|c| c.surface_view_sizes.keys().copied().collect::<Vec<_>>())
5804            .unwrap_or_default();
5805        if sess.resize_ptys_to_mediated_sizes(affected_ptys) {
5806            need_nudge = true;
5807        }
5808        sess.resize_surfaces_to_mediated_sizes(affected_surfaces, &state.config.surface_encoders);
5809        // Release any keys this client was holding when it disconnected.
5810        // Without this, modifier keys (Shift, Ctrl, etc.) stay stuck and
5811        // regular keys auto-repeat forever in the compositor.
5812        if let Some(ref client) = client
5813            && !client.pressed_surface_keys.is_empty()
5814            && let Some(cs) = sess.compositor.as_mut()
5815        {
5816            let keycodes: Vec<u32> = client.pressed_surface_keys.iter().copied().collect();
5817            let _ = cs
5818                .handle
5819                .command_tx
5820                .send(CompositorCommand::ReleaseKeys { keycodes });
5821            cs.handle.wake();
5822        }
5823        // Destroy Vulkan Video encoders for surfaces that no remaining
5824        // client needs.
5825        if let Some(ref client) = client
5826            && !client.vulkan_video_surfaces.is_empty()
5827            && let Some(cs) = sess.compositor.as_ref()
5828        {
5829            for &sid in client.vulkan_video_surfaces.keys() {
5830                let still_needed = sess
5831                    .clients
5832                    .values()
5833                    .any(|c| c.vulkan_video_surfaces.contains_key(&sid));
5834                if !still_needed {
5835                    let _ = cs
5836                        .handle
5837                        .command_tx
5838                        .send(CompositorCommand::DestroyVulkanEncoder {
5839                            surface_id: sid as u32,
5840                        });
5841                }
5842            }
5843            cs.handle.wake();
5844        }
5845        drop(sess);
5846        if need_nudge {
5847            nudge_delivery(&state);
5848        }
5849    }
5850    sender.abort();
5851    if state.config.verbose {
5852        eprintln!("client disconnected");
5853    }
5854}
5855
5856#[cfg(test)]
5857mod tests {
5858    use super::*;
5859
5860    fn test_client_with_capacity(
5861        _capacity: usize,
5862    ) -> (ClientState, mpsc::UnboundedReceiver<Vec<u8>>) {
5863        let (tx, rx) = mpsc::unbounded_channel();
5864        let (audio_tx, _audio_rx) = mpsc::unbounded_channel();
5865        let client = ClientState {
5866            tx,
5867            outbox_queued_frames: Arc::new(AtomicUsize::new(0)),
5868            outbox_queued_bytes: Arc::new(AtomicUsize::new(0)),
5869            audio_tx,
5870            lead: None,
5871            subscriptions: HashSet::new(),
5872            surface_subscriptions: HashSet::new(),
5873            audio_subscribed: false,
5874            #[cfg(target_os = "linux")]
5875            audio_bitrate_kbps: 0,
5876            view_sizes: HashMap::new(),
5877            scroll_offsets: HashMap::new(),
5878            scroll_caches: HashMap::new(),
5879            last_sent: HashMap::new(),
5880            preview_next_send_at: HashMap::new(),
5881            rtt_ms: 50.0,
5882            min_rtt_ms: 50.0,
5883            display_fps: 60.0,
5884            delivery_bps: 262_144.0,
5885            goodput_bps: 262_144.0,
5886            goodput_jitter_bps: 0.0,
5887            max_goodput_jitter_bps: 0.0,
5888            last_goodput_sample_bps: 0.0,
5889            avg_frame_bytes: 1_024.0,
5890            avg_paced_frame_bytes: 1_024.0,
5891            avg_preview_frame_bytes: 1_024.0,
5892            avg_surface_frame_bytes: 8_192.0,
5893            inflight_bytes: 0,
5894            inflight_frames: VecDeque::new(),
5895            next_send_at: Instant::now(),
5896            probe_frames: 0.0,
5897            frames_sent: 0,
5898            acks_recv: 0,
5899            acked_bytes_since_log: 0,
5900            browser_backlog_frames: 0,
5901            browser_ack_ahead_frames: 0,
5902            browser_apply_ms: 0.0,
5903            last_metrics_update: Instant::now(),
5904            last_log: Instant::now(),
5905            last_window_blocked_log: Instant::now(),
5906            last_skip_log: Instant::now(),
5907            skip_same_gen_count: 0,
5908            skip_in_flight_count: 0,
5909            skip_pacing_count: 0,
5910            skip_vulkan_await_count: 0,
5911            skip_no_subs_count: 0,
5912            skip_not_subbed_count: 0,
5913            skip_last_pixels_mismatch_count: 0,
5914            encode_loop_iters: 0,
5915            goodput_window_bytes: 0,
5916            goodput_window_start: Instant::now(),
5917            surface_subs: HashMap::new(),
5918            surface_needs_keyframe: true,
5919            surface_inflight_frames: VecDeque::new(),
5920            vulkan_video_surfaces: HashMap::new(),
5921            surface_view_sizes: HashMap::new(),
5922            surface_codec_support: 0,
5923            pressed_surface_keys: HashSet::new(),
5924        };
5925        (client, rx)
5926    }
5927
5928    fn test_client() -> ClientState {
5929        let (client, _rx) = test_client_with_capacity(0);
5930        client
5931    }
5932
5933    fn fill_inflight(client: &mut ClientState, frames: usize, bytes_per_frame: usize) {
5934        let now = Instant::now();
5935        client.inflight_bytes = frames.saturating_mul(bytes_per_frame);
5936        client.inflight_frames = (0..frames)
5937            .map(|_| InFlightFrame {
5938                sent_at: now,
5939                bytes: bytes_per_frame,
5940                paced: true,
5941            })
5942            .collect();
5943    }
5944
5945    fn sample_frame(text: &str) -> FrameState {
5946        let mut frame = FrameState::new(2, 8);
5947        frame.write_text(0, 0, text, blit_remote::CellStyle::default());
5948        frame
5949    }
5950
5951    #[test]
5952    fn unset_view_size_accepts_zero_pair_only() {
5953        assert!(is_unset_view_size(0, 0));
5954        assert!(!is_unset_view_size(0, 80));
5955        assert!(!is_unset_view_size(u16::MAX, u16::MAX));
5956    }
5957
5958    #[test]
5959    fn unsubscribe_client_from_clears_view_size() {
5960        let mut client = test_client();
5961        client.subscriptions.insert(7);
5962        client.view_sizes.insert(7, (24, 80));
5963        assert!(unsubscribe_client_from(&mut client, 7));
5964        assert!(!client.subscriptions.contains(&7));
5965        assert!(!client.view_sizes.contains_key(&7));
5966    }
5967
5968    #[test]
5969    fn mediated_size_uses_per_pty_view_sizes_without_lead() {
5970        let mut session = Session::new();
5971        let mut c1 = test_client();
5972        let mut c2 = test_client();
5973        c1.view_sizes.insert(7, (30, 120));
5974        c2.view_sizes.insert(7, (24, 100));
5975        session.clients.insert(1, c1);
5976        session.clients.insert(2, c2);
5977        assert_eq!(session.mediated_size_for_pty(7), Some((24, 100)));
5978    }
5979
5980    #[test]
5981    fn mediated_surface_size_picks_min_dimensions_max_scale() {
5982        let mut session = Session::new();
5983        let mut c1 = test_client();
5984        let mut c2 = test_client();
5985        c1.surface_view_sizes.insert(1, (1920, 1080, 240)); // 2×
5986        c2.surface_view_sizes.insert(1, (1280, 720, 120)); // 1×
5987        session.clients.insert(1, c1);
5988        session.clients.insert(2, c2);
5989        assert_eq!(
5990            session.mediated_size_for_surface(1, None),
5991            Some((1280, 720, 240))
5992        );
5993    }
5994
5995    #[test]
5996    fn mediated_surface_size_none_when_no_clients() {
5997        let session = Session::new();
5998        assert_eq!(session.mediated_size_for_surface(1, None), None);
5999    }
6000
6001    #[test]
6002    fn mediated_surface_size_single_client() {
6003        let mut session = Session::new();
6004        let mut c1 = test_client();
6005        c1.surface_view_sizes.insert(3, (800, 600, 120));
6006        session.clients.insert(1, c1);
6007        assert_eq!(
6008            session.mediated_size_for_surface(3, None),
6009            Some((800, 600, 120))
6010        );
6011    }
6012
6013    #[test]
6014    fn mediated_surface_size_ignores_other_surfaces() {
6015        let mut session = Session::new();
6016        let mut c1 = test_client();
6017        c1.surface_view_sizes.insert(1, (1920, 1080, 240));
6018        c1.surface_view_sizes.insert(2, (640, 480, 120));
6019        session.clients.insert(1, c1);
6020        assert_eq!(
6021            session.mediated_size_for_surface(1, None),
6022            Some((1920, 1080, 240))
6023        );
6024        assert_eq!(
6025            session.mediated_size_for_surface(2, None),
6026            Some((640, 480, 120))
6027        );
6028        assert_eq!(session.mediated_size_for_surface(3, None), None);
6029    }
6030
6031    #[test]
6032    fn mediated_surface_size_clamped_to_encoder_max() {
6033        let mut session = Session::new();
6034        let mut c1 = test_client();
6035        c1.surface_view_sizes.insert(1, (5000, 3000, 240));
6036        session.clients.insert(1, c1);
6037        assert_eq!(
6038            session.mediated_size_for_surface(1, None),
6039            Some((5000, 3000, 240))
6040        );
6041        assert_eq!(
6042            session.mediated_size_for_surface(1, Some((3840, 2160))),
6043            Some((3840, 2160, 240))
6044        );
6045    }
6046
6047    #[test]
6048    fn mediated_surface_size_picks_min_across_clients() {
6049        let mut session = Session::new();
6050        let mut c1 = test_client();
6051        let mut c2 = test_client();
6052        c1.surface_view_sizes.insert(1, (1920, 1080, 120));
6053        c2.surface_view_sizes.insert(1, (640, 360, 120));
6054        c1.surface_subscriptions.insert(1);
6055        c2.surface_subscriptions.insert(1);
6056        session.clients.insert(1, c1);
6057        session.clients.insert(2, c2);
6058        assert_eq!(
6059            session.mediated_size_for_surface(1, None),
6060            Some((640, 360, 120))
6061        );
6062    }
6063
6064    #[test]
6065    fn due_preview_reserves_the_last_lead_slot() {
6066        let mut client = test_client();
6067        client.lead = Some(1);
6068        client.subscriptions.insert(1);
6069        client.subscriptions.insert(2);
6070
6071        let target_frames = target_frame_window(&client);
6072        let lead_limit = target_frames.saturating_sub(1).max(1);
6073        fill_inflight(&mut client, lead_limit, 512);
6074
6075        assert!(window_open(&client));
6076        assert!(lead_window_open(&client, false));
6077        assert!(!lead_window_open(&client, true));
6078        assert!(can_send_preview(&client, 2, Instant::now()));
6079    }
6080
6081    #[test]
6082    fn entering_scrollback_uses_current_visible_frame_as_baseline() {
6083        let mut client = test_client();
6084        let live = sample_frame("live");
6085        client.lead = Some(7);
6086        client.subscriptions.insert(7);
6087        client.last_sent.insert(7, live.clone());
6088
6089        assert!(update_client_scroll_state(&mut client, 7, 12));
6090        assert_eq!(client.scroll_offsets.get(&7), Some(&12));
6091        assert_eq!(client.scroll_caches.get(&7), Some(&live));
6092    }
6093
6094    #[test]
6095    fn leaving_scrollback_seeds_live_diff_from_scrollback_view() {
6096        let mut client = test_client();
6097        let history = sample_frame("hist");
6098        client.lead = Some(7);
6099        client.subscriptions.insert(7);
6100        client.scroll_offsets.insert(7, 12);
6101        client.scroll_caches.insert(7, history.clone());
6102
6103        assert!(update_client_scroll_state(&mut client, 7, 0));
6104        assert_eq!(client.scroll_offsets.get(&7), None);
6105        assert_eq!(client.last_sent.get(&7), Some(&history));
6106        assert_eq!(client.scroll_caches.get(&7), None);
6107    }
6108
6109    #[tokio::test]
6110    async fn request_surface_capture_returns_pixels_from_compositor() {
6111        let (command_tx, command_rx) = std::sync::mpsc::channel();
6112        std::thread::Builder::new()
6113            .name("test-capture-reply".into())
6114            .spawn(move || {
6115                let CompositorCommand::Capture {
6116                    surface_id,
6117                    scale_120: _,
6118                    reply,
6119                } = command_rx.recv().unwrap()
6120                else {
6121                    panic!("expected capture command");
6122                };
6123                assert_eq!(surface_id, 7);
6124                let _ = reply.send(Some((2, 3, vec![1, 2, 3, 4])));
6125            })
6126            .unwrap();
6127
6128        let result =
6129            request_surface_capture_with_timeout(command_tx, 7, 0, Duration::from_millis(50)).await;
6130
6131        assert_eq!(result, Some((2, 3, vec![1, 2, 3, 4])));
6132    }
6133
6134    #[tokio::test]
6135    async fn request_surface_capture_returns_none_when_compositor_disconnects() {
6136        let (command_tx, command_rx) = std::sync::mpsc::channel();
6137        std::thread::Builder::new()
6138            .name("test-capture-drop".into())
6139            .spawn(move || {
6140                let _ = command_rx.recv().unwrap();
6141            })
6142            .unwrap();
6143
6144        let result =
6145            request_surface_capture_with_timeout(command_tx, 7, 0, Duration::from_millis(50)).await;
6146
6147        assert_eq!(result, None);
6148    }
6149
6150    // ── frame_window ──
6151
6152    #[test]
6153    fn frame_window_minimum_is_two() {
6154        assert!(frame_window(0.0, 60.0) >= 2);
6155    }
6156
6157    #[test]
6158    fn frame_window_scales_with_rtt() {
6159        let low = frame_window(10.0, 60.0);
6160        let high = frame_window(200.0, 60.0);
6161        assert!(high > low, "higher RTT should need more frames in flight");
6162    }
6163
6164    #[test]
6165    fn frame_window_scales_with_fps() {
6166        let slow = frame_window(100.0, 10.0);
6167        let fast = frame_window(100.0, 120.0);
6168        assert!(fast > slow, "higher fps should need more frames in flight");
6169    }
6170
6171    #[test]
6172    fn frame_window_zero_rtt() {
6173        assert!(frame_window(0.0, 120.0) >= 2);
6174    }
6175
6176    // ── path_rtt_ms ──
6177
6178    #[test]
6179    fn path_rtt_ms_uses_min_when_positive() {
6180        let mut client = test_client();
6181        client.rtt_ms = 100.0;
6182        client.min_rtt_ms = 30.0;
6183        assert_eq!(path_rtt_ms(&client), 30.0);
6184    }
6185
6186    #[test]
6187    fn path_rtt_ms_falls_back_to_rtt_when_min_zero() {
6188        let mut client = test_client();
6189        client.rtt_ms = 80.0;
6190        client.min_rtt_ms = 0.0;
6191        assert_eq!(path_rtt_ms(&client), 80.0);
6192    }
6193
6194    // ── ewma_with_direction ──
6195
6196    #[test]
6197    fn ewma_rising_uses_rise_alpha() {
6198        let result = ewma_with_direction(100.0, 200.0, 0.5, 0.1);
6199        // rise: 100 * 0.5 + 200 * 0.5 = 150
6200        assert!((result - 150.0).abs() < 0.01);
6201    }
6202
6203    #[test]
6204    fn ewma_falling_uses_fall_alpha() {
6205        let result = ewma_with_direction(200.0, 100.0, 0.5, 0.1);
6206        // fall: 200 * 0.9 + 100 * 0.1 = 190
6207        assert!((result - 190.0).abs() < 0.01);
6208    }
6209
6210    #[test]
6211    fn ewma_same_value_unchanged() {
6212        let result = ewma_with_direction(50.0, 50.0, 0.5, 0.5);
6213        assert!((result - 50.0).abs() < 0.01);
6214    }
6215
6216    // ── advance_deadline ──
6217
6218    #[test]
6219    fn advance_deadline_steps_forward() {
6220        let now = Instant::now();
6221        let mut deadline = now;
6222        let interval = Duration::from_millis(16);
6223        advance_deadline(&mut deadline, now, interval);
6224        assert!(deadline > now);
6225        assert!(deadline <= now + interval + Duration::from_micros(100));
6226    }
6227
6228    #[test]
6229    fn advance_deadline_resets_when_far_behind() {
6230        let now = Instant::now();
6231        // deadline is way in the past (more than 2 intervals ago)
6232        let mut deadline = now - Duration::from_secs(10);
6233        let interval = Duration::from_millis(16);
6234        advance_deadline(&mut deadline, now, interval);
6235        // Should snap to now + interval since scheduled + interval < now
6236        assert!(deadline >= now);
6237    }
6238
6239    #[test]
6240    fn should_snapshot_pty_requires_dirty_and_needful() {
6241        assert!(should_snapshot_pty(true, true, false));
6242        assert!(!should_snapshot_pty(false, true, false));
6243        assert!(!should_snapshot_pty(true, false, false));
6244    }
6245
6246    #[test]
6247    fn should_snapshot_pty_defers_synced_output() {
6248        assert!(!should_snapshot_pty(true, true, true));
6249        assert!(should_snapshot_pty(true, true, false));
6250    }
6251
6252    #[test]
6253    fn enqueue_ready_frame_refuses_new_frames_when_capped() {
6254        let mut queue = VecDeque::new();
6255        for cols in 1..=(READY_FRAME_QUEUE_CAP as u16) {
6256            assert!(enqueue_ready_frame(&mut queue, FrameState::new(1, cols)));
6257        }
6258        assert!(!enqueue_ready_frame(
6259            &mut queue,
6260            FrameState::new(1, READY_FRAME_QUEUE_CAP as u16 + 1),
6261        ));
6262        assert_eq!(queue.len(), READY_FRAME_QUEUE_CAP);
6263        assert_eq!(queue.front().map(FrameState::cols), Some(1));
6264        assert_eq!(
6265            queue.back().map(FrameState::cols),
6266            Some(READY_FRAME_QUEUE_CAP as u16),
6267        );
6268    }
6269
6270    #[test]
6271    fn find_sync_output_end_returns_end_of_first_close_sequence() {
6272        let bytes = b"abc\x1b[?2026lrest\x1b[?2026l";
6273        assert_eq!(find_sync_output_end(&[], bytes), Some(11));
6274    }
6275
6276    #[test]
6277    fn find_sync_output_end_returns_none_without_close_sequence() {
6278        assert_eq!(find_sync_output_end(&[], b"\x1b[?2026hpartial"), None);
6279    }
6280
6281    #[test]
6282    fn find_sync_output_end_detects_boundary_split_across_reads() {
6283        assert_eq!(find_sync_output_end(b"abc\x1b[?20", b"26lrest"), Some(3));
6284    }
6285
6286    #[test]
6287    fn update_sync_scan_tail_keeps_recent_suffix_only() {
6288        let mut tail = Vec::new();
6289        update_sync_scan_tail(&mut tail, b"123456789");
6290        assert_eq!(tail, b"3456789");
6291    }
6292
6293    // ── window_saturated ──
6294
6295    #[test]
6296    fn window_saturated_at_90_percent_frames() {
6297        let client = test_client();
6298        let target = target_frame_window(&client);
6299        let frames_90 = (target * 9).div_ceil(10); // ceil(target * 0.9)
6300        assert!(window_saturated(&client, frames_90, 0));
6301    }
6302
6303    #[test]
6304    fn window_saturated_not_at_low_usage() {
6305        let client = test_client();
6306        assert!(!window_saturated(&client, 1, 0));
6307    }
6308
6309    #[test]
6310    fn window_saturated_at_90_percent_bytes() {
6311        let client = test_client();
6312        let target_bytes = target_byte_window(&client);
6313        let bytes_90 = (target_bytes * 9).div_ceil(10);
6314        assert!(window_saturated(&client, 0, bytes_90));
6315    }
6316
6317    // ── outbox_queued_frames / outbox_backpressured ──
6318
6319    #[test]
6320    fn outbox_queued_frames_zero_when_empty() {
6321        let client = test_client();
6322        assert_eq!(outbox_queued_frames(&client), 0);
6323    }
6324
6325    #[test]
6326    fn outbox_backpressured_when_queue_full() {
6327        let (client, _rx) = test_client_with_capacity(0);
6328        // Fill the channel to trigger backpressure
6329        for _ in 0..OUTBOX_SOFT_QUEUE_LIMIT_FRAMES {
6330            let _ = send_outbox(&client, vec![0u8]);
6331        }
6332        assert!(outbox_backpressured(&client));
6333    }
6334
6335    #[test]
6336    fn outbox_not_backpressured_by_small_frames_under_byte_budget() {
6337        let (client, _rx) = test_client_with_capacity(0);
6338        for _ in 0..(OUTBOX_SOFT_QUEUE_LIMIT_FRAMES - 1) {
6339            let _ = send_outbox(&client, vec![0u8; 512]);
6340        }
6341        assert!(!outbox_backpressured(&client));
6342    }
6343
6344    #[test]
6345    fn outbox_backpressured_by_large_queued_bytes() {
6346        let (client, _rx) = test_client_with_capacity(0);
6347        // First frame is always allowed through, even at the byte limit, so
6348        // pending encoders can make progress when keyframes exceed the cap.
6349        let _ = send_outbox(&client, vec![0u8; OUTBOX_SOFT_QUEUE_LIMIT_BYTES]);
6350        assert!(!outbox_backpressured(&client));
6351        // A second frame pushes total bytes past the soft limit.
6352        let _ = send_outbox(&client, vec![0u8; 1]);
6353        assert!(outbox_backpressured(&client));
6354    }
6355
6356    #[test]
6357    fn outbox_not_backpressured_when_empty() {
6358        let client = test_client();
6359        assert!(!outbox_backpressured(&client));
6360    }
6361
6362    // ── browser_pacing_fps baseline ──
6363
6364    #[test]
6365    fn browser_pacing_fps_matches_display_fps_when_browser_ready() {
6366        let mut client = test_client();
6367        client.rtt_ms = 1.0;
6368        client.min_rtt_ms = 1.0;
6369        client.browser_backlog_frames = 0;
6370        client.browser_ack_ahead_frames = 0;
6371        client.browser_apply_ms = 0.0;
6372        client.goodput_bps = 1_000_000.0;
6373        client.delivery_bps = 1_000_000.0;
6374        client.display_fps = 144.0;
6375        assert!((browser_pacing_fps(&client) - 144.0).abs() < 0.01);
6376    }
6377
6378    #[test]
6379    fn browser_pacing_fps_drops_below_display_fps_when_backlogged() {
6380        let mut client = test_client();
6381        client.browser_backlog_frames = 20;
6382        let fps = browser_pacing_fps(&client);
6383        assert!(fps >= 1.0);
6384        assert!(fps < client.display_fps);
6385    }
6386
6387    // ── effective_rtt_ms ──
6388
6389    #[test]
6390    fn effective_rtt_ms_equals_path_when_queue_is_empty() {
6391        let mut client = test_client();
6392        client.rtt_ms = 1.0;
6393        client.min_rtt_ms = 1.0;
6394        client.browser_backlog_frames = 0;
6395        client.browser_ack_ahead_frames = 0;
6396        client.browser_apply_ms = 0.0;
6397        client.goodput_bps = 1_000_000.0;
6398        client.delivery_bps = 1_000_000.0;
6399        assert!((effective_rtt_ms(&client) - 1.0).abs() < 0.01);
6400    }
6401
6402    #[test]
6403    fn effective_rtt_ms_at_least_path_rtt() {
6404        let client = test_client();
6405        assert!(effective_rtt_ms(&client) >= path_rtt_ms(&client));
6406    }
6407
6408    // ── target_frame_window ──
6409
6410    #[test]
6411    fn target_frame_window_at_least_two() {
6412        let client = test_client();
6413        assert!(target_frame_window(&client) >= 2);
6414    }
6415
6416    #[test]
6417    fn target_frame_window_grows_with_probe() {
6418        let mut client = test_client();
6419        let base = target_frame_window(&client);
6420        client.probe_frames = 10.0;
6421        let probed = target_frame_window(&client);
6422        assert!(probed > base, "probe_frames should grow the window");
6423    }
6424
6425    // ── bandwidth_floor_bps ──
6426
6427    #[test]
6428    fn bandwidth_floor_bps_at_least_16k() {
6429        let mut client = test_client();
6430        client.goodput_bps = 0.0;
6431        client.delivery_bps = 0.0;
6432        assert_eq!(bandwidth_floor_bps(&client), 0.0);
6433    }
6434
6435    #[test]
6436    fn bandwidth_floor_bps_scales_with_goodput() {
6437        let mut client = test_client();
6438        client.goodput_bps = 1_000_000.0;
6439        client.delivery_bps = 1_000_000.0;
6440        let floor = bandwidth_floor_bps(&client);
6441        assert!(floor > 0.0);
6442    }
6443
6444    #[test]
6445    fn browser_ready_delivery_floor_can_drive_large_frames_to_display_fps() {
6446        let mut client = test_client();
6447        client.display_fps = 60.0;
6448        client.browser_backlog_frames = 0;
6449        client.browser_ack_ahead_frames = 0;
6450        client.browser_apply_ms = 0.2;
6451        client.goodput_bps = 3_000_000.0;
6452        client.delivery_bps = 9_500_000.0;
6453        client.last_goodput_sample_bps = 3_000_000.0;
6454        client.avg_paced_frame_bytes = 150_000.0;
6455        client.avg_preview_frame_bytes = 1_024.0;
6456        client.avg_frame_bytes = 150_000.0;
6457
6458        assert!(
6459            (pacing_fps(&client) - client.display_fps).abs() < 0.01,
6460            "browser-ready delivery floor should let large frames reach display_fps on a fast path",
6461        );
6462    }
6463
6464    // ── pacing_fps ──
6465
6466    #[test]
6467    fn pacing_fps_zero_when_no_bandwidth() {
6468        let mut client = test_client();
6469        client.goodput_bps = 0.0;
6470        client.delivery_bps = 0.0;
6471        client.last_goodput_sample_bps = 0.0;
6472        assert!(
6473            pacing_fps(&client) == 0.0,
6474            "pacing_fps should be 0 with zero bandwidth"
6475        );
6476    }
6477
6478    #[test]
6479    fn pacing_fps_reaches_display_fps_when_not_bandwidth_limited() {
6480        let mut client = test_client();
6481        client.rtt_ms = 1.0;
6482        client.min_rtt_ms = 1.0;
6483        client.browser_backlog_frames = 0;
6484        client.browser_ack_ahead_frames = 0;
6485        client.browser_apply_ms = 0.0;
6486        client.goodput_bps = 1_000_000.0;
6487        client.delivery_bps = 1_000_000.0;
6488        client.display_fps = 60.0;
6489        assert!((pacing_fps(&client) - 60.0).abs() < 0.01);
6490    }
6491
6492    // ── throughput_limited ──
6493
6494    #[test]
6495    fn throughput_limited_when_low_bandwidth() {
6496        let mut client = test_client();
6497        client.goodput_bps = 1_000.0;
6498        client.delivery_bps = 1_000.0;
6499        client.last_goodput_sample_bps = 0.0;
6500        assert!(throughput_limited(&client));
6501    }
6502
6503    #[test]
6504    fn throughput_not_limited_with_high_bandwidth() {
6505        let mut client = test_client();
6506        client.goodput_bps = 100_000_000.0;
6507        client.delivery_bps = 100_000_000.0;
6508        assert!(!throughput_limited(&client));
6509    }
6510
6511    // ── browser_pacing_fps ──
6512
6513    #[test]
6514    fn browser_pacing_fps_at_least_one() {
6515        let client = test_client();
6516        assert!(browser_pacing_fps(&client) >= 1.0);
6517    }
6518
6519    #[test]
6520    fn browser_pacing_fps_reduced_by_high_backlog() {
6521        let mut client = test_client();
6522        let normal = browser_pacing_fps(&client);
6523        client.browser_backlog_frames = 20;
6524        let backlogged = browser_pacing_fps(&client);
6525        assert!(backlogged < normal, "high backlog should reduce pacing fps");
6526    }
6527
6528    #[test]
6529    fn browser_pacing_fps_reduced_by_high_ack_ahead() {
6530        let mut client = test_client();
6531        let normal = browser_pacing_fps(&client);
6532        client.browser_ack_ahead_frames = 10;
6533        let ahead = browser_pacing_fps(&client);
6534        assert!(ahead < normal, "high ack_ahead should reduce pacing fps");
6535    }
6536
6537    // ── browser_backlog_blocked ──
6538
6539    #[test]
6540    fn browser_backlog_blocked_over_threshold() {
6541        let mut client = test_client();
6542        client.browser_backlog_frames = 9;
6543        assert!(browser_backlog_blocked(&client));
6544    }
6545
6546    #[test]
6547    fn browser_backlog_not_blocked_under_threshold() {
6548        let mut client = test_client();
6549        client.browser_backlog_frames = 8;
6550        assert!(!browser_backlog_blocked(&client));
6551    }
6552
6553    // ── byte_budget_for ──
6554
6555    #[test]
6556    fn byte_budget_for_at_least_one_frame() {
6557        let client = test_client();
6558        let budget = byte_budget_for(&client, 10.0);
6559        assert!(budget >= client.avg_frame_bytes.max(256.0) as usize);
6560    }
6561
6562    #[test]
6563    fn byte_budget_for_grows_with_time() {
6564        let client = test_client();
6565        let short = byte_budget_for(&client, 10.0);
6566        let long = byte_budget_for(&client, 1000.0);
6567        assert!(long >= short);
6568    }
6569
6570    // ── target_byte_window ──
6571
6572    #[test]
6573    fn target_byte_window_positive() {
6574        let client = test_client();
6575        assert!(target_byte_window(&client) > 0);
6576    }
6577
6578    #[test]
6579    fn target_byte_window_covers_frame_window() {
6580        let client = test_client();
6581        let byte_win = target_byte_window(&client);
6582        let frame_win = target_frame_window(&client);
6583        let min_bytes =
6584            (client.avg_paced_frame_bytes.max(256.0) * frame_win.max(2) as f32).ceil() as usize;
6585        assert!(
6586            byte_win >= min_bytes,
6587            "byte window should cover at least frame_window worth of paced frames"
6588        );
6589    }
6590
6591    // ── send_interval ──
6592
6593    #[test]
6594    fn send_interval_matches_browser_pacing() {
6595        let client = test_client();
6596        let interval = send_interval(&client);
6597        let expected = Duration::from_secs_f64(1.0 / browser_pacing_fps(&client) as f64);
6598        let diff = interval.abs_diff(expected);
6599        assert!(diff < Duration::from_micros(10));
6600    }
6601
6602    // ── preview_fps ──
6603
6604    #[test]
6605    fn preview_fps_at_least_one() {
6606        let client = test_client();
6607        assert!(preview_fps(&client) >= 1.0);
6608    }
6609
6610    // ── window_open ──
6611
6612    #[test]
6613    fn window_open_initially() {
6614        let client = test_client();
6615        assert!(window_open(&client));
6616    }
6617
6618    #[test]
6619    fn window_open_false_when_browser_blocked() {
6620        let mut client = test_client();
6621        client.browser_backlog_frames = 20;
6622        assert!(!window_open(&client));
6623    }
6624
6625    #[test]
6626    fn window_open_false_when_inflight_full() {
6627        let mut client = test_client();
6628        let target = target_frame_window(&client);
6629        fill_inflight(&mut client, target + 10, 1024);
6630        assert!(!window_open(&client));
6631    }
6632
6633    // ── lead_window_open ──
6634
6635    #[test]
6636    fn lead_window_open_no_reserve_same_as_window_open() {
6637        let client = test_client();
6638        assert_eq!(lead_window_open(&client, false), window_open(&client));
6639    }
6640
6641    #[test]
6642    fn lead_window_open_reserves_preview_slot() {
6643        let mut client = test_client();
6644        client.lead = Some(1);
6645        client.subscriptions.insert(1);
6646        let target = target_frame_window(&client);
6647        // Fill to just under target minus reserve
6648        fill_inflight(&mut client, target.saturating_sub(1), 512);
6649        // Without reserve: may still be open
6650        // With reserve: should be closed
6651        assert!(!lead_window_open(&client, true));
6652    }
6653
6654    // ── can_send_frame ──
6655
6656    #[test]
6657    fn can_send_frame_when_window_open_and_time_due() {
6658        let mut client = test_client();
6659        client.next_send_at = Instant::now() - Duration::from_millis(100);
6660        assert!(can_send_frame(&client, Instant::now(), false));
6661    }
6662
6663    #[test]
6664    fn can_send_frame_false_when_not_due() {
6665        let mut client = test_client();
6666        client.next_send_at = Instant::now() + Duration::from_secs(10);
6667        assert!(!can_send_frame(&client, Instant::now(), false));
6668    }
6669
6670    #[test]
6671    fn can_send_frame_false_when_window_closed() {
6672        let mut client = test_client();
6673        client.browser_backlog_frames = 20; // triggers browser_backlog_blocked
6674        client.next_send_at = Instant::now() - Duration::from_millis(100);
6675        assert!(!can_send_frame(&client, Instant::now(), false));
6676    }
6677
6678    // ── record_send / record_ack state transitions ──
6679
6680    #[test]
6681    fn record_send_increases_inflight() {
6682        let mut client = test_client();
6683        let now = Instant::now();
6684        assert_eq!(client.inflight_bytes, 0);
6685        assert_eq!(client.inflight_frames.len(), 0);
6686
6687        record_send(&mut client, 1000, now, true);
6688        assert_eq!(client.inflight_bytes, 1000);
6689        assert_eq!(client.inflight_frames.len(), 1);
6690
6691        record_send(&mut client, 500, now, false);
6692        assert_eq!(client.inflight_bytes, 1500);
6693        assert_eq!(client.inflight_frames.len(), 2);
6694    }
6695
6696    #[test]
6697    fn record_send_paced_advances_deadline() {
6698        let mut client = test_client();
6699        let now = Instant::now();
6700        client.next_send_at = now;
6701        record_send(&mut client, 1000, now, true);
6702        assert!(client.next_send_at > now);
6703    }
6704
6705    #[test]
6706    fn record_send_unpaced_does_not_advance_deadline() {
6707        let mut client = test_client();
6708        let now = Instant::now();
6709        let before = client.next_send_at;
6710        record_send(&mut client, 1000, now, false);
6711        assert_eq!(client.next_send_at, before);
6712    }
6713
6714    #[test]
6715    fn record_ack_decreases_inflight() {
6716        let mut client = test_client();
6717        let now = Instant::now();
6718        record_send(&mut client, 1000, now, true);
6719        record_send(&mut client, 500, now, true);
6720        assert_eq!(client.inflight_frames.len(), 2);
6721
6722        record_ack(&mut client);
6723        assert_eq!(client.inflight_frames.len(), 1);
6724        assert_eq!(client.inflight_bytes, 500);
6725    }
6726
6727    #[test]
6728    fn record_ack_on_empty_clears_bytes() {
6729        let mut client = test_client();
6730        client.inflight_bytes = 999; // stale state
6731        record_ack(&mut client);
6732        assert_eq!(client.inflight_bytes, 0);
6733    }
6734
6735    #[test]
6736    fn record_ack_updates_rtt_estimate() {
6737        let mut client = test_client();
6738        let now = Instant::now();
6739        client.inflight_frames.push_back(InFlightFrame {
6740            sent_at: now - Duration::from_millis(20),
6741            bytes: 512,
6742            paced: true,
6743        });
6744        client.inflight_bytes = 512;
6745        let old_rtt = client.rtt_ms;
6746        record_ack(&mut client);
6747        // RTT should have been updated (moved toward ~20ms from the default 50ms)
6748        assert!(
6749            (client.rtt_ms - old_rtt).abs() > 0.01,
6750            "rtt_ms should be updated after ack"
6751        );
6752    }
6753
6754    #[test]
6755    fn record_ack_paced_updates_avg_paced_frame_bytes() {
6756        let mut client = test_client();
6757        let now = Instant::now();
6758        client.inflight_frames.push_back(InFlightFrame {
6759            sent_at: now - Duration::from_millis(10),
6760            bytes: 4096,
6761            paced: true,
6762        });
6763        client.inflight_bytes = 4096;
6764        let old_avg = client.avg_paced_frame_bytes;
6765        record_ack(&mut client);
6766        // Should move toward 4096 from 1024
6767        assert!(client.avg_paced_frame_bytes > old_avg);
6768    }
6769
6770    #[test]
6771    fn record_ack_unpaced_updates_avg_preview_frame_bytes() {
6772        let mut client = test_client();
6773        let now = Instant::now();
6774        client.inflight_frames.push_back(InFlightFrame {
6775            sent_at: now - Duration::from_millis(10),
6776            bytes: 8192,
6777            paced: false,
6778        });
6779        client.inflight_bytes = 8192;
6780        let old_avg = client.avg_preview_frame_bytes;
6781        record_ack(&mut client);
6782        assert!(client.avg_preview_frame_bytes > old_avg);
6783    }
6784
6785    // ── Session::pty_list_msg format ──
6786
6787    #[test]
6788    fn pty_list_msg_empty_session() {
6789        let sess = Session::new();
6790        let msg = sess.pty_list_msg();
6791        assert_eq!(msg[0], S2C_LIST);
6792        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 0);
6793        assert_eq!(msg.len(), 3);
6794    }
6795
6796    #[test]
6797    fn pty_list_msg_includes_tags() {
6798        let _sess = Session::new();
6799        // Insert minimal Pty entries. We can't call spawn_pty, so build
6800        // a mock-like Pty with a stub driver. Instead, directly insert
6801        // into the HashMap using an unsafe-free approach: just build the
6802        // wire message by hand and verify against a known layout.
6803        //
6804        // The wire format is: [S2C_LIST] [count:u16le] [id:u16le tag_len:u16le tag_bytes]...
6805        //
6806        // Since we can't easily construct a Pty without forking, verify
6807        // the format by constructing the expected bytes and comparing.
6808        let tag1 = "shell";
6809        let tag2 = "build";
6810
6811        // Expected wire for ptys {1 => "shell", 3 => "build"} sorted by id:
6812        let mut expected = vec![S2C_LIST];
6813        expected.extend_from_slice(&2u16.to_le_bytes());
6814        // id=1
6815        expected.extend_from_slice(&1u16.to_le_bytes());
6816        expected.extend_from_slice(&(tag1.len() as u16).to_le_bytes());
6817        expected.extend_from_slice(tag1.as_bytes());
6818        // id=3
6819        expected.extend_from_slice(&3u16.to_le_bytes());
6820        expected.extend_from_slice(&(tag2.len() as u16).to_le_bytes());
6821        expected.extend_from_slice(tag2.as_bytes());
6822
6823        // Verify our expected format starts with S2C_LIST and has correct count
6824        assert_eq!(expected[0], S2C_LIST);
6825        assert_eq!(u16::from_le_bytes([expected[1], expected[2]]), 2);
6826        // Verify tags are embedded
6827        let msg_str = String::from_utf8_lossy(&expected);
6828        assert!(msg_str.contains("shell"));
6829        assert!(msg_str.contains("build"));
6830    }
6831
6832    // ── can_send_preview / record_preview_send ──
6833
6834    #[test]
6835    fn can_send_preview_true_when_due() {
6836        let mut client = test_client();
6837        let now = Instant::now();
6838        client
6839            .preview_next_send_at
6840            .insert(5, now - Duration::from_millis(100));
6841        assert!(can_send_preview(&client, 5, now));
6842    }
6843
6844    #[test]
6845    fn can_send_preview_false_when_not_due() {
6846        let mut client = test_client();
6847        let now = Instant::now();
6848        client
6849            .preview_next_send_at
6850            .insert(5, now + Duration::from_secs(10));
6851        assert!(!can_send_preview(&client, 5, now));
6852    }
6853
6854    #[test]
6855    fn can_send_preview_false_when_window_closed() {
6856        let mut client = test_client();
6857        client.browser_backlog_frames = 20;
6858        let now = Instant::now();
6859        assert!(!can_send_preview(&client, 5, now));
6860    }
6861
6862    #[test]
6863    fn can_send_preview_true_for_unseen_pid() {
6864        let client = test_client();
6865        let now = Instant::now();
6866        // No entry in preview_next_send_at means deadline defaults to now
6867        assert!(can_send_preview(&client, 99, now));
6868    }
6869
6870    #[test]
6871    fn record_preview_send_sets_future_deadline() {
6872        let mut client = test_client();
6873        let now = Instant::now();
6874        record_preview_send(&mut client, 5, now);
6875        let deadline = client.preview_next_send_at.get(&5).unwrap();
6876        assert!(*deadline > now);
6877    }
6878
6879    #[test]
6880    fn record_preview_send_successive_calls_advance() {
6881        let mut client = test_client();
6882        let now = Instant::now();
6883        record_preview_send(&mut client, 5, now);
6884        let first = *client.preview_next_send_at.get(&5).unwrap();
6885        record_preview_send(&mut client, 5, first);
6886        let second = *client.preview_next_send_at.get(&5).unwrap();
6887        assert!(second > first, "successive sends should advance deadline");
6888    }
6889
6890    // ── congestion control end-to-end properties ──
6891    //
6892    // These tests encode the two goals of the congestion controller:
6893    //   1. Browser-ready, well-provisioned path → full display FPS, minimal added latency
6894    //   2. Bottleneck                           → lowest sustainable FPS, fast recovery when pipe clears
6895    //
6896    // Some tests assert desired future behaviour and currently FAIL due to
6897    // known issues (min_rtt contamination, lead_floor dominating byte window).
6898    // They are marked with a comment so they are easy to find when fixing.
6899
6900    /// Return a client in ideal low-latency, high-bandwidth conditions:
6901    /// browser ready, abundant bandwidth, and tiny RTT. The normal pacing path
6902    /// should still reach display_fps.
6903    fn browser_ready_high_bandwidth_client() -> ClientState {
6904        let mut c = test_client();
6905        c.display_fps = 120.0;
6906        c.rtt_ms = 1.0;
6907        c.min_rtt_ms = 1.0;
6908        c.goodput_bps = 50_000_000.0;
6909        c.delivery_bps = 50_000_000.0;
6910        c.last_goodput_sample_bps = 50_000_000.0;
6911        c.avg_paced_frame_bytes = 30_000.0;
6912        c.avg_preview_frame_bytes = 1_024.0;
6913        c.avg_frame_bytes = 30_000.0;
6914        c.browser_apply_ms = 0.3;
6915        c
6916    }
6917
6918    /// Return a client that has converged to a clearly congested state:
6919    /// ~10× min_rtt inflation, low goodput.
6920    fn congested_client() -> ClientState {
6921        let mut c = test_client();
6922        c.display_fps = 120.0;
6923        c.rtt_ms = 500.0;
6924        c.min_rtt_ms = 40.0;
6925        c.goodput_bps = 200_000.0;
6926        c.delivery_bps = 150_000.0;
6927        c.last_goodput_sample_bps = 200_000.0;
6928        c.avg_paced_frame_bytes = 50_000.0;
6929        c.avg_preview_frame_bytes = 1_024.0;
6930        c.avg_frame_bytes = 50_000.0;
6931        c.goodput_jitter_bps = 50_000.0;
6932        c.max_goodput_jitter_bps = 200_000.0;
6933        c.browser_apply_ms = 1.0;
6934        c
6935    }
6936
6937    /// Simulate one ACK: insert a frame with the given RTT into inflight and
6938    /// call record_ack.  Forces a goodput-window sample each call so that
6939    /// goodput estimates respond within a few calls.
6940    fn sim_ack(client: &mut ClientState, bytes: usize, rtt_ms: f32) {
6941        let sent_at = Instant::now() - Duration::from_millis(rtt_ms as u64);
6942        client.inflight_bytes += bytes;
6943        client.inflight_frames.push_back(InFlightFrame {
6944            sent_at,
6945            bytes,
6946            paced: true,
6947        });
6948        // Age the goodput window so record_ack always emits a sample.
6949        client.goodput_window_start = Instant::now() - Duration::from_millis(25);
6950        record_ack(client);
6951    }
6952
6953    fn sim_acks(client: &mut ClientState, n: usize, bytes: usize, rtt_ms: f32) {
6954        for _ in 0..n {
6955            sim_ack(client, bytes, rtt_ms);
6956        }
6957    }
6958
6959    // ── property: full FPS on a browser-ready path ──
6960
6961    #[test]
6962    fn browser_ready_high_bandwidth_client_uses_full_display_fps() {
6963        let client = browser_ready_high_bandwidth_client();
6964        assert!(
6965            (pacing_fps(&client) - client.display_fps).abs() < 0.01,
6966            "pacing_fps {} should equal display_fps {} when browser is ready and bandwidth is abundant",
6967            pacing_fps(&client),
6968            client.display_fps,
6969        );
6970    }
6971
6972    #[test]
6973    fn browser_ready_high_bandwidth_client_send_interval_within_one_frame() {
6974        let client = browser_ready_high_bandwidth_client();
6975        let interval_ms = send_interval(&client).as_secs_f32() * 1000.0;
6976        let frame_ms = 1000.0 / client.display_fps;
6977        assert!(
6978            interval_ms <= frame_ms + 0.1,
6979            "send_interval {interval_ms:.2}ms exceeds one frame ({frame_ms:.2}ms) when browser is ready"
6980        );
6981    }
6982
6983    // ── property: degraded FPS when bottlenecked ──
6984
6985    #[test]
6986    fn congested_pipe_reduces_pacing_fps_substantially() {
6987        let client = congested_client();
6988        let fps = pacing_fps(&client);
6989        assert!(
6990            fps < client.display_fps * 0.5,
6991            "pacing_fps {fps:.0} should be well below display_fps {} when congested",
6992            client.display_fps,
6993        );
6994    }
6995
6996    #[test]
6997    fn congested_pipe_is_throughput_limited() {
6998        let client = congested_client();
6999        assert!(
7000            throughput_limited(&client),
7001            "congested client must be recognised as throughput-limited"
7002        );
7003    }
7004
7005    // ── property: byte window should stay near BDP ──
7006    //
7007    // KNOWN FAILING: lead_floor in target_byte_window overrides the BDP
7008    // budget when avg_paced_frame_bytes is large.  Fix: cap lead_floor.
7009
7010    #[test]
7011    fn byte_window_bounded_near_bdp_when_congested() {
7012        let client = congested_client();
7013        // BDP at the unloaded path RTT.
7014        let bdp = client.goodput_bps * (path_rtt_ms(&client) / 1_000.0);
7015        let window = target_byte_window(&client);
7016        assert!(
7017            window < bdp as usize * 8,
7018            "byte window {window}B is {:.1}× BDP ({bdp:.0}B); \
7019             expected ≤ 8× — lead_floor may be dominating",
7020            window as f32 / bdp.max(1.0),
7021        );
7022    }
7023
7024    // ── property: min_rtt must not drift upward under congestion ──
7025    //
7026    // KNOWN FAILING: the `min_rtt_ms * 0.999 + rtt_ms * 0.001` update
7027    // bleeds queued RTT into min_rtt.
7028
7029    #[test]
7030    fn min_rtt_not_contaminated_by_congested_rtts() {
7031        let mut client = test_client();
7032        client.display_fps = 120.0;
7033        client.rtt_ms = 40.0;
7034        client.min_rtt_ms = 40.0;
7035        client.goodput_bps = 2_000_000.0;
7036        client.delivery_bps = 2_000_000.0;
7037        client.avg_paced_frame_bytes = 30_000.0;
7038        client.avg_preview_frame_bytes = 1_024.0;
7039        let original_min = client.min_rtt_ms;
7040
7041        // 200 ACKs arriving with 500ms RTT (severe congestion).
7042        sim_acks(&mut client, 200, 30_000, 500.0);
7043
7044        assert!(
7045            client.min_rtt_ms < original_min * 2.0,
7046            "min_rtt drifted from {original_min}ms to {:.1}ms after 200 congested ACKs",
7047            client.min_rtt_ms,
7048        );
7049    }
7050
7051    // ── property: fast recovery when congestion clears ──
7052
7053    #[test]
7054    fn delivery_bps_rises_quickly_when_congestion_clears() {
7055        let mut client = congested_client();
7056        let before = client.delivery_bps;
7057
7058        // 10 ACKs at low latency / high throughput.
7059        sim_acks(&mut client, 10, 30_000, 40.0);
7060
7061        assert!(
7062            client.delivery_bps > before * 2.0,
7063            "delivery_bps {:.0} should more than double from {before:.0} after 10 fast ACKs",
7064            client.delivery_bps,
7065        );
7066    }
7067
7068    #[test]
7069    fn pacing_fps_recovers_after_congestion_clears() {
7070        let mut client = congested_client();
7071
7072        // Use window-saturated rounds: fill the window with frames, age the
7073        // goodput window once, then ACK all.  The first ACK each round emits
7074        // a sample; the remaining target-1 ACKs carry over into the next
7075        // window, so sample throughput grows as target grows — mimicking a
7076        // real link where the sender keeps the pipe full across one RTT.
7077        for _ in 0..40 {
7078            let target = target_frame_window(&client).max(2);
7079            for _ in 0..target {
7080                let sent_at = Instant::now() - Duration::from_millis(40);
7081                client.inflight_bytes += 30_000;
7082                client.inflight_frames.push_back(InFlightFrame {
7083                    sent_at,
7084                    bytes: 30_000,
7085                    paced: true,
7086                });
7087            }
7088            client.goodput_window_start = Instant::now() - Duration::from_millis(25);
7089            for _ in 0..target {
7090                record_ack(&mut client);
7091            }
7092        }
7093
7094        let fps = pacing_fps(&client);
7095        assert!(
7096            fps > client.display_fps * 0.7,
7097            "pacing_fps {fps:.0} didn't recover toward display_fps {} \
7098             after window-saturated rounds at low RTT",
7099            client.display_fps,
7100        );
7101    }
7102
7103    #[test]
7104    fn rtt_estimate_drops_quickly_when_congestion_clears() {
7105        let mut client = test_client();
7106        client.rtt_ms = 500.0;
7107        client.min_rtt_ms = 40.0;
7108        client.goodput_bps = 2_000_000.0;
7109        client.avg_paced_frame_bytes = 30_000.0;
7110        client.avg_preview_frame_bytes = 1_024.0;
7111
7112        // The asymmetric EWMA uses rise=0.125, fall=0.25, so rtt_ms drops
7113        // at fall_alpha=0.25 per sample toward the new low.
7114        sim_acks(&mut client, 10, 30_000, 40.0);
7115
7116        assert!(
7117            client.rtt_ms < 300.0,
7118            "rtt_ms {:.0}ms did not fall fast enough after congestion cleared",
7119            client.rtt_ms,
7120        );
7121    }
7122
7123    // ── property: probing ──
7124
7125    #[test]
7126    fn probe_collapses_immediately_on_queue_delay() {
7127        let mut client = test_client();
7128        client.display_fps = 120.0;
7129        client.rtt_ms = 40.0;
7130        client.min_rtt_ms = 40.0;
7131        client.goodput_bps = 5_000_000.0;
7132        client.delivery_bps = 5_000_000.0;
7133        client.last_goodput_sample_bps = 5_000_000.0;
7134        client.avg_paced_frame_bytes = 10_000.0;
7135        client.avg_preview_frame_bytes = 1_024.0;
7136        client.probe_frames = 10.0;
7137
7138        // ACKs arriving with high RTT signal queue buildup.
7139        sim_acks(&mut client, 5, 10_000, 600.0);
7140
7141        assert!(
7142            client.probe_frames < 5.0,
7143            "probe_frames {:.1} should have collapsed on queue delay signal",
7144            client.probe_frames,
7145        );
7146    }
7147
7148    #[test]
7149    fn probe_grows_when_window_saturated_with_clean_rtt() {
7150        let mut client = test_client();
7151        client.display_fps = 120.0;
7152        client.rtt_ms = 40.0;
7153        client.min_rtt_ms = 40.0;
7154        client.goodput_bps = 5_000_000.0;
7155        client.delivery_bps = 5_000_000.0;
7156        client.last_goodput_sample_bps = 5_000_000.0;
7157        client.avg_paced_frame_bytes = 10_000.0;
7158        client.avg_preview_frame_bytes = 1_024.0;
7159        client.goodput_jitter_bps = 0.0;
7160        client.max_goodput_jitter_bps = 0.0;
7161        client.probe_frames = 0.0;
7162
7163        // Saturate inflight so window_saturated returns true during acks.
7164        let target = target_frame_window(&client);
7165        for _ in 0..target {
7166            let sent_at = Instant::now() - Duration::from_millis(40);
7167            client.inflight_bytes += 10_000;
7168            client.inflight_frames.push_back(InFlightFrame {
7169                sent_at,
7170                bytes: 10_000,
7171                paced: true,
7172            });
7173        }
7174
7175        // Ack one frame with clean RTT.  One saturated ACK is sufficient to
7176        // verify the property: as probe_frames increments, target_frame_window
7177        // grows, so the remaining (target-1) frames would fall below the 90%
7178        // threshold and trigger gentle decay.  The property under test is that
7179        // *receiving an ACK while window-saturated* increments probe_frames —
7180        // not that it stays incremented across subsequent unsaturated ACKs.
7181        // Also: do NOT age the goodput window — that would emit a per-frame
7182        // sample far below goodput_bps, spiking jitter and collapsing probe.
7183        record_ack(&mut client);
7184
7185        assert!(
7186            client.probe_frames > 0.0,
7187            "probe_frames should grow when window-saturated with clean RTT"
7188        );
7189    }
7190
7191    // ── property: frame window larger on high-latency links ──
7192
7193    #[test]
7194    fn frame_window_larger_on_high_latency_link() {
7195        let mut lo = test_client();
7196        lo.display_fps = 120.0;
7197        lo.rtt_ms = 10.0;
7198        lo.min_rtt_ms = 10.0;
7199        lo.goodput_bps = 5_000_000.0;
7200        lo.delivery_bps = 5_000_000.0;
7201        lo.avg_paced_frame_bytes = 10_000.0;
7202        lo.avg_preview_frame_bytes = 1_024.0;
7203
7204        let mut hi = test_client();
7205        hi.display_fps = 120.0;
7206        hi.rtt_ms = 200.0;
7207        hi.min_rtt_ms = 200.0;
7208        hi.goodput_bps = 5_000_000.0;
7209        hi.delivery_bps = 5_000_000.0;
7210        hi.avg_paced_frame_bytes = 10_000.0;
7211        hi.avg_preview_frame_bytes = 1_024.0;
7212
7213        let lo_win = target_frame_window(&lo);
7214        let hi_win = target_frame_window(&hi);
7215        assert!(
7216            hi_win > lo_win,
7217            "high-latency link ({hi_win}f) should need more frames in flight \
7218             than low-latency ({lo_win}f)"
7219        );
7220    }
7221
7222    // ── property: small-frame byte window allows pipelining ──
7223
7224    #[test]
7225    fn small_frame_byte_window_enables_pipelining() {
7226        // Tiny terminal frames (~1KB) with a stale congested RTT and low
7227        // goodput estimate (stop-and-wait artifact): byte window must be at
7228        // least target_frame_window × frame_bytes so the sender can pipeline
7229        // rather than stay stuck in stop-and-wait.
7230        let mut client = test_client();
7231        client.display_fps = 120.0;
7232        client.rtt_ms = 165.0;
7233        client.min_rtt_ms = 8.0;
7234        client.goodput_bps = 11_000.0; // stop-and-wait artifact
7235        client.delivery_bps = 6_800.0;
7236        client.last_goodput_sample_bps = 11_000.0;
7237        client.avg_paced_frame_bytes = 1_120.0;
7238        client.avg_preview_frame_bytes = 1_024.0;
7239        client.goodput_jitter_bps = 4_300.0;
7240        client.max_goodput_jitter_bps = 6_500.0;
7241
7242        let window = target_byte_window(&client);
7243        let frames = target_frame_window(&client);
7244        let pipeline = frames * 1_120;
7245
7246        assert!(
7247            window >= pipeline,
7248            "byte window {window}B should be >= pipeline ({frames}f × 1120B = {pipeline}B) \
7249             so small frames can pipeline across the RTT"
7250        );
7251    }
7252
7253    #[test]
7254    fn large_frame_byte_window_bounded_by_one_frame_floor() {
7255        // With large frames (50KB), pipelining the full frame window (5×50KB=250KB)
7256        // would be many multiples of BDP.  Byte window should fall back to
7257        // the one-frame floor so the BDP budget governs.
7258        let mut client = test_client();
7259        client.display_fps = 120.0;
7260        client.rtt_ms = 165.0;
7261        client.min_rtt_ms = 8.0;
7262        client.goodput_bps = 11_000.0;
7263        client.delivery_bps = 6_800.0;
7264        client.last_goodput_sample_bps = 11_000.0;
7265        client.avg_paced_frame_bytes = 50_000.0; // large frame
7266        client.avg_preview_frame_bytes = 1_024.0;
7267        client.goodput_jitter_bps = 0.0;
7268        client.max_goodput_jitter_bps = 0.0;
7269
7270        let window = target_byte_window(&client);
7271        let frames = target_frame_window(&client);
7272        let pipeline = frames.saturating_mul(50_000);
7273
7274        assert!(
7275            window < pipeline,
7276            "byte window {window}B should be < full pipeline {pipeline}B \
7277             ({frames}f × 50KB) — large frames must use one-frame floor"
7278        );
7279        assert!(
7280            window >= 50_000,
7281            "byte window {window}B must be at least one frame (50KB)"
7282        );
7283    }
7284
7285    // ── property: preview reservation applies uniformly ──
7286
7287    #[test]
7288    fn preview_reservation_applies_even_on_low_latency_high_bandwidth_links() {
7289        let mut client = browser_ready_high_bandwidth_client();
7290        client.lead = Some(1);
7291        client.subscriptions.insert(1);
7292        let target = target_frame_window(&client);
7293        fill_inflight(&mut client, target.saturating_sub(1), 512);
7294        assert!(
7295            !lead_window_open(&client, true),
7296            "preview reservation should apply uniformly for lead clients"
7297        );
7298    }
7299
7300    // ── property: blip recovery on healthy paths ──
7301
7302    #[test]
7303    fn probe_recovers_on_healthy_path_after_blip() {
7304        let mut client = browser_ready_high_bandwidth_client();
7305        client.probe_frames = 8.0;
7306
7307        // Blip: 3 ACKs with inflated RTT crush probes.
7308        sim_acks(&mut client, 3, 30_000, 200.0);
7309        let post_blip = client.probe_frames;
7310        assert!(
7311            post_blip < 4.0,
7312            "probe_frames {post_blip:.1} should have dropped after blip"
7313        );
7314
7315        // Reset browser metrics to healthy (browser cleared backlog).
7316        client.browser_backlog_frames = 0;
7317        client.browser_ack_ahead_frames = 0;
7318        client.browser_apply_ms = 0.3;
7319
7320        // Recovery: 20 healthy ACKs at low RTT on an underfilled path.
7321        sim_acks(&mut client, 20, 30_000, 1.0);
7322
7323        assert!(
7324            client.probe_frames > post_blip,
7325            "probe_frames {:.1} should have recovered from {post_blip:.1} after healthy ACKs",
7326            client.probe_frames,
7327        );
7328    }
7329
7330    #[test]
7331    fn jitter_decays_fast_on_browser_ready_path() {
7332        let mut client = browser_ready_high_bandwidth_client();
7333
7334        // Inject elevated jitter (simulating post-blip state).
7335        client.max_goodput_jitter_bps = client.goodput_bps * 0.4;
7336        client.goodput_jitter_bps = client.goodput_bps * 0.3;
7337        let initial_jitter = client.max_goodput_jitter_bps;
7338
7339        // 10 healthy ACKs on a browser-ready path.
7340        sim_acks(&mut client, 10, 30_000, 1.0);
7341
7342        assert!(
7343            client.max_goodput_jitter_bps < initial_jitter * 0.5,
7344            "max_goodput_jitter_bps {:.0} should have decayed below {:.0} \
7345             (50% of initial {initial_jitter:.0}) after 10 healthy ACKs on a ready path",
7346            client.max_goodput_jitter_bps,
7347            initial_jitter * 0.5,
7348        );
7349    }
7350
7351    #[test]
7352    fn byte_budget_uses_floor_when_goodput_depressed() {
7353        let mut client = browser_ready_high_bandwidth_client();
7354        client.goodput_bps = 100_000.0;
7355
7356        let budget = byte_budget_for(&client, 100.0);
7357        let floor_budget = (bandwidth_floor_bps(&client) * 100.0 / 1_000.0).ceil() as usize;
7358
7359        assert!(
7360            budget >= floor_budget,
7361            "byte_budget {budget} should be at least bandwidth_floor-based {floor_budget} \
7362             when goodput_bps is depressed but delivery_bps is high"
7363        );
7364    }
7365
7366    #[test]
7367    fn probe_floor_maintained_under_congestion_signal() {
7368        let mut client = test_client();
7369        client.display_fps = 120.0;
7370        client.rtt_ms = 40.0;
7371        client.min_rtt_ms = 40.0;
7372        client.goodput_bps = 5_000_000.0;
7373        client.delivery_bps = 5_000_000.0;
7374        client.last_goodput_sample_bps = 5_000_000.0;
7375        client.avg_paced_frame_bytes = 10_000.0;
7376        client.avg_preview_frame_bytes = 1_024.0;
7377        client.probe_frames = 10.0;
7378
7379        // Many ACKs with high RTT: probes should not drop below the floor.
7380        sim_acks(&mut client, 20, 10_000, 600.0);
7381
7382        assert!(
7383            client.probe_frames >= 1.0,
7384            "probe_frames {:.1} should not drop below the floor of 1.0",
7385            client.probe_frames,
7386        );
7387    }
7388
7389    // ── parse_terminal_queries ──
7390
7391    #[test]
7392    fn parse_tq_da1_bare() {
7393        let results = parse_terminal_queries(b"\x1b[c", (24, 80), (0, 0));
7394        assert_eq!(results.len(), 1);
7395        assert!(results[0].starts_with("\x1b[?64;"));
7396    }
7397
7398    #[test]
7399    fn parse_tq_da1_with_zero_param() {
7400        let results = parse_terminal_queries(b"\x1b[0c", (24, 80), (0, 0));
7401        assert_eq!(results.len(), 1);
7402        assert!(results[0].starts_with("\x1b[?64;"));
7403    }
7404
7405    #[test]
7406    fn parse_tq_dsr_cursor_position() {
7407        let results = parse_terminal_queries(b"\x1b[6n", (24, 80), (5, 10));
7408        assert_eq!(results.len(), 1);
7409        assert_eq!(results[0], "\x1b[6;11R");
7410    }
7411
7412    #[test]
7413    fn parse_tq_dsr_status() {
7414        let results = parse_terminal_queries(b"\x1b[5n", (24, 80), (0, 0));
7415        assert_eq!(results.len(), 1);
7416        assert_eq!(results[0], "\x1b[0n");
7417    }
7418
7419    #[test]
7420    fn parse_tq_window_size_cells() {
7421        let results = parse_terminal_queries(b"\x1b[18t", (24, 80), (0, 0));
7422        assert_eq!(results.len(), 1);
7423        assert_eq!(results[0], "\x1b[8;24;80t");
7424    }
7425
7426    #[test]
7427    fn parse_tq_window_size_pixels() {
7428        let results = parse_terminal_queries(b"\x1b[14t", (30, 100), (0, 0));
7429        assert_eq!(results.len(), 1);
7430        assert_eq!(results[0], "\x1b[4;480;800t");
7431    }
7432
7433    #[test]
7434    fn parse_tq_multiple_queries() {
7435        let data = b"\x1b[c\x1b[6n\x1b[5n";
7436        let results = parse_terminal_queries(data, (24, 80), (2, 3));
7437        assert_eq!(results.len(), 3);
7438        assert!(results[0].starts_with("\x1b[?64;"));
7439        assert_eq!(results[1], "\x1b[3;4R");
7440        assert_eq!(results[2], "\x1b[0n");
7441    }
7442
7443    #[test]
7444    fn parse_tq_question_mark_sequences_skipped() {
7445        let results = parse_terminal_queries(b"\x1b[?1h", (24, 80), (0, 0));
7446        assert!(results.is_empty());
7447    }
7448
7449    #[test]
7450    fn parse_tq_unknown_final_byte_ignored() {
7451        let results = parse_terminal_queries(b"\x1b[42z", (24, 80), (0, 0));
7452        assert!(results.is_empty());
7453    }
7454
7455    #[test]
7456    fn parse_tq_empty_input() {
7457        let results = parse_terminal_queries(b"", (24, 80), (0, 0));
7458        assert!(results.is_empty());
7459    }
7460
7461    #[test]
7462    fn parse_tq_plain_text_no_csi() {
7463        let results = parse_terminal_queries(b"hello world", (24, 80), (0, 0));
7464        assert!(results.is_empty());
7465    }
7466
7467    #[test]
7468    fn parse_tq_interleaved_with_text() {
7469        let results = parse_terminal_queries(b"abc\x1b[cdef\x1b[6n", (24, 80), (1, 2));
7470        assert_eq!(results.len(), 2);
7471    }
7472
7473    // ── parse_terminal_queries: OSC ──
7474
7475    #[test]
7476    fn parse_tq_osc11_background_color_bel() {
7477        let results = parse_terminal_queries(b"\x1b]11;?\x07", (24, 80), (0, 0));
7478        assert_eq!(results.len(), 1);
7479        assert_eq!(results[0], "\x1b]11;rgb:0000/0000/0000\x1b\\");
7480    }
7481
7482    #[test]
7483    fn parse_tq_osc11_background_color_st() {
7484        let results = parse_terminal_queries(b"\x1b]11;?\x1b\\", (24, 80), (0, 0));
7485        assert_eq!(results.len(), 1);
7486        assert_eq!(results[0], "\x1b]11;rgb:0000/0000/0000\x1b\\");
7487    }
7488
7489    #[test]
7490    fn parse_tq_osc10_foreground_color() {
7491        let results = parse_terminal_queries(b"\x1b]10;?\x07", (24, 80), (0, 0));
7492        assert_eq!(results.len(), 1);
7493        assert_eq!(results[0], "\x1b]10;rgb:ffff/ffff/ffff\x1b\\");
7494    }
7495
7496    #[test]
7497    fn parse_tq_osc4_palette_color_0() {
7498        let results = parse_terminal_queries(b"\x1b]4;0;?\x07", (24, 80), (0, 0));
7499        assert_eq!(results.len(), 1);
7500        assert_eq!(results[0], "\x1b]4;0;rgb:0000/0000/0000\x1b\\");
7501    }
7502
7503    #[test]
7504    fn parse_tq_osc4_palette_color_1() {
7505        let results = parse_terminal_queries(b"\x1b]4;1;?\x07", (24, 80), (0, 0));
7506        assert_eq!(results.len(), 1);
7507        assert_eq!(results[0], "\x1b]4;1;rgb:8080/0000/0000\x1b\\");
7508    }
7509
7510    #[test]
7511    fn parse_tq_osc_mixed_with_csi() {
7512        let results =
7513            parse_terminal_queries(b"\x1b]11;?\x07\x1b[c\x1b]4;0;?\x07", (24, 80), (0, 0));
7514        assert_eq!(results.len(), 3);
7515        assert!(results[0].starts_with("\x1b]11;"));
7516        assert!(results[1].starts_with("\x1b[?64;"));
7517        assert!(results[2].starts_with("\x1b]4;0;"));
7518    }
7519
7520    // ── build_search_results_msg ──
7521
7522    #[test]
7523    fn search_results_empty() {
7524        let msg = build_search_results_msg(42, &[]);
7525        assert_eq!(msg[0], S2C_SEARCH_RESULTS);
7526        assert_eq!(u16::from_le_bytes([msg[1], msg[2]]), 42);
7527        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 0);
7528        assert_eq!(msg.len(), 5);
7529    }
7530
7531    #[test]
7532    fn search_results_single() {
7533        let results = vec![SearchResultRow {
7534            pty_id: 7,
7535            score: 100,
7536            primary_source: 1,
7537            matched_sources: 3,
7538            context: "hello".into(),
7539            scroll_offset: Some(42),
7540        }];
7541        let msg = build_search_results_msg(1, &results);
7542        assert_eq!(msg[0], S2C_SEARCH_RESULTS);
7543        assert_eq!(u16::from_le_bytes([msg[3], msg[4]]), 1);
7544        let pty_id = u16::from_le_bytes([msg[5], msg[6]]);
7545        assert_eq!(pty_id, 7);
7546        let score = u32::from_le_bytes([msg[7], msg[8], msg[9], msg[10]]);
7547        assert_eq!(score, 100);
7548        assert_eq!(msg[11], 1);
7549        assert_eq!(msg[12], 3);
7550        let scroll = u32::from_le_bytes([msg[13], msg[14], msg[15], msg[16]]);
7551        assert_eq!(scroll, 42);
7552        let ctx_len = u16::from_le_bytes([msg[17], msg[18]]) as usize;
7553        assert_eq!(ctx_len, 5);
7554        assert_eq!(&msg[19..19 + ctx_len], b"hello");
7555    }
7556
7557    #[test]
7558    fn search_results_none_scroll_offset() {
7559        let results = vec![SearchResultRow {
7560            pty_id: 1,
7561            score: 0,
7562            primary_source: 0,
7563            matched_sources: 0,
7564            context: String::new(),
7565            scroll_offset: None,
7566        }];
7567        let msg = build_search_results_msg(0, &results);
7568        let scroll = u32::from_le_bytes([msg[13], msg[14], msg[15], msg[16]]);
7569        assert_eq!(scroll, u32::MAX);
7570    }
7571
7572    // ── allocate_pty_id ──
7573
7574    #[test]
7575    fn allocate_pty_id_empty_session() {
7576        let mut sess = Session::new();
7577        assert_eq!(sess.allocate_pty_id(0), Some(1));
7578    }
7579
7580    #[test]
7581    fn allocate_pty_id_rotates() {
7582        let mut sess = Session::new();
7583        // Sequential allocations return increasing IDs (not always 1).
7584        assert_eq!(sess.allocate_pty_id(0), Some(1));
7585        assert_eq!(sess.allocate_pty_id(0), Some(2));
7586        assert_eq!(sess.allocate_pty_id(0), Some(3));
7587    }
7588
7589    #[test]
7590    fn allocate_pty_id_wraps_at_max() {
7591        let mut sess = Session::new();
7592        sess.next_pty_id = u16::MAX;
7593        assert_eq!(sess.allocate_pty_id(0), Some(u16::MAX));
7594        // Next allocation wraps to 1.
7595        assert_eq!(sess.allocate_pty_id(0), Some(1));
7596    }
7597
7598    // ── try_send_update ──
7599
7600    #[test]
7601    fn try_send_no_change() {
7602        let mut client = test_client();
7603        let frame = sample_frame("x");
7604        let now = Instant::now();
7605        let outcome = try_send_update(&mut client, 1, frame, None, now, false);
7606        assert!(matches!(outcome, SendOutcome::NoChange));
7607    }
7608
7609    #[test]
7610    fn try_send_sent() {
7611        let (mut client, _rx) = test_client_with_capacity(8);
7612        let frame = sample_frame("x");
7613        let now = Instant::now();
7614        let outcome = try_send_update(
7615            &mut client,
7616            1,
7617            frame.clone(),
7618            Some(vec![1, 2, 3]),
7619            now,
7620            true,
7621        );
7622        assert!(matches!(outcome, SendOutcome::Sent));
7623        assert!(client.last_sent.contains_key(&1));
7624    }
7625
7626    #[test]
7627    fn try_send_backpressured_on_disconnect() {
7628        let (mut client, rx) = test_client_with_capacity(0);
7629        let frame = sample_frame("x");
7630        let now = Instant::now();
7631        // Drop the receiver to simulate a disconnected client.
7632        drop(rx);
7633        let outcome = try_send_update(
7634            &mut client,
7635            1,
7636            frame.clone(),
7637            Some(vec![1, 2, 3]),
7638            now,
7639            true,
7640        );
7641        assert!(matches!(outcome, SendOutcome::Backpressured));
7642        assert!(
7643            client.last_sent.contains_key(&1),
7644            "last_sent should advance even on disconnect"
7645        );
7646    }
7647}