crates/signaltty-gui/src/actor.rs

//! IPC actor owns control and subscription sockets on a background runtime.
//! GTK awaits replies without blocking; stream delivery continues during calls.

use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::time::Duration;

use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::UnixStream;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{timeout, Instant};

use signaltty_proto::{Request, Response};

const IPC_TIMEOUT: Duration = Duration::from_secs(3);
const WORKTREE_TIMEOUT: Duration = Duration::from_secs(90);
const FILE_DIFF_TIMEOUT: Duration = Duration::from_secs(10);

fn worktree_call(method: &str) -> bool {
    matches!(
        method,
        "worktree.list" | "worktree.create" | "worktree.open" | "worktree.remove"
    )
}
const RECONNECT_DELAY: Duration = Duration::from_secs(2);

pub type UiTx = mpsc::UnboundedSender<UiEvent>;

type Reply = oneshot::Sender<Result<Value, String>>;

pub enum ActorRequest {
    Call {
        method: String,
        params: Value,
        reply: Reply,
        deadline: Instant,
    },
    Attach {
        pane_id: String,
        cols: u16,
        rows: u16,
    },
    Detach {
        pane_id: String,
    },
}

#[derive(Debug)]
pub enum UiEvent {
    ServerEvent {
        name: String,
        payload: Value,
    },
    /// Canonical VT state replaces the terminal contents before following data.
    PtySnapshot {
        pane_id: String,
        data: Vec<u8>,
    },
    PtyData {
        pane_id: String,
        data: Vec<u8>,
    },
    FocusPane(String),
    MarkSeen(String),
    Reconnected,
    Disconnected,
}

#[derive(Clone)]
pub struct IpcHandle {
    tx: mpsc::UnboundedSender<ActorRequest>,
}

impl IpcHandle {
    #[cfg(test)]
    pub fn test_channel() -> (Self, mpsc::UnboundedReceiver<ActorRequest>) {
        let (tx, rx) = mpsc::unbounded_channel();
        (Self { tx }, rx)
    }

    pub async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
        let (reply, rx) = oneshot::channel();
        self.tx
            .send(ActorRequest::Call {
                method: method.into(),
                params,
                reply,
                deadline: Instant::now()
                    + if method == signaltty_proto::method::WORKSPACE_FILE_DIFF {
                        FILE_DIFF_TIMEOUT
                    } else if worktree_call(method) {
                        WORKTREE_TIMEOUT
                    } else {
                        IPC_TIMEOUT
                    },
            })
            .map_err(|_| "ipc actor gone".to_string())?;
        rx.await.map_err(|_| "ipc reply lost".to_string())?
    }

    pub fn attach(&self, pane_id: &str, cols: u16, rows: u16) {
        let _ = self.tx.send(ActorRequest::Attach {
            pane_id: pane_id.into(),
            cols,
            rows,
        });
    }

    pub fn detach(&self, pane_id: &str) {
        let _ = self.tx.send(ActorRequest::Detach {
            pane_id: pane_id.into(),
        });
    }
}

pub fn spawn(socket: PathBuf, ui: UiTx) -> IpcHandle {
    let (tx, rx) = mpsc::unbounded_channel();
    std::thread::Builder::new()
        .name("signaltty-ipc".into())
        .spawn(move || {
            let rt = tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
                .expect("tokio runtime");
            rt.block_on(actor_main(socket, rx, ui));
        })
        .expect("spawn ipc thread");
    IpcHandle { tx }
}

struct Conn {
    lines: Lines<BufReader<tokio::net::unix::OwnedReadHalf>>,
    writer: tokio::net::unix::OwnedWriteHalf,
    next_id: u64,
}

impl Conn {
    async fn connect(socket: &PathBuf) -> Result<Self, String> {
        let stream = timeout(IPC_TIMEOUT, UnixStream::connect(socket))
            .await
            .map_err(|_| "ipc connect timed out".to_string())?
            .map_err(|e| e.to_string())?;
        let (r, writer) = stream.into_split();
        Ok(Self {
            lines: BufReader::new(r).lines(),
            writer,
            next_id: 1,
        })
    }

    async fn send(&mut self, method: &str, params: Value) -> Result<String, String> {
        let id = self.next_id.to_string();
        self.next_id += 1;
        let req = Request {
            protocol: signaltty_proto::PROTOCOL.into(),
            id: id.clone(),
            method: method.into(),
            params,
        };
        let mut line = serde_json::to_string(&req).unwrap();
        line.push('\n');
        timeout(IPC_TIMEOUT, self.writer.write_all(line.as_bytes()))
            .await
            .map_err(|_| "ipc write timed out".to_string())?
            .map_err(|e| e.to_string())?;
        crate::metrics::record("ipc", method);
        Ok(id)
    }

    async fn read_line(&mut self) -> Result<String, String> {
        self.lines
            .next_line()
            .await
            .map_err(|e| e.to_string())?
            .ok_or_else(|| "eof".into())
    }
}

struct QueuedCall {
    method: String,
    params: Value,
    reply: Reply,
    deadline: Instant,
}
struct PendingCall {
    id: String,
    reply: Reply,
    deadline: Instant,
}
enum PendingKind {
    Attach(String),
    Detach,
}
struct Pending {
    kind: PendingKind,
    deadline: Instant,
}

/// Error every call gets while the actor waits to reconnect. It is sent
/// only after `UiEvent::Disconnected`, so the UI already shows the banner.
pub const RECONNECTING: &str = "reconnecting";

async fn actor_main(socket: PathBuf, mut rx: mpsc::UnboundedReceiver<ActorRequest>, ui: UiTx) {
    let mut attached = HashMap::new();
    let mut reconnecting = false;
    loop {
        if run_session(&socket, &mut rx, &ui, &mut attached, reconnecting).await {
            return;
        }
        reconnecting = true;
        let _ = ui.send(UiEvent::Disconnected);
        let retry_at = Instant::now() + RECONNECT_DELAY;
        loop {
            tokio::select! {
                _ = tokio::time::sleep_until(retry_at) => break,
                req = rx.recv() => match req {
                    None => return,
                    Some(ActorRequest::Call { reply, .. }) => { let _ = reply.send(Err(RECONNECTING.into())); }
                    Some(ActorRequest::Attach { pane_id, cols, rows }) => { attached.insert(pane_id, (cols, rows)); }
                    Some(ActorRequest::Detach { pane_id }) => { attached.remove(&pane_id); }
                }
            }
        }
    }
}

async fn run_session(
    socket: &PathBuf,
    rx: &mut mpsc::UnboundedReceiver<ActorRequest>,
    ui: &UiTx,
    attached: &mut HashMap<String, (u16, u16)>,
    reconnecting: bool,
) -> bool {
    // Bound the entire connect/subscribe phase, including queue wait for callers.
    let handshake = async {
        let control = Conn::connect(socket).await?;
        let mut sub = Conn::connect(socket).await?;
        // `*` already covers task events. `task.*` is named so a
        // narrower subscribe still delivers the worker chip.
        let id = sub
            .send("subscribe", json!({"events":["*", "task.*"]}))
            .await?;
        let response = read_response(&mut sub, &id).await?;
        response_result(response)?;
        Ok::<_, String>((control, sub))
    };
    let (mut control, mut sub) = match timeout(IPC_TIMEOUT, handshake).await {
        Ok(Ok(connections)) => connections,
        _ => return false,
    };
    if reconnecting {
        let _ = ui.send(UiEvent::Reconnected);
    }
    let mut pending = HashMap::new();
    for pane_id in attached.keys() {
        if send_attach(&mut sub, &mut pending, pane_id.clone(), None)
            .await
            .is_err()
        {
            return false;
        }
    }
    let mut offsets = HashMap::<String, u64>::new();
    let mut queued = VecDeque::<QueuedCall>::new();
    let mut current: Option<PendingCall> = None;
    loop {
        if current.is_none() {
            while let Some(call) = queued.pop_front() {
                if call.deadline <= Instant::now() {
                    let _ = call
                        .reply
                        .send(Err("ipc request timed out before sending".into()));
                    continue;
                }
                if call.reply.is_closed() {
                    continue;
                }
                match send_control(&mut control, &call).await {
                    Ok(id) => {
                        current = Some(PendingCall {
                            id,
                            reply: call.reply,
                            deadline: call.deadline,
                        });
                        break;
                    }
                    Err(e) => {
                        let _ = call.reply.send(Err(e));
                        return false;
                    }
                }
            }
        }
        let deadline = pending
            .values()
            .map(|p: &Pending| p.deadline)
            .chain(current.iter().map(|c| c.deadline))
            .chain(queued.iter().map(|c| c.deadline))
            .min()
            .unwrap_or_else(|| Instant::now() + Duration::from_secs(3600));
        tokio::select! {
            req = rx.recv() => match req {
                None => return true,
                Some(ActorRequest::Call { method, params, reply, deadline }) => {
                    if worktree_call(&method) || method == signaltty_proto::method::WORKSPACE_FILE_DIFF {
                        let read_only = method == signaltty_proto::method::WORKSPACE_FILE_DIFF;
                        let socket = socket.clone();
                        tokio::spawn(async move {
                            let result = tokio::time::timeout_at(deadline, async {
                                let mut connection = Conn::connect(&socket).await?;
                                let id = connection.send(&method, params).await?;
                                let response = read_response(&mut connection, &id).await.map_err(|e| if read_only { format!("ipc file diff read failed: {e}") } else { format!("ipc worktree response lost; outcome unknown: {e}") })?;
                                response_result(response)
                            }).await.unwrap_or_else(|_| Err(if read_only { "ipc file diff read timed out" } else { "ipc worktree request timed out; outcome unknown" }.into()));
                            let _ = reply.send(result);
                        });
                    } else {
                        queued.push_back(QueuedCall { method, params, reply, deadline });
                    }
                }
                Some(ActorRequest::Attach { pane_id, cols, rows }) => {
                    attached.insert(pane_id.clone(), (cols, rows));
                    if send_attach(&mut sub, &mut pending, pane_id, Some((cols, rows))).await.is_err() { return false; }
                }
                Some(ActorRequest::Detach { pane_id }) => {
                    attached.remove(&pane_id);
                    offsets.remove(&pane_id);
                    match sub.send("pane.detach", json!({"pane_id":pane_id})).await {
                        Ok(id) => { pending.insert(id, Pending { kind:PendingKind::Detach, deadline:Instant::now() + IPC_TIMEOUT }); }
                        Err(_) => return false,
                    }
                }
            },
            line = control.read_line(), if current.is_some() => {
                let call = current.take().unwrap();
                let response = line.and_then(|line| parse_response(&line, &call.id));
                match response {
                    Ok(response) => { let _ = call.reply.send(response_result(response)); }
                    Err(e) => { let _ = call.reply.send(Err(e)); return false; }
                }
            },
            line = sub.read_line() => {
                let value: Value = match line.and_then(|line| serde_json::from_str(&line).map_err(|e| e.to_string())) { Ok(v) => v, Err(_) => return false };
                if value.get("type").and_then(Value::as_str) == Some("event") {
                    if !deliver_event(value, attached, &mut offsets, ui) { return false; }
                    continue;
                }
                let response: Response = match serde_json::from_value(value) { Ok(r) => r, Err(_) => return false };
                if response.protocol != signaltty_proto::PROTOCOL { return false; }
                let Some(request) = pending.remove(&response.id) else { return false };
                if let PendingKind::Attach(pane_id) = request.kind {
                    if !attached.contains_key(&pane_id) { continue; }
                    if !response.ok {
                        if response.error.as_ref().map(|e| e.code.as_str()) == Some(signaltty_proto::code::NO_SUCH_PANE) {
                            attached.remove(&pane_id);
                            offsets.remove(&pane_id);
                        }
                        continue;
                    }
                    let Some(data) = response.result.get("snapshot_b64").and_then(Value::as_str).and_then(|s| base64_decode(s).ok()) else { return false };
                    if let Some(offset) = response.result.get("output_offset").and_then(Value::as_u64) { offsets.insert(pane_id.clone(), offset); }
                    let _ = ui.send(UiEvent::PtySnapshot { pane_id, data });
                }
            },
            _ = tokio::time::sleep_until(deadline) => {
                let now = Instant::now();
                let mut waiting = VecDeque::new();
                for call in queued.drain(..) {
                    if call.deadline <= now {
                        let _ = call.reply.send(Err("ipc request timed out before sending".into()));
                    } else if !call.reply.is_closed() {
                        waiting.push_back(call);
                    }
                }
                queued = waiting;
                if current.as_ref().is_some_and(|call| call.deadline <= now) {
                    let _ = current.take().unwrap().reply.send(Err("ipc request timed out; outcome unknown".into()));
                    return false; // never associate a delayed reply with a later mutation
                }
                if pending.values().any(|p| p.deadline <= now) { return false; }
            }
        }
    }
}

async fn send_control(control: &mut Conn, call: &QueuedCall) -> Result<String, String> {
    timeout(
        call.deadline.saturating_duration_since(Instant::now()),
        control.send(&call.method, call.params.clone()),
    )
    .await
    .map_err(|_| "ipc request timed out; outcome unknown".to_string())?
}

async fn send_attach(
    sub: &mut Conn,
    pending: &mut HashMap<String, Pending>,
    pane_id: String,
    size: Option<(u16, u16)>,
) -> Result<(), String> {
    let mut params = json!({"pane_id":pane_id,"mark_seen":false});
    if let Some((cols, rows)) = size {
        params["cols"] = json!(cols);
        params["rows"] = json!(rows);
    }
    let id = sub.send("pane.attach", params).await?;
    pending.insert(
        id,
        Pending {
            kind: PendingKind::Attach(pane_id),
            deadline: Instant::now() + IPC_TIMEOUT,
        },
    );
    Ok(())
}

fn deliver_event(
    value: Value,
    attached: &HashMap<String, (u16, u16)>,
    offsets: &mut HashMap<String, u64>,
    ui: &UiTx,
) -> bool {
    let name = value
        .get("event")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    let payload = value.get("payload").cloned().unwrap_or(Value::Null);
    if name != signaltty_proto::event::PTY_DATA {
        let _ = ui.send(UiEvent::ServerEvent { name, payload });
        return true;
    }
    let Some(pane_id) = payload.get("pane_id").and_then(Value::as_str) else {
        return true;
    };
    if !attached.contains_key(pane_id) {
        return true;
    }
    let Some(mut data) = payload
        .get("data_b64")
        .and_then(Value::as_str)
        .and_then(|s| base64_decode(s).ok())
    else {
        return true;
    };
    if let Some(end) = payload.get("output_offset").and_then(Value::as_u64) {
        let Some(seen) = offsets.get_mut(pane_id) else {
            // The attach snapshot has not established a baseline yet; it
            // covers any output sent before its response.
            return true;
        };
        if end <= *seen {
            return true;
        }
        let Some(start) = end.checked_sub(data.len() as u64) else {
            return false;
        };
        if start > *seen {
            return false;
        }
        let covered = seen.saturating_sub(start).min(data.len() as u64) as usize;
        data.drain(..covered);
        *seen = end;
    }
    if !data.is_empty() {
        let _ = ui.send(UiEvent::PtyData {
            pane_id: pane_id.into(),
            data,
        });
    }
    true
}

async fn read_response(conn: &mut Conn, expected_id: &str) -> Result<Response, String> {
    parse_response(&conn.read_line().await?, expected_id)
}

fn parse_response(line: &str, expected_id: &str) -> Result<Response, String> {
    let response: Response = serde_json::from_str(line).map_err(|e| e.to_string())?;
    if response.protocol != signaltty_proto::PROTOCOL {
        return Err("ipc response protocol mismatch".into());
    }
    if response.id != expected_id {
        return Err("ipc response id mismatch".into());
    }
    Ok(response)
}

fn response_result(response: Response) -> Result<Value, String> {
    if response.ok {
        return Ok(response.result);
    }
    Err(response
        .error
        .map(|e| format!("{}: {}", e.code, e.message))
        .unwrap_or_else(|| "invalid ipc error response".into()))
}

fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
    use base64::Engine;
    base64::engine::general_purpose::STANDARD
        .decode(s.as_bytes())
        .map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};
    use tokio::net::UnixListener;

    static NEXT_SOCKET: AtomicU64 = AtomicU64::new(0);
    struct TestSocket(PathBuf);
    impl TestSocket {
        fn new() -> Self {
            Self(std::env::temp_dir().join(format!(
                "signaltty-actor-{}-{}.sock",
                std::process::id(),
                NEXT_SOCKET.fetch_add(1, Ordering::Relaxed)
            )))
        }
    }
    impl Drop for TestSocket {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(&self.0);
        }
    }
    async fn server_conn(listener: &UnixListener) -> Conn {
        let (stream, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept())
            .await
            .unwrap()
            .unwrap();
        let (r, writer) = stream.into_split();
        Conn {
            lines: BufReader::new(r).lines(),
            writer,
            next_id: 1,
        }
    }
    async fn request(conn: &mut Conn) -> Request {
        let line = tokio::time::timeout(Duration::from_secs(5), conn.read_line())
            .await
            .unwrap()
            .unwrap();
        serde_json::from_str(&line).unwrap()
    }
    async fn respond(conn: &mut Conn, req: &Request, result: Value) {
        conn.writer
            .write_all(Response::ok(&req.id, result).to_line().as_bytes())
            .await
            .unwrap();
    }
    async fn connected(listener: &UnixListener) -> (Conn, Conn) {
        let control = server_conn(listener).await;
        let mut sub = server_conn(listener).await;
        let subscribe = request(&mut sub).await;
        assert_eq!(subscribe.method, "subscribe");
        let events = subscribe.params["events"]
            .as_array()
            .cloned()
            .unwrap_or_default();
        assert!(
            events.iter().any(|v| v.as_str() == Some("task.*")),
            "subscribe must include task.* so worker chips update"
        );
        respond(&mut sub, &subscribe, json!({"subscribed": true})).await;
        (control, sub)
    }
    async fn next_event(events: &mut mpsc::UnboundedReceiver<UiEvent>) -> UiEvent {
        tokio::time::timeout(Duration::from_secs(5), events.recv())
            .await
            .unwrap()
            .unwrap()
    }
    async fn data(sub: &mut Conn, text: &str, offset: u64) {
        use base64::Engine;
        let value = json!({"type":"event", "event":signaltty_proto::event::PTY_DATA,
            "payload":{"pane_id":"pane-test", "data_b64":base64::engine::general_purpose::STANDARD.encode(text), "output_offset":offset}});
        sub.writer
            .write_all(format!("{value}\n").as_bytes())
            .await
            .unwrap();
    }
    async fn snapshot(sub: &mut Conn, text: &str, offset: u64) {
        use base64::Engine;
        let attach = request(sub).await;
        assert_eq!(attach.method, "pane.attach");
        assert_eq!(attach.params["mark_seen"], false);
        respond(sub, &attach, json!({"snapshot_b64":base64::engine::general_purpose::STANDARD.encode(text), "output_offset":offset})).await;
    }

    #[tokio::test]
    async fn stream_keeps_delivering_while_control_reply_is_stalled() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (mut control, mut sub) = connected(&listener).await;
        let caller = actor.clone();
        let call = tokio::spawn(async move { caller.call("workspace.list", json!({})).await });
        let _ = request(&mut control).await;
        sub.writer
            .write_all(b"{\"type\":\"event\",\"event\":\"notification.created\",\"payload\":{}}\n")
            .await
            .unwrap();
        let event = tokio::time::timeout(Duration::from_millis(300), events.recv()).await;
        assert!(
            matches!(event, Ok(Some(UiEvent::ServerEvent { name, .. })) if name == "notification.created")
        );
        call.abort();
    }

    #[tokio::test]
    async fn reconnect_replays_snapshot_before_live_data_without_duplicate_bytes() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (control, mut sub) = connected(&listener).await;
        actor.attach("pane-test", 80, 24);
        snapshot(&mut sub, "one", 3).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtySnapshot { data, .. } if data == b"one")
        );
        data(&mut sub, "onetwo", 6).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtyData { data, .. } if data == b"two")
        );
        data(&mut sub, "two", 6).await;
        sub.writer
            .write_all(b"{\"type\":\"event\",\"event\":\"notification.created\",\"payload\":{}}\n")
            .await
            .unwrap();
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::ServerEvent { .. }
        ));
        drop(control);
        drop(sub);
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Disconnected
        ));
        let (_control, mut sub) = connected(&listener).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Reconnected
        ));
        snapshot(&mut sub, "one GAP two", 11).await;
        data(&mut sub, " GAP twoLIVE", 15).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtySnapshot { data, .. } if data == b"one GAP two")
        );
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtyData { data, .. } if data == b"LIVE")
        );
    }

    #[tokio::test]
    async fn forward_output_gap_reconnects_and_replaces_the_screen() {
        use base64::Engine;
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (_control, mut sub) = connected(&listener).await;
        actor.attach("pane-test", 80, 24);
        snapshot(&mut sub, "old", 3).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::PtySnapshot { .. }
        ));

        // Bytes 3..6 were lost. Feeding only "new" would preserve a wrong screen.
        data(&mut sub, "new", 9).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Disconnected
        ));
        let (_control, mut sub) = connected(&listener).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Reconnected
        ));
        let attach = request(&mut sub).await;
        assert!(
            attach.params.get("cols").is_none(),
            "recovery must preserve the latest PTY size"
        );
        assert!(attach.params.get("rows").is_none());
        assert_eq!(attach.params["mark_seen"], false);
        respond(&mut sub,&attach,json!({"snapshot_b64":base64::engine::general_purpose::STANDARD.encode("oldGAPnew"),"output_offset":9})).await;
        data(&mut sub, "newLIVE", 13).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtySnapshot { data, .. } if data == b"oldGAPnew")
        );
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtyData { data, .. } if data == b"LIVE")
        );
    }

    #[tokio::test]
    async fn pre_attach_output_is_ignored_until_snapshot_sets_the_offset() {
        use base64::Engine;
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (_control, mut sub) = connected(&listener).await;
        actor.attach("pane-test", 80, 24);
        // The attach request proves the pane is already marked attached while
        // its offset baseline is still missing.
        let attach = request(&mut sub).await;
        assert_eq!(attach.method, "pane.attach");
        // Bytes 3..8 sent before the snapshot must not read as a gap: the
        // snapshot covers them.
        data(&mut sub, "defgh", 8).await;
        respond(&mut sub,&attach,json!({"snapshot_b64":base64::engine::general_purpose::STANDARD.encode("abcdefgh"),"output_offset":8})).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtySnapshot { data, .. } if data == b"abcdefgh")
        );
        data(&mut sub, "abcdefghIJ", 10).await;
        assert!(
            matches!(next_event(&mut events).await, UiEvent::PtyData { data, .. } if data == b"IJ")
        );
    }

    #[tokio::test]
    async fn slow_worktree_uses_an_independent_connection_and_keeps_control_available() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, _events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (mut control, _sub) = connected(&listener).await;
        let caller = actor.clone();
        let worktree = tokio::spawn(async move {
            caller
                .call(
                    "worktree.create",
                    json!({"workspace_id": "a", "branch": "feature", "path": "/tmp/worktree"}),
                )
                .await
        });
        let mut dedicated = server_conn(&listener).await;
        let creation = request(&mut dedicated).await;
        assert_eq!(creation.method, "worktree.create");
        let caller = actor.clone();
        let fast = tokio::spawn(async move { caller.call("server.status", json!({})).await });
        let status = request(&mut control).await;
        assert_eq!(status.method, "server.status");
        respond(&mut control, &status, json!({"ready": true})).await;
        assert_eq!(fast.await.unwrap().unwrap()["ready"], true);
        tokio::time::sleep(IPC_TIMEOUT + Duration::from_millis(100)).await;
        respond(
            &mut dedicated,
            &creation,
            json!({"workspace": {"id": "new"}}),
        )
        .await;
        assert_eq!(worktree.await.unwrap().unwrap()["workspace"]["id"], "new");
    }

    #[tokio::test]
    async fn file_diff_uses_independent_read_connection_and_keeps_control_available() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, _events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (mut control, _sub) = connected(&listener).await;
        let caller = actor.clone();
        let read = tokio::spawn(async move {
            caller
                .call(
                    "workspace.file_diff",
                    json!({"workspace_id":"a", "path":"src/a.rs"}),
                )
                .await
        });
        let mut dedicated = server_conn(&listener).await;
        let diff = request(&mut dedicated).await;
        assert_eq!(diff.method, "workspace.file_diff");
        let caller = actor.clone();
        let fast = tokio::spawn(async move { caller.call("server.status", json!({})).await });
        let status = request(&mut control).await;
        respond(&mut control, &status, json!({"ready":true})).await;
        assert_eq!(fast.await.unwrap().unwrap()["ready"], true);
        tokio::time::sleep(IPC_TIMEOUT + Duration::from_millis(100)).await;
        respond(&mut dedicated, &diff, json!({"path":"src/a.rs"})).await;
        assert_eq!(read.await.unwrap().unwrap()["path"], "src/a.rs");
        let caller = actor.clone();
        let read = tokio::spawn(async move { caller.call("workspace.file_diff", json!({})).await });
        let mut dedicated = server_conn(&listener).await;
        let _ = request(&mut dedicated).await;
        drop(dedicated);
        let error = read.await.unwrap().unwrap_err();
        assert!(error.contains("read"), "{error}");
        assert!(!error.contains("outcome unknown"), "{error}");
    }

    #[tokio::test]
    async fn stalled_mutation_times_out_and_is_never_retried() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (mut control, _sub) = connected(&listener).await;
        let caller = actor.clone();
        let call = tokio::spawn(async move { caller.call("pane.spawn", json!({})).await });
        assert_eq!(request(&mut control).await.method, "pane.spawn");
        let error = tokio::time::timeout(IPC_TIMEOUT + Duration::from_secs(1), call)
            .await
            .unwrap()
            .unwrap()
            .unwrap_err();
        assert!(error.contains("outcome unknown"), "{error}");
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Disconnected
        ));
        let (mut control, _sub) = connected(&listener).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Reconnected
        ));
        assert!(
            tokio::time::timeout(Duration::from_millis(100), control.read_line())
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn missing_panes_are_not_reattached_again() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (control, mut sub) = connected(&listener).await;
        actor.attach("pane-gone", 80, 24);
        let attach = request(&mut sub).await;
        sub.writer
            .write_all(
                Response::err(&attach.id, signaltty_proto::code::NO_SUCH_PANE, "missing")
                    .to_line()
                    .as_bytes(),
            )
            .await
            .unwrap();
        sub.writer
            .write_all(b"{\"type\":\"event\",\"event\":\"notification.created\",\"payload\":{}}\n")
            .await
            .unwrap();
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::ServerEvent { .. }
        ));
        drop(control);
        drop(sub);
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Disconnected
        ));
        let (_control, mut sub) = connected(&listener).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Reconnected
        ));
        assert!(
            tokio::time::timeout(Duration::from_millis(100), sub.read_line())
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn mismatched_response_discards_connection_before_the_next_call() {
        let socket = TestSocket::new();
        let listener = UnixListener::bind(&socket.0).unwrap();
        let (ui, mut events) = mpsc::unbounded_channel();
        let actor = spawn(socket.0.clone(), ui);
        let (mut control, _sub) = connected(&listener).await;
        let caller = actor.clone();
        let call = tokio::spawn(async move { caller.call("pane.spawn", json!({})).await });
        let _ = request(&mut control).await;
        control
            .writer
            .write_all(
                Response::ok("wrong-id", json!({"pane":"wrong"}))
                    .to_line()
                    .as_bytes(),
            )
            .await
            .unwrap();
        assert!(call.await.unwrap().unwrap_err().contains("id mismatch"));
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Disconnected
        ));
        let (mut control, _sub) = connected(&listener).await;
        assert!(matches!(
            next_event(&mut events).await,
            UiEvent::Reconnected
        ));
        let caller = actor.clone();
        let call = tokio::spawn(async move { caller.call("pane.get", json!({})).await });
        let req = request(&mut control).await;
        assert_eq!(req.method, "pane.get");
        respond(&mut control, &req, json!({"pane":"fresh"})).await;
        assert_eq!(call.await.unwrap().unwrap(), json!({"pane":"fresh"}));
    }
}