retach 0.10.0

Persistent terminal sessions with native scrollback passthrough
Documentation
//! Leaf module of shared relay primitives depended on by `session_bridge`,
//! `session_relay`, and `session_setup` so those siblings never import back up
//! from the orchestrator.

use crate::protocol::{self, ServerMsg};
use retach::screen::{AnsiRenderer, Screen};
use std::sync::{Arc, Mutex as StdMutex};
use tokio::io::AsyncWriteExt;

/// Minimum interval between consecutive screen renders to the client.
/// 16ms ≈ 60fps — fast enough for smooth animation (progress bars, htop)
/// while preventing CPU waste from rendering every PTY read (1000s/sec).
pub(super) const RENDER_THROTTLE: std::time::Duration = std::time::Duration::from_millis(16);

/// Prepend passthrough escape sequences to the rendered screen data so they
/// are sent as a single `ScreenUpdate` write.  This avoids the intermediate
/// `flush()` that `Passthrough` messages trigger on the client, which can cause
/// rendering glitches in terminals like Blink (e.g. `\e[3J` clearing the
/// viewport before the new screen content arrives).
pub(super) fn prepend_passthrough(passthrough: Vec<Vec<u8>>, render_data: Vec<u8>) -> Vec<u8> {
    if passthrough.is_empty() {
        return render_data;
    }
    let total: usize = passthrough.iter().map(|c| c.len()).sum::<usize>() + render_data.len();
    let mut combined = Vec::with_capacity(total);
    for chunk in passthrough {
        combined.extend_from_slice(&chunk);
    }
    combined.extend_from_slice(&render_data);
    combined
}

/// Lock a `StdMutex`, recovering from poisoning rather than failing.
///
/// A poisoned screen mutex (e.g. a panic in the persistent reader's VTE
/// processing) would otherwise make every reconnect fail forever.  Recovering
/// the guard via `into_inner()` lets the relay render a possibly-torn screen,
/// which is far better than a permanently dead-but-unreapable session.  The
/// returned `Result` is kept so callers stay `?`-friendly.
pub(super) fn lock_mutex<'a, T>(
    mutex: &'a StdMutex<T>,
    label: &str,
) -> anyhow::Result<std::sync::MutexGuard<'a, T>> {
    match mutex.lock() {
        Ok(guard) => Ok(guard),
        Err(poisoned) => {
            tracing::warn!(label, "mutex poisoned, recovering inner guard");
            Ok(poisoned.into_inner())
        }
    }
}

/// Store sanitized dimensions in the shared `dims` mutex, recovering from
/// poisoning by logging and leaving the previous value in place.
pub(super) fn store_dims(
    dims: &StdMutex<retach::screen::TerminalSize>,
    cols: u16,
    rows: u16,
    session_name: &str,
) {
    match dims.lock() {
        Ok(mut d) => *d = retach::screen::sanitize_dimensions(cols, rows),
        Err(e) => {
            tracing::warn!(session = %session_name, error = %e, "dims mutex poisoned during resize")
        }
    }
}

/// Render the full screen state and send the update to the client.
pub(super) async fn render_and_send(
    screen: &Arc<StdMutex<Screen>>,
    renderer: &mut AnsiRenderer,
    writer: &mut tokio::net::unix::OwnedWriteHalf,
) -> anyhow::Result<()> {
    let update = {
        let screen = lock_mutex(screen, "screen")?;
        renderer.render(&*screen, true)
    };
    let msg = protocol::encode(&ServerMsg::ScreenUpdate(update))?;
    writer.write_all(&msg).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn prepend_passthrough_empty() {
        let render = b"render-data".to_vec();
        let result = prepend_passthrough(vec![], render.clone());
        assert_eq!(result, render);
    }

    #[test]
    fn prepend_passthrough_single() {
        let pt = vec![b"\x1b[3J".to_vec()];
        let render = b"\x1b[?2026hcontent\x1b[?2026l".to_vec();
        let result = prepend_passthrough(pt, render);
        assert_eq!(&result[..4], b"\x1b[3J");
        assert_eq!(&result[4..], b"\x1b[?2026hcontent\x1b[?2026l");
    }

    #[test]
    fn prepend_passthrough_multiple() {
        let pt = vec![vec![0x07], b"\x1b[3J".to_vec()];
        let render = b"screen".to_vec();
        let result = prepend_passthrough(pt, render);
        assert_eq!(result, b"\x07\x1b[3Jscreen");
    }

    /// `lock_mutex` recovers a poisoned mutex instead of failing, so a panic in
    /// the persistent reader does not wedge every subsequent reconnect.
    #[test]
    fn lock_mutex_recovers_from_poison() {
        let m = std::sync::Arc::new(StdMutex::new(42u32));
        let m2 = m.clone();
        let _ = std::thread::spawn(move || {
            let _g = m2.lock().unwrap();
            panic!("poison the mutex");
        })
        .join();
        assert!(m.lock().is_err(), "mutex should be poisoned");
        let guard = lock_mutex(&m, "test").expect("lock_mutex should recover from poison");
        assert_eq!(*guard, 42);
    }

    /// `store_dims` writes sanitized dimensions and recovers a poisoned mutex
    /// without panicking, leaving the prior value untouched.
    #[test]
    fn store_dims_writes_sanitized_value() {
        let dims = StdMutex::new(retach::screen::TerminalSize { cols: 80, rows: 24 });
        store_dims(&dims, 100, 40, "test");
        let got = *dims.lock().unwrap();
        let expected = retach::screen::sanitize_dimensions(100, 40);
        assert_eq!(got.cols, expected.cols);
        assert_eq!(got.rows, expected.rows);
    }

    #[test]
    fn store_dims_recovers_from_poison() {
        let dims = std::sync::Arc::new(StdMutex::new(retach::screen::TerminalSize {
            cols: 80,
            rows: 24,
        }));
        let d2 = dims.clone();
        let _ = std::thread::spawn(move || {
            let _g = d2.lock().unwrap();
            panic!("poison");
        })
        .join();
        // Must not panic even though the mutex is poisoned.
        store_dims(&dims, 100, 40, "test");
    }
}