crates/signaltty-server/src/server.rs

//! Unix-socket listener, connection handling, event fan-out.

use std::collections::HashSet;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, Mutex};

use signaltty_proto::{code, EventMsg, Request, Response, MAX_LINE_BYTES};

use crate::config::Config;
use crate::pty::PtyManager;
use crate::router::{dispatch, Ctx};
use crate::store::{SharedStore, Store, StoredEvent};

pub async fn serve(config: Config) -> Result<(), Box<dyn std::error::Error>> {
    let runtime_dir = config
        .socket_path
        .parent()
        .ok_or("socket path has no parent")?
        .to_path_buf();
    // Only tighten permissions on directories we create; never chmod a
    // shared parent (e.g. /tmp) supplied via --socket.
    if !runtime_dir.exists() {
        std::fs::create_dir_all(&runtime_dir)?;
        std::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))?;
    }
    // Remove a stale socket left by a crashed server.
    if config.socket_path.exists() {
        if UnixStream::connect(&config.socket_path).await.is_ok() {
            return Err(format!(
                "a server is already listening on {}",
                config.socket_path.display()
            )
            .into());
        }
        std::fs::remove_file(&config.socket_path)?;
    }

    let store: SharedStore = Arc::new(std::sync::RwLock::new(Store::new()));
    let (bcast, _) = broadcast::channel::<StoredEvent>(4096);
    let persist_pending = Arc::new(AtomicBool::new(false));
    let ptys = PtyManager::new(store.clone(), bcast.clone(), persist_pending.clone())
        .with_integration_home(config.integration_home.clone());

    // Restore previous snapshot (structure only — never processes).
    if let Some(loaded) = crate::persist::load(&config) {
        let n = loaded.snapshot.panes.len();
        crate::persist::apply(&store, &ptys.terms(), loaded);
        tracing::info!("restored {n} panes from snapshot (no processes restarted)");
    }

    let listener = UnixListener::bind(&config.socket_path)?;
    std::fs::set_permissions(&config.socket_path, std::fs::Permissions::from_mode(0o600))?;
    tracing::info!("listening on {}", config.socket_path.display());

    let shutdown = Arc::new(tokio::sync::Notify::new());
    let plugins = signaltty_plugin::PluginRegistry::load(config.plugin_dir.clone());
    let overlays = crate::router::load_overlays(&config.agents_dir);
    let audit = crate::audit::AuditLog::open(&config.state_dir)?;
    let sequence = crate::audit::EventSequence::open(&config.state_dir, audit.max_seq())?;
    store
        .write()
        .unwrap()
        .configure_events(audit, sequence, bcast.clone());
    crate::persist::recover_tasks(&store);
    let ctx = Arc::new(Ctx {
        store: store.clone(),
        approvals: crate::approvals::Approvals::default(),
        worktrees: crate::worktrees::Worktrees::default(),
        bcast,
        ptys: ptys.clone(),
        config: config.clone(),
        shutdown: shutdown.clone(),
        plugins,
        overlays,
    });

    // Plugin event hooks: every broadcast event (except high-volume
    // pty.data) is offered to matching hooks as JSON on stdin.
    {
        let ctx = ctx.clone();
        tokio::spawn(async move {
            let mut rx = ctx.bcast.subscribe();
            loop {
                match rx.recv().await {
                    Ok(ev) => {
                        if ev.name == signaltty_proto::event::PTY_DATA {
                            continue;
                        }
                        let envelope =
                            serde_json::to_value(EventMsg::new(&ev.name, ev.seq, ev.payload))
                                .unwrap_or(serde_json::Value::Null);
                        ctx.plugins
                            .dispatch(&ev.name, &envelope, &ctx.config.socket_path)
                            .await;
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
        });
    }

    // Live /proc refresh (docs/07 layer 4): promote agent kinds and follow
    // cwds every 10s. Broadcasts + persists only when something changed.
    {
        let ctx = ctx.clone();
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
            loop {
                interval.tick().await;
                let events =
                    crate::procscan::scan(&ctx.store, &ctx.ptys.child_pids(), &ctx.overlays);
                if !events.is_empty() {
                    ctx.mark_persist();
                }
            }
        });
    }

    // Silent-worker watchdog:
    // If a task stays `working` with no hook activity and no PTY output
    // for worker_silent_timeout_s, transition to `input_required` with
    // {reason: "worker_silent"}
    {
        let ctx = ctx.clone();
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_millis(250));
            loop {
                interval.tick().await;
                let timeout_s = ctx.config.worker_silent_timeout_s;
                if timeout_s == 0 {
                    continue;
                }
                let now = chrono::Utc::now();
                let silent_tasks: Vec<(String, String)> = {
                    let s = ctx.store.read().unwrap();
                    s.tasks
                        .values()
                        .filter_map(|t| {
                            if t.state == signaltty_core::TaskState::Working {
                                if let Some(pid) = &t.pane_id {
                                    if let Some(p) = s.panes.get(pid) {
                                        let elapsed = now
                                            .signed_duration_since(p.last_activity_at)
                                            .num_seconds();
                                        if elapsed >= timeout_s as i64 {
                                            return Some((t.id.clone(), pid.clone()));
                                        }
                                    }
                                }
                            }
                            None
                        })
                        .collect()
                };

                if silent_tasks.is_empty() {
                    continue;
                }
                let mut s = ctx.store.write().unwrap();
                for (task_id, _pid) in silent_tasks {
                    if let Some(t) = s.tasks.get(&task_id) {
                        if t.state == signaltty_core::TaskState::Working {
                            let evidence = serde_json::json!({
                                "reason": "worker_silent",
                                "timeout_s": timeout_s,
                            });
                            s.task_input_required_on_turn_end(&task_id, Some(evidence));
                        }
                    }
                }
                drop(s);
                ctx.mark_persist();
            }
        });
    }

    // Debounced snapshot flusher.
    {
        let ctx = ctx.clone();
        let pending = persist_pending.clone();
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
            loop {
                interval.tick().await;
                if pending.swap(false, Ordering::Relaxed) {
                    if let Err(e) = crate::persist::save(&ctx.store, &ctx.ptys.terms(), &ctx.config)
                    {
                        tracing::warn!("snapshot failed: {e}");
                    }
                }
            }
        });
    }

    // OS signals → snapshot + shutdown.
    {
        let ctx = ctx.clone();
        tokio::spawn(async move {
            let mut sigterm =
                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate());
            let ctrlc = tokio::signal::ctrl_c();
            tokio::pin!(ctrlc);
            match sigterm {
                Ok(ref mut term) => {
                    tokio::select! {
                        _ = term.recv() => {},
                        _ = &mut ctrlc => {},
                    }
                }
                Err(_) => {
                    ctrlc.await.ok();
                }
            }
            tracing::info!("shutting down; writing snapshot");
            if let Err(e) = crate::persist::save(&ctx.store, &ctx.ptys.terms(), &ctx.config) {
                tracing::warn!("shutdown snapshot failed: {e}");
            }
            ctx.shutdown.notify_waiters();
        });
    }

    let my_uid = nix::unistd::getuid().as_raw();
    loop {
        tokio::select! {
            _ = shutdown.notified() => {
                tracing::info!("shutdown requested");
                break;
            }
            conn = listener.accept() => {
                let (stream, _) = match conn {
                    Ok(c) => c,
                    Err(e) => {
                        tracing::warn!("accept failed: {e}");
                        continue;
                    }
                };
                // SO_PEERCRED: only our own uid, even if perms were loosened.
                match stream.peer_cred() {
                    Ok(cred) if cred.uid() == my_uid => {}
                    Ok(cred) => {
                        tracing::warn!("refusing connection from uid {}", cred.uid());
                        continue;
                    }
                    Err(e) => {
                        tracing::warn!("peer_cred failed: {e}");
                        continue;
                    }
                }
                let ctx = ctx.clone();
                tokio::spawn(async move {
                    if let Err(e) = handle_conn(ctx, stream).await {
                        tracing::debug!("connection ended: {e}");
                    }
                });
            }
        }
    }
    Ok(())
}

struct ConnState {
    ptys: PtyManager,
    subs: Vec<String>,
    task_ids: Option<Vec<String>>,
    attached: HashSet<String>,
    fence: u64,
    last_received: u64,
}

impl Drop for ConnState {
    fn drop(&mut self) {
        for pane_id in &self.attached {
            self.ptys.remove_viewer(pane_id);
        }
    }
}

async fn handle_conn(ctx: Arc<Ctx>, stream: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
    let (read_half, write_half) = stream.into_split();
    // `next_line` is cancel-safe; `read_line` would drop a partly received
    // request whenever an event wins the select below.
    let mut lines = BufReader::new(read_half).lines();
    let writer = Arc::new(Mutex::new(write_half));
    let mut rx = ctx.bcast.subscribe();
    let mut state = ConnState {
        ptys: ctx.ptys.clone(),
        subs: Vec::new(),
        task_ids: None,
        attached: HashSet::new(),
        fence: 0,
        last_received: 0,
    };

    let send_line = |writer: Arc<Mutex<tokio::net::unix::OwnedWriteHalf>>, line: String| async move {
        let mut w = writer.lock().await;
        w.write_all(line.as_bytes()).await
    };

    loop {
        tokio::select! {
            line = lines.next_line() => {
                let Some(line) = line? else {
                    break; // EOF = implicit detach.
                };
                if line.len() > MAX_LINE_BYTES {
                    let resp = Response::err("?", code::RATE_LIMITED, "line too large");
                    send_line(writer.clone(), resp.to_line()).await?;
                    continue;
                }
                let req: Request = match serde_json::from_str(line.trim()) {
                    Ok(r) => r,
                    Err(_) => {
                        let resp = Response::err("?", code::BAD_PARAMS, "invalid JSON");
                        send_line(writer.clone(), resp.to_line()).await?;
                        continue;
                    }
                };
                if req.protocol != signaltty_proto::PROTOCOL {
                    let resp = Response::err(&req.id, code::BAD_PROTOCOL,
                        format!("want {}, got {}", signaltty_proto::PROTOCOL, req.protocol));
                    send_line(writer.clone(), resp.to_line()).await?;
                    continue;
                }
                let native_wait = req.method == signaltty_proto::method::HOOK_EVENT
                    && req.params.get("wait_for_answer").and_then(serde_json::Value::as_bool) == Some(true);
                let is_wait_call = native_wait
                    || req.method == signaltty_proto::method::WAIT
                    || req.method == signaltty_proto::method::TASK_WAIT;
                let (mut resp, effect) = if is_wait_call {
                    tokio::select! {
                        result = dispatch(&ctx, &req) => result,
                        _ = lines.next_line() => break,
                        _ = ctx.shutdown.notified() => break,
                    }
                } else { dispatch(&ctx, &req).await };
                // Register streaming before the final snapshot. Output is either
                // covered by its offset or delivered after the response.
                for pane_id in effect.attach {
                    if state.attached.insert(pane_id.clone()) {
                        ctx.ptys.add_viewer(&pane_id);
                    }
                    let (snapshot, output_offset) = ctx.ptys.snapshot(&pane_id);
                    use base64::Engine;
                    resp.result["snapshot_b64"] = serde_json::json!(base64::engine::general_purpose::STANDARD.encode(snapshot));
                    resp.result["output_offset"] = serde_json::json!(output_offset);
                }
                send_line(writer.clone(), resp.to_line()).await?;
                // Apply the remaining connection effects.
                for pane_id in effect.detach {
                    if state.attached.remove(&pane_id) {
                        ctx.ptys.remove_viewer(&pane_id);
                    }
                }
                if let Some(subs) = effect.subscribe {
                    state.subs = subs;
                }
                if let Some(task_ids) = effect.task_ids {
                    state.task_ids = Some(task_ids);
                }
                if let Some(fence) = effect.fence { state.fence = fence; }
                if effect.close { break; }
                if let Some(backlog) = effect.replay {
                    for ev in backlog {
                        let msg = EventMsg::new(&ev.name, ev.seq, ev.payload);
                        send_line(writer.clone(), msg.to_line()).await?;
                    }
                }
            }
            msg = rx.recv() => {
                let ev = match msg {
                    Ok(ev) => ev,
                    Err(broadcast::error::RecvError::Lagged(_)) => {
                        // A long `wait`/`hook-event` dispatch does not poll this
                        // receiver. When nothing was deliverable, lag is harmless:
                        // later subscriptions replay from their own fence.
                        if state.subs.is_empty() && state.attached.is_empty() {
                            rx = rx.resubscribe();
                            continue;
                        }
                        break;
                    }
                    Err(_) => break,
                };
                if ev.seq <= state.last_received { break; }
                state.last_received = ev.seq;
                if ev.name != signaltty_proto::event::PTY_DATA && ev.seq <= state.fence { continue; }
                let pane_id = ev.payload.get("pane_id").and_then(|v| v.as_str());
                let for_attached = ev.name == signaltty_proto::event::PTY_DATA
                    && pane_id.map(|p| state.attached.contains(p)).unwrap_or(false);
                let for_sub = state.subs.iter().any(|g| signaltty_proto::glob_matches(g, &ev.name));
                let for_task = if let Some(ref tids) = state.task_ids {
                    if ev.name.starts_with("task.") {
                        ev.payload
                            .get("task_id")
                            .and_then(|v| v.as_str())
                            .map(|tid| tids.iter().any(|t| t == tid))
                            .unwrap_or(false)
                    } else {
                        true
                    }
                } else {
                    true
                };
                if for_attached || (for_sub && for_task && ev.name != signaltty_proto::event::PTY_DATA) {
                    let msg = EventMsg::new(&ev.name, ev.seq, ev.payload);
                    if send_line(writer.clone(), msg.to_line()).await.is_err() {
                        break;
                    }
                }
            }
        }
    }

    Ok(())
}

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

    fn test_context() -> (Arc<Ctx>, std::path::PathBuf, String) {
        let base = std::env::temp_dir().join(signaltty_core::ids::new_pane_id());
        let config = Config {
            socket_path: base.join("test.sock"),
            state_dir: base.join("state"),
            history_tail_bytes: 1024,
            plugin_dir: base.join("plugins"),
            agents_dir: base.join("agents"),
            integration_home: Some(base.join("home")),
            max_parallel_tasks: crate::config::DEFAULT_MAX_PARALLEL_TASKS,
            worker_silent_timeout_s: crate::config::DEFAULT_WORKER_SILENT_TIMEOUT_S,
            merge_timeout_ms: crate::config::DEFAULT_MERGE_TIMEOUT_MS,
            fetch_timeout_ms: crate::config::DEFAULT_FETCH_TIMEOUT_MS,
        };
        let mut store = Store::new();
        let pane = signaltty_core::model::Pane::new(
            "ws".into(),
            "tab".into(),
            "/tmp".into(),
            vec!["sleep".into()],
            signaltty_core::model::PtySize::default(),
            chrono::Utc::now(),
        );
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane);
        let (bcast, _) = broadcast::channel(8);
        let audit = crate::audit::AuditLog::open(&config.state_dir).unwrap();
        let sequence = crate::audit::EventSequence::open(&config.state_dir, 0).unwrap();
        store.configure_events(audit, sequence, bcast.clone());
        let store = Arc::new(std::sync::RwLock::new(store));
        let ptys = PtyManager::new(
            store.clone(),
            bcast.clone(),
            Arc::new(AtomicBool::new(false)),
        );
        let ctx = Arc::new(Ctx {
            store,
            bcast,
            ptys,
            config: config.clone(),
            shutdown: Arc::new(tokio::sync::Notify::new()),
            approvals: crate::approvals::Approvals::default(),
            worktrees: crate::worktrees::Worktrees::default(),
            plugins: signaltty_plugin::PluginRegistry::load(config.plugin_dir),
            overlays: Vec::new(),
        });
        (ctx, base, id)
    }

    #[tokio::test]
    async fn pending_wait_releases_receivers_on_eof_shutdown_and_another_request() {
        for cause in ["eof", "shutdown", "request"] {
            let (ctx, base, pane) = test_context();
            let (mut client, server) = UnixStream::pair().unwrap();
            let owned = ctx.clone();
            let task =
                tokio::spawn(
                    async move { handle_conn(owned, server).await.map_err(|e| e.to_string()) },
                );
            let request = serde_json::json!({"protocol":signaltty_proto::PROTOCOL,"id":"wait","method":"wait","params":{"pane_id":pane,"until":"done","timeout_s":3600}});
            client
                .write_all(format!("{request}\n").as_bytes())
                .await
                .unwrap();
            tokio::time::timeout(std::time::Duration::from_secs(1), async {
                while ctx.bcast.receiver_count() != 2 {
                    tokio::task::yield_now().await;
                }
            })
            .await
            .unwrap();
            match cause {
                "eof" => drop(client),
                "shutdown" => ctx.shutdown.notify_waiters(),
                _ => {
                    client.write_all(b"{}\n").await.unwrap();
                }
            }
            tokio::time::timeout(std::time::Duration::from_secs(1), task)
                .await
                .unwrap()
                .unwrap()
                .unwrap();
            assert_eq!(ctx.bcast.receiver_count(), 0, "{cause}");
            std::fs::remove_dir_all(base).unwrap();
        }
    }

    #[tokio::test]
    async fn pending_task_wait_releases_receivers_on_eof() {
        let (ctx, base, _pane) = test_context();
        let task_id = {
            let mut store = ctx.store.write().unwrap();
            let now = chrono::Utc::now();
            let id = signaltty_core::ids::new_task_id();
            store.tasks.insert(
                id.clone(),
                signaltty_core::model::Task {
                    id: id.clone(),
                    context_id: signaltty_core::ids::new_context_id(),
                    parent_task_id: None,
                    pane_id: None,
                    parent_pane_id: None,
                    root_pane_id: None,
                    relationship: signaltty_core::model::Relationship::Subagent,
                    label: "wait-eof".into(),
                    contract: signaltty_core::model::Contract::new("wait").unwrap(),
                    agent: None,
                    source_repo: std::path::PathBuf::from("/tmp/repo"),
                    target_branch: Some("main".into()),
                    worktree_path: std::path::PathBuf::from("/tmp/wt"),
                    branch: "task-wait".into(),
                    preexisting_branch: false,
                    base_ref: "HEAD".into(),
                    base_sha: "abc".into(),
                    state: signaltty_core::state::TaskState::Pending,
                    result: None,
                    disposition: signaltty_core::model::Disposition::default(),
                    pr: None,
                    status_reason: None,
                    finish_error: None,
                    worker_pid: None,
                    worker_cmd: None,
                    client_request_id: None,
                    created_at: now,
                    updated_at: now,
                },
            );
            id
        };
        let (mut client, server) = UnixStream::pair().unwrap();
        let owned = ctx.clone();
        let task =
            tokio::spawn(
                async move { handle_conn(owned, server).await.map_err(|e| e.to_string()) },
            );
        let request = serde_json::json!({
            "protocol": signaltty_proto::PROTOCOL,
            "id": "wait",
            "method": "task.wait",
            "params": {"task_id": task_id, "until": "completed", "timeout_s": 3600}
        });
        client
            .write_all(format!("{request}\n").as_bytes())
            .await
            .unwrap();
        tokio::time::timeout(std::time::Duration::from_secs(1), async {
            while ctx.bcast.receiver_count() != 2 {
                tokio::task::yield_now().await;
            }
        })
        .await
        .unwrap();
        drop(client);
        tokio::time::timeout(std::time::Duration::from_secs(1), task)
            .await
            .unwrap()
            .unwrap()
            .unwrap();
        assert_eq!(ctx.bcast.receiver_count(), 0);
        std::fs::remove_dir_all(base).unwrap();
    }

    #[tokio::test]
    async fn lagged_connection_closes_instead_of_skipping_events() {
        let (ctx, base, _) = test_context();
        let (mut client, server) = UnixStream::pair().unwrap();
        let owned = ctx.clone();
        let task =
            tokio::spawn(
                async move { handle_conn(owned, server).await.map_err(|e| e.to_string()) },
            );
        client.write_all(format!("{}\n",serde_json::json!({"protocol":signaltty_proto::PROTOCOL,"id":"s","method":"subscribe","params":{"events":["*"]}})).as_bytes()).await.unwrap();
        let mut reader = BufReader::new(client);
        let mut ack = String::new();
        reader.read_line(&mut ack).await.unwrap();
        // The current-thread runtime cannot drain the receiver during this burst.
        for _ in 0..32 {
            ctx.store
                .write()
                .unwrap()
                .emit("test.event", serde_json::Value::Null);
        }
        let mut eof = String::new();
        assert_eq!(
            tokio::time::timeout(
                std::time::Duration::from_secs(1),
                reader.read_line(&mut eof)
            )
            .await
            .unwrap()
            .unwrap(),
            0
        );
        task.await.unwrap().unwrap();
        std::fs::remove_dir_all(base).unwrap();
    }

    #[tokio::test]
    async fn lagged_idle_connection_survives_and_serves_later_calls() {
        let (ctx, base, _) = test_context();
        let (mut client, server) = UnixStream::pair().unwrap();
        let owned = ctx.clone();
        let task =
            tokio::spawn(
                async move { handle_conn(owned, server).await.map_err(|e| e.to_string()) },
            );
        while ctx.bcast.receiver_count() != 1 {
            tokio::task::yield_now().await;
        }
        // No subscriptions or attachments: this burst overflows the receiver
        // without anything deliverable to this connection.
        for _ in 0..32 {
            ctx.store
                .write()
                .unwrap()
                .emit("test.event", serde_json::Value::Null);
        }
        client.write_all(format!("{}\n",serde_json::json!({"protocol":signaltty_proto::PROTOCOL,"id":"s","method":"server.status","params":{}})).as_bytes()).await.unwrap();
        let mut reader = BufReader::new(client);
        let mut line = String::new();
        tokio::time::timeout(
            std::time::Duration::from_secs(1),
            reader.read_line(&mut line),
        )
        .await
        .unwrap()
        .unwrap();
        let resp: serde_json::Value = serde_json::from_str(&line).unwrap();
        assert_eq!(resp["id"], "s");
        assert_eq!(resp["ok"], true);
        drop(reader);
        task.await.unwrap().unwrap();
        std::fs::remove_dir_all(base).unwrap();
    }

    #[tokio::test]
    async fn replay_to_live_handoff_delivers_each_event_once() {
        let (ctx, base, _) = test_context();
        let (mut client, server) = UnixStream::pair().unwrap();
        let owned = ctx.clone();
        let task =
            tokio::spawn(
                async move { handle_conn(owned, server).await.map_err(|e| e.to_string()) },
            );
        while ctx.bcast.receiver_count() != 1 {
            tokio::task::yield_now().await;
        }
        for _ in 0..2 {
            ctx.store
                .write()
                .unwrap()
                .emit("test.event", serde_json::Value::Null);
        }
        client.write_all(format!("{}\n",serde_json::json!({"protocol":signaltty_proto::PROTOCOL,"id":"s","method":"subscribe","params":{"events":["test.*"],"from_seq":0}})).as_bytes()).await.unwrap();
        let mut reader = BufReader::new(client);
        let mut line = String::new();
        reader.read_line(&mut line).await.unwrap();
        let ack: serde_json::Value = serde_json::from_str(&line).unwrap();
        assert_eq!(ack["result"]["replay"]["status"], "complete");
        assert_eq!(ack["result"]["seq"], 2);
        for seq in 1..=2 {
            line.clear();
            reader.read_line(&mut line).await.unwrap();
            let event: serde_json::Value = serde_json::from_str(&line).unwrap();
            assert_eq!(event["seq"], seq);
        }
        ctx.store
            .write()
            .unwrap()
            .emit("test.event", serde_json::Value::Null);
        line.clear();
        tokio::time::timeout(
            std::time::Duration::from_secs(1),
            reader.read_line(&mut line),
        )
        .await
        .unwrap()
        .unwrap();
        assert_eq!(
            serde_json::from_str::<serde_json::Value>(&line).unwrap()["seq"],
            3
        );
        drop(reader);
        task.await.unwrap().unwrap();
        std::fs::remove_dir_all(base).unwrap();
    }

    #[test]
    fn dropping_a_connection_unregisters_all_viewers_even_on_error() {
        let store = Arc::new(std::sync::RwLock::new(crate::store::Store::new()));
        let (events, _) = tokio::sync::broadcast::channel(8);
        let ptys = crate::pty::PtyManager::new(store, events, Arc::new(AtomicBool::new(false)));
        ptys.add_viewer("pane");
        // Another connection remains attached to the same pane.
        ptys.add_viewer("pane");
        {
            let _connection = ConnState {
                ptys: ptys.clone(),
                subs: Vec::new(),
                task_ids: None,
                attached: HashSet::from(["pane".to_string()]),
                fence: 0,
                last_received: 0,
            };
        }
        assert_eq!(ptys.viewer_count("pane"), 1);
    }
}