1use 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 pub generation: u64,
21}
22
23impl AttachedClient {
24 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 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 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 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 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 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 pub fn input_sender(&self) -> mpsc::UnboundedSender<Vec<u8>> {
117 self.input.clone()
118 }
119
120 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 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 let repaint = collect_client_output(&mut rx, &id, 2000).await;
198 assert!(!repaint.is_empty(), "attach should trigger a full repaint");
199
200 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 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 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 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()); let out = collect_client_output(&mut rx, &id, 3000).await;
273 let text = String::from_utf8_lossy(&out);
274 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}