Skip to main content

ninox_core/
client.rs

1//! A hidden tmux client: `tmux attach` running on a PTY pair the app owns.
2//! From tmux's perspective this is a normal terminal, so attach triggers a
3//! full repaint, PTY resize drives window size (window-size latest), and
4//! keyboard bytes written to the master are real terminal input.
5
6use crate::events::{Engine, Event};
7use crate::types::SessionId;
8use anyhow::{Context, Result};
9use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
10use std::sync::Arc;
11use tokio::sync::mpsc;
12
13pub struct AttachedClient {
14    input:  mpsc::UnboundedSender<Vec<u8>>,
15    master: Box<dyn MasterPty + Send>,
16    killer: Box<dyn ChildKiller + Send + Sync>,
17    /// Identity stamped on every event this client's threads emit — lets
18    /// consumers tell this client's events apart from a superseding one
19    /// attached to the same session_id after a stale-close race.
20    pub generation: u64,
21}
22
23impl AttachedClient {
24    /// Spawn `argv` (from `tmux::attach_args`) on a fresh PTY sized
25    /// cols x rows. Output is emitted as `Event::ClientOutput`; exactly one
26    /// `Event::ClientClosed` follows when the process exits. `generation`
27    /// is echoed on both events so a caller can distinguish this client's
28    /// events from a different generation's after a replace/reattach race.
29    pub fn spawn(
30        engine:     Arc<Engine>,
31        session_id: SessionId,
32        argv:       Vec<String>,
33        cols:       u16,
34        rows:       u16,
35        generation: u64,
36    ) -> Result<Self> {
37        anyhow::ensure!(!argv.is_empty(), "attach argv must not be empty");
38        let pair = native_pty_system()
39            .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
40            .context("openpty for attached client")?;
41
42        let mut cmd = CommandBuilder::new(&argv[0]);
43        cmd.args(&argv[1..]);
44        cmd.env("TERM", "xterm-256color");
45        let mut child = pair.slave.spawn_command(cmd).context("spawn tmux attach")?;
46        let mut killer = child.clone_killer();
47        drop(pair.slave);
48
49        // Acquire both PTY handles before spawning any thread. Once a
50        // thread exists it owns `child` and is the sole emitter of the
51        // exactly-once `ClientClosed` event; if we spawned the reader
52        // first and then failed to get the writer, a caller retry after
53        // our Err would leave that thread running and orphan a second
54        // reader on top of it. So: get both handles, and if either is
55        // missing, kill+reap the child ourselves since no thread exists
56        // yet to do it.
57        let reader = pair.master.try_clone_reader();
58        let writer = pair.master.take_writer();
59        let (mut reader, mut writer) = match (reader, writer) {
60            (Ok(r), Ok(w)) => (r, w),
61            (r, w) => {
62                // Never leak the attach process: no thread owns it yet.
63                let _ = killer.kill();
64                let _ = child.wait();
65                r.context("clone PTY reader")?;
66                w.context("take PTY writer")?;
67                unreachable!("one of reader/writer must have errored");
68            }
69        };
70
71        // Reader thread: PTY master → ClientOutput events. Blocking reads on
72        // a dedicated thread; Engine::emit is sync so no runtime needed here.
73        let engine_out = engine.clone();
74        let sid = session_id.clone();
75        std::thread::spawn(move || {
76            let mut buf = [0u8; 8192];
77            loop {
78                match std::io::Read::read(&mut reader, &mut buf) {
79                    Ok(0) => break,
80                    // A signal interrupted the blocking read (e.g. SIGWINCH
81                    // from a concurrent resize) — not a real error, retry
82                    // rather than tearing down the client and emitting a
83                    // spurious ClientClosed.
84                    Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
85                    Err(_) => break,
86                    Ok(n) => engine_out.emit(Event::ClientOutput {
87                        session_id: sid.clone(),
88                        generation,
89                        bytes:      buf[..n].to_vec(),
90                    }),
91                }
92            }
93            let _ = child.wait();
94            engine_out.emit(Event::ClientClosed { session_id: sid, generation });
95        });
96
97        // Writer thread: mpsc → PTY master (real keyboard input path).
98        let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
99        std::thread::spawn(move || {
100            use std::io::Write;
101            while let Some(bytes) = input_rx.blocking_recv() {
102                if writer.write_all(&bytes).is_err() { break; }
103                let _ = writer.flush();
104            }
105        });
106
107        Ok(Self { input: input_tx, master: pair.master, killer, generation })
108    }
109
110    pub fn write(&self, bytes: Vec<u8>) {
111        let _ = self.input.send(bytes);
112    }
113
114    /// A cloneable sender for the input path — handed to the emulator's
115    /// event proxy so query responses (DSR, DA, kitty) reach the PTY.
116    pub fn input_sender(&self) -> mpsc::UnboundedSender<Vec<u8>> {
117        self.input.clone()
118    }
119
120    /// Resize the client PTY; tmux follows via `window-size latest` and
121    /// repaints — no explicit resize-window call needed.
122    pub fn resize(&self, cols: u16, rows: u16) {
123        let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
124    }
125}
126
127impl Drop for AttachedClient {
128    fn drop(&mut self) {
129        let _ = self.killer.kill();
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::{events::Event, store::Store, tmux};
137    use std::sync::Arc;
138    use tempfile::tempdir;
139    use tokio::time::{sleep, Duration};
140
141    fn tmux_available() -> bool {
142        std::process::Command::new("tmux").args(["-V"]).output()
143            .map(|o| o.status.success()).unwrap_or(false)
144    }
145
146    fn unique_id() -> String {
147        // Millis alone collide when parallel test threads start within the
148        // same tick, producing duplicate tmux session names; a per-process
149        // counter guarantees uniqueness regardless of clock resolution.
150        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
151        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
152        let millis = std::time::SystemTime::now()
153            .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis();
154        format!("ac-{millis}-{n}")
155    }
156
157    fn test_engine() -> Arc<crate::events::Engine> {
158        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
159        crate::events::Engine::new(store)
160    }
161
162    async fn collect_client_output(
163        rx: &mut tokio::sync::broadcast::Receiver<Event>,
164        session_id: &str,
165        timeout_ms: u64,
166    ) -> Vec<u8> {
167        let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
168        let mut all = Vec::new();
169        loop {
170            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
171            if remaining.is_zero() { break; }
172            match tokio::time::timeout(remaining, rx.recv()).await {
173                Ok(Ok(Event::ClientOutput { session_id: sid, bytes, .. })) if sid == session_id => {
174                    all.extend_from_slice(&bytes);
175                }
176                Ok(_) => {}
177                Err(_) => break,
178            }
179        }
180        all
181    }
182
183    #[tokio::test]
184    async fn attach_repaints_and_round_trips_input() {
185        if !tmux_available() { return; }
186        let id = unique_id();
187        let engine = test_engine();
188        let mut rx = engine.subscribe();
189
190        tmux::create_session(&id, "/tmp", "bash", &[]).await.unwrap();
191        sleep(Duration::from_millis(300)).await;
192
193        let argv = tmux::attach_args(&id).await;
194        let client = AttachedClient::spawn(engine.clone(), id.clone(), argv, 100, 30, 1).unwrap();
195
196        // Attach must repaint the current screen (shell prompt) unprompted.
197        let repaint = collect_client_output(&mut rx, &id, 2000).await;
198        assert!(!repaint.is_empty(), "attach should trigger a full repaint");
199
200        // Input written to the client PTY reaches the inner shell.
201        client.write(b"echo attach-round-trip\r".to_vec());
202        let out = collect_client_output(&mut rx, &id, 3000).await;
203        let text = String::from_utf8_lossy(&out);
204        assert!(text.contains("attach-round-trip"), "echo output missing: {text}");
205
206        drop(client);
207        tmux::kill_session(&id).await.unwrap();
208    }
209
210    #[tokio::test]
211    async fn resize_drives_tmux_window_size() {
212        if !tmux_available() { return; }
213        let id = unique_id();
214        let engine = test_engine();
215        tmux::create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
216        sleep(Duration::from_millis(300)).await;
217
218        let argv = tmux::attach_args(&id).await;
219        let client = AttachedClient::spawn(engine.clone(), id.clone(), argv, 97, 41, 1).unwrap();
220        sleep(Duration::from_millis(500)).await;
221
222        // window-size latest: the window follows the (only) client's PTY size.
223        let out = tokio::process::Command::new("tmux")
224            .args(["-L", "ninox", "display-message", "-p", "-t", &id, "#{window_width}x#{window_height}"])
225            .output().await.unwrap();
226        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "97x41");
227
228        client.resize(80, 24);
229        sleep(Duration::from_millis(500)).await;
230        let out = tokio::process::Command::new("tmux")
231            .args(["-L", "ninox", "display-message", "-p", "-t", &id, "#{window_width}x#{window_height}"])
232            .output().await.unwrap();
233        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "80x24");
234
235        drop(client);
236        tmux::kill_session(&id).await.unwrap();
237    }
238
239    #[tokio::test]
240    async fn shift_enter_csi_u_reaches_kitty_enabled_app() {
241        if !tmux_available() { return; }
242        // extended-keys-format csi-u (how tmux disambiguates Shift+Enter for
243        // a forwarded CSI-u sequence) does not exist before tmux 3.5 — our
244        // managed config omits it there (see tmux::server_config_for_version),
245        // so there's nothing this test can exercise. Skip with a clear
246        // reason rather than failing on tmux that genuinely can't do this.
247        let version = tmux::detected_version_sync();
248        if version < (3, 5) {
249            eprintln!(
250                "skipping shift_enter_csi_u_reaches_kitty_enabled_app: tmux {version:?} \
251                 predates extended-keys-format (needs >= 3.5)"
252            );
253            return;
254        }
255        let id = unique_id();
256        let engine = test_engine();
257        let mut rx = engine.subscribe();
258
259        // Inner app enables the kitty keyboard protocol (CSI > 1 u), then
260        // echoes every byte it receives with escapes made visible. With
261        // extended-keys always + csi-u format, tmux must forward our
262        // Shift+Enter encoding to it intact.
263        let cmd = r#"bash -c 'stty -echo -icanon; printf "\033[>1u"; cat -v'"#;
264        tmux::create_session(&id, "/tmp", cmd, &[]).await.unwrap();
265        sleep(Duration::from_millis(400)).await;
266
267        let argv = tmux::attach_args(&id).await;
268        let client = AttachedClient::spawn(engine.clone(), id.clone(), argv, 100, 30, 1).unwrap();
269        sleep(Duration::from_millis(400)).await;
270
271        client.write(b"\x1b[13;2u".to_vec()); // Shift+Enter, CSI-u encoded
272        let out = collect_client_output(&mut rx, &id, 3000).await;
273        let text = String::from_utf8_lossy(&out);
274        // cat -v renders ESC as ^[ — the CSI-u sequence must survive intact.
275        assert!(text.contains("^[[13;2u"),
276                "Shift+Enter did not reach the kitty-enabled app as CSI-u: {text}");
277
278        drop(client);
279        tmux::kill_session(&id).await.unwrap();
280    }
281
282    #[tokio::test]
283    async fn client_closed_emitted_when_session_killed() {
284        if !tmux_available() { return; }
285        let id = unique_id();
286        let engine = test_engine();
287        let mut rx = engine.subscribe();
288        tmux::create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
289        sleep(Duration::from_millis(300)).await;
290
291        let argv = tmux::attach_args(&id).await;
292        let expected_generation = 99;
293        let _client = AttachedClient::spawn(
294            engine.clone(), id.clone(), argv, 80, 24, expected_generation,
295        ).unwrap();
296        sleep(Duration::from_millis(300)).await;
297        tmux::kill_session(&id).await.unwrap();
298
299        let deadline = tokio::time::Instant::now() + Duration::from_millis(3000);
300        loop {
301            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
302            assert!(!remaining.is_zero(), "ClientClosed never arrived");
303            if let Ok(Ok(Event::ClientClosed { session_id, generation })) =
304                tokio::time::timeout(remaining, rx.recv()).await
305            {
306                if session_id == id {
307                    assert_eq!(generation, expected_generation, "generation must round-trip on ClientClosed");
308                    break;
309                }
310            }
311        }
312    }
313}