Skip to main content

rmux_client/
attach.rs

1//! Raw terminal lifecycle and attach-stream helpers for attach-mode clients.
2
3use std::io::{self, Read, Write};
4use std::net::Shutdown;
5use std::os::fd::AsFd;
6use std::os::unix::net::UnixStream;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{mpsc, Arc};
9use std::thread;
10use std::time::Duration;
11
12use rmux_proto::{
13    encode_attach_message, AttachFrameDecoder, AttachMessage, AttachedKeystroke, RmuxError,
14    TerminalGeometry, TerminalSize,
15};
16use rustix::event::{poll, PollFd, PollFlags, Timespec};
17use rustix::process::{kill_process, Signal};
18
19use crate::ClientError;
20
21#[path = "attach/resize.rs"]
22mod resize;
23#[path = "attach/screen.rs"]
24mod screen;
25#[path = "attach/terminal.rs"]
26mod terminal;
27#[path = "attach/terminal_cleanup.rs"]
28mod terminal_cleanup;
29
30#[cfg(test)]
31use resize::terminal_size_from_fd;
32use resize::{terminal_geometry_from_fd, ResizeWatcher, SignalMaskGuard};
33use screen::{
34    contains_subslice, AttachScreenTracker, AttachStopDetector, ALT_SCREEN_EXIT_FALLBACK,
35    DETACHED_BANNER_PREFIX, EXITED_BANNER,
36};
37use terminal::current_process_pid;
38pub use terminal::{AttachError, RawTerminal, Result};
39
40#[cfg(test)]
41use terminal_cleanup::fallback_attach_stop_sequence;
42
43const READ_BUFFER_SIZE: usize = 8192;
44const POLL_TIMEOUT: Timespec = Timespec {
45    tv_sec: 0,
46    tv_nsec: 100_000_000,
47};
48
49/// Runs the attach loop using the process stdin/stdout streams.
50pub fn attach_terminal(stream: UnixStream) -> std::result::Result<(), ClientError> {
51    attach_terminal_with_initial_bytes(stream, Vec::new())
52}
53
54/// Runs the attach loop using process stdin/stdout and pre-read stream bytes.
55pub fn attach_terminal_with_initial_bytes(
56    stream: UnixStream,
57    initial_bytes: Vec<u8>,
58) -> std::result::Result<(), ClientError> {
59    attach_terminal_with_initial_bytes_and_geometry_flag(stream, initial_bytes, false)
60}
61
62/// Runs the attach loop and sends resize events with pixel geometry.
63///
64/// Call this only after the daemon advertises the
65/// `stream.attach.resize_geometry` capability. Older daemons do not understand
66/// that attach-stream frame and would close the stream on decode.
67pub fn attach_terminal_with_initial_bytes_and_resize_geometry(
68    stream: UnixStream,
69    initial_bytes: Vec<u8>,
70) -> std::result::Result<(), ClientError> {
71    attach_terminal_with_initial_bytes_and_geometry_flag(stream, initial_bytes, true)
72}
73
74fn attach_terminal_with_initial_bytes_and_geometry_flag(
75    stream: UnixStream,
76    initial_bytes: Vec<u8>,
77    resize_geometry_enabled: bool,
78) -> std::result::Result<(), ClientError> {
79    let terminal = io::stdin();
80    let input = io::stdin();
81    let output = io::stdout();
82
83    attach_with_terminal_with_initial_bytes(
84        stream,
85        initial_bytes,
86        &terminal,
87        input,
88        output,
89        resize_geometry_enabled,
90    )
91}
92
93/// Runs the attach loop with an explicit terminal file descriptor.
94///
95/// The `terminal` handle is used for raw-mode lifecycle and resize discovery,
96/// while `input` and `output` carry the byte stream.
97pub fn attach_with_terminal<Terminal, Input, Output>(
98    stream: UnixStream,
99    terminal: &Terminal,
100    input: Input,
101    output: Output,
102) -> std::result::Result<(), ClientError>
103where
104    Terminal: AsFd,
105    Input: Read + AsFd + Send + 'static,
106    Output: Write + Send + 'static,
107{
108    attach_with_terminal_with_initial_bytes(stream, Vec::new(), terminal, input, output, false)
109}
110
111fn attach_with_terminal_with_initial_bytes<Terminal, Input, Output>(
112    stream: UnixStream,
113    initial_bytes: Vec<u8>,
114    terminal: &Terminal,
115    input: Input,
116    output: Output,
117    resize_geometry_enabled: bool,
118) -> std::result::Result<(), ClientError>
119where
120    Terminal: AsFd,
121    Input: Read + AsFd + Send + 'static,
122    Output: Write + Send + 'static,
123{
124    let raw_terminal = RawTerminal::from_fd(terminal).map_err(ClientError::from)?;
125    let _ = raw_terminal.flush_pending_input();
126    let screen_tracker = AttachScreenTracker::default();
127    let attach_state = AttachTerminalState {
128        stream,
129        initial_bytes,
130        terminal,
131        raw_terminal: &raw_terminal,
132        screen_tracker: &screen_tracker,
133        resize_geometry_enabled,
134    };
135    let result = drive_attach_with_terminal_state(attach_state, input, output);
136    if result.is_err() && !screen_tracker.was_stopped() {
137        let _ = raw_terminal.restore_attach_terminal_state();
138    }
139    let _ = raw_terminal.flush_pending_input();
140    drop(raw_terminal);
141    result
142}
143
144struct AttachTerminalState<'a, Terminal> {
145    stream: UnixStream,
146    initial_bytes: Vec<u8>,
147    terminal: &'a Terminal,
148    raw_terminal: &'a RawTerminal,
149    screen_tracker: &'a AttachScreenTracker,
150    resize_geometry_enabled: bool,
151}
152
153struct AttachStreamState<'a> {
154    stream: UnixStream,
155    initial_bytes: Vec<u8>,
156    raw_terminal: Option<&'a RawTerminal>,
157    screen_tracker: AttachScreenTracker,
158    resize_events: mpsc::Receiver<TerminalGeometry>,
159    resize_geometry_enabled: bool,
160}
161
162fn drive_attach_with_terminal_state<Terminal, Input, Output>(
163    state: AttachTerminalState<'_, Terminal>,
164    input: Input,
165    output: Output,
166) -> std::result::Result<(), ClientError>
167where
168    Terminal: AsFd,
169    Input: Read + AsFd + Send + 'static,
170    Output: Write + Send + 'static,
171{
172    // This helper runs while the caller's `RawTerminal` guard is still alive,
173    // which keeps termios restoration as the last drop on every return path.
174    let _signal_mask = SignalMaskGuard::block_winch().map_err(ClientError::from)?;
175    let (resize_tx, resize_rx) = mpsc::channel();
176    let initial_geometry = terminal_geometry_from_fd(state.terminal).map_err(ClientError::from)?;
177    let terminal_fd = state
178        .terminal
179        .as_fd()
180        .try_clone_to_owned()
181        .map_err(AttachError::from)?;
182
183    if let Some(initial_geometry) = initial_geometry {
184        resize_tx.send(initial_geometry).map_err(|_| {
185            ClientError::Io(io::Error::other(
186                "resize channel closed before attach start",
187            ))
188        })?;
189    }
190
191    let resize_watcher = ResizeWatcher::spawn(terminal_fd, resize_tx)?;
192    let stream_state = AttachStreamState {
193        stream: state.stream,
194        initial_bytes: state.initial_bytes,
195        raw_terminal: Some(state.raw_terminal),
196        screen_tracker: state.screen_tracker.clone(),
197        resize_events: resize_rx,
198        resize_geometry_enabled: state.resize_geometry_enabled,
199    };
200    let attach_result = drive_attach_stream_inner(stream_state, input, output);
201    drop(resize_watcher);
202    attach_result
203}
204
205/// Drives raw attach-stream byte forwarding over an upgraded Unix socket.
206pub fn drive_attach_stream<Input, Output>(
207    stream: UnixStream,
208    input: Input,
209    output: Output,
210    resize_events: mpsc::Receiver<TerminalSize>,
211) -> std::result::Result<(), ClientError>
212where
213    Input: Read + AsFd + Send + 'static,
214    Output: Write + Send + 'static,
215{
216    let resize_events = geometry_resize_events_from_size_events(resize_events);
217    let stream_state = AttachStreamState {
218        stream,
219        initial_bytes: Vec::new(),
220        raw_terminal: None,
221        screen_tracker: AttachScreenTracker::default(),
222        resize_events,
223        resize_geometry_enabled: false,
224    };
225    drive_attach_stream_inner(stream_state, input, output)
226}
227
228fn drive_attach_stream_inner<Input, Output>(
229    state: AttachStreamState<'_>,
230    input: Input,
231    output: Output,
232) -> std::result::Result<(), ClientError>
233where
234    Input: Read + AsFd + Send + 'static,
235    Output: Write + Send + 'static,
236{
237    let control = state.stream.try_clone().map_err(ClientError::Io)?;
238    let mut lock_stream = state.stream.try_clone().map_err(ClientError::Io)?;
239    let input_stream = state.stream.try_clone().map_err(ClientError::Io)?;
240    let closed = Arc::new(AtomicBool::new(false));
241    let input_closed = Arc::clone(&closed);
242    let output_closed = Arc::clone(&closed);
243    let locked = Arc::new(AtomicBool::new(false));
244    let input_locked = Arc::clone(&locked);
245    let output_locked = Arc::clone(&locked);
246    let (action_tx, action_rx) = mpsc::channel();
247
248    let input_thread = thread::spawn(move || {
249        input_loop(
250            input_stream,
251            input,
252            state.resize_events,
253            state.resize_geometry_enabled,
254            input_closed,
255            input_locked,
256        )
257    });
258    let output_screen_tracker = state.screen_tracker.clone();
259    let output_thread = thread::spawn(move || {
260        output_loop(
261            state.stream,
262            state.initial_bytes,
263            output,
264            output_closed,
265            output_locked,
266            output_screen_tracker,
267            action_tx,
268        )
269    });
270
271    let output_result = wait_for_output_thread(
272        output_thread,
273        state.raw_terminal,
274        &mut lock_stream,
275        &locked,
276        action_rx,
277    )?;
278    closed.store(true, Ordering::SeqCst);
279    let _ = control.shutdown(Shutdown::Both);
280    let input_result = join_attach_thread(input_thread)?;
281
282    output_result?;
283    input_result
284}
285
286fn geometry_resize_events_from_size_events(
287    resize_events: mpsc::Receiver<TerminalSize>,
288) -> mpsc::Receiver<TerminalGeometry> {
289    let (geometry_tx, geometry_rx) = mpsc::channel();
290    let _forwarder = thread::spawn(move || {
291        while let Ok(size) = resize_events.recv() {
292            if geometry_tx.send(TerminalGeometry::from_size(size)).is_err() {
293                break;
294            }
295        }
296    });
297    geometry_rx
298}
299
300fn input_loop<Input>(
301    mut stream: UnixStream,
302    mut input: Input,
303    resize_events: mpsc::Receiver<TerminalGeometry>,
304    resize_geometry_enabled: bool,
305    closed: Arc<AtomicBool>,
306    locked: Arc<AtomicBool>,
307) -> std::result::Result<(), ClientError>
308where
309    Input: Read + AsFd,
310{
311    let mut read_buffer = [0_u8; READ_BUFFER_SIZE];
312
313    loop {
314        if closed.load(Ordering::SeqCst) {
315            return Ok(());
316        }
317
318        drain_resize_events(&mut stream, &resize_events, resize_geometry_enabled)?;
319        if locked.load(Ordering::SeqCst) {
320            thread::sleep(Duration::from_millis(20));
321            continue;
322        }
323
324        let mut fds = [PollFd::new(
325            &input,
326            PollFlags::IN | PollFlags::ERR | PollFlags::HUP,
327        )];
328        match poll(&mut fds, Some(&POLL_TIMEOUT)) {
329            Ok(0) => continue,
330            Ok(_) => {}
331            Err(rustix::io::Errno::INTR) => continue,
332            Err(error) => return Err(ClientError::Io(error.into())),
333        }
334
335        let ready = fds[0].revents();
336        if ready.is_empty() {
337            continue;
338        }
339        if closed.load(Ordering::SeqCst) {
340            return Ok(());
341        }
342        if !ready.contains(PollFlags::IN) {
343            if ready.contains(PollFlags::HUP) || ready.contains(PollFlags::ERR) {
344                shutdown_attach_writes(&stream)?;
345                return Ok(());
346            }
347            continue;
348        }
349
350        let bytes_read = match input.read(&mut read_buffer) {
351            Ok(0) => {
352                shutdown_attach_writes(&stream)?;
353                return Ok(());
354            }
355            Ok(bytes_read) => bytes_read,
356            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
357            Err(error) => return Err(ClientError::Io(error)),
358        };
359
360        write_attach_message(
361            &mut stream,
362            AttachMessage::Keystroke(AttachedKeystroke::new(read_buffer[..bytes_read].to_vec())),
363        )?;
364    }
365}
366
367fn output_loop<Output>(
368    mut stream: UnixStream,
369    initial_bytes: Vec<u8>,
370    mut output: Output,
371    closed: Arc<AtomicBool>,
372    locked: Arc<AtomicBool>,
373    screen_tracker: AttachScreenTracker,
374    action_tx: mpsc::Sender<ClientAttachAction>,
375) -> std::result::Result<(), ClientError>
376where
377    Output: Write,
378{
379    let mut decoder = AttachFrameDecoder::new();
380    decoder.push_bytes(&initial_bytes);
381    let mut read_buffer = [0_u8; READ_BUFFER_SIZE];
382    let mut stop_detector = AttachStopDetector::new(screen_tracker.clone());
383
384    loop {
385        while let Some(message) = decoder.next_message().map_err(ClientError::from)? {
386            match message {
387                AttachMessage::Data(bytes) => {
388                    if contains_subslice(&bytes, ALT_SCREEN_EXIT_FALLBACK)
389                        || contains_subslice(&bytes, DETACHED_BANNER_PREFIX)
390                        || contains_subslice(&bytes, EXITED_BANNER)
391                    {
392                        screen_tracker.mark_stopped();
393                    }
394                    stop_detector.observe(&bytes);
395                    if locked.load(Ordering::SeqCst) {
396                        continue;
397                    }
398                    output.write_all(&bytes).map_err(ClientError::Io)?;
399                    output.flush().map_err(ClientError::Io)?;
400                }
401                AttachMessage::KeyDispatched(_) => {}
402                AttachMessage::Resize(_) | AttachMessage::ResizeGeometry(_) => {
403                    return Err(ClientError::Protocol(RmuxError::Decode(
404                        "received unexpected resize message from attach stream".to_owned(),
405                    )));
406                }
407                AttachMessage::Lock(command) => {
408                    locked.store(true, Ordering::SeqCst);
409                    action_tx
410                        .send(ClientAttachAction::Lock(command))
411                        .map_err(|_| {
412                            ClientError::Io(io::Error::other("lock request receiver closed"))
413                        })?;
414                }
415                AttachMessage::LockShellCommand(command) => {
416                    locked.store(true, Ordering::SeqCst);
417                    action_tx
418                        .send(ClientAttachAction::Lock(command.command().to_owned()))
419                        .map_err(|_| {
420                            ClientError::Io(io::Error::other("lock request receiver closed"))
421                        })?;
422                }
423                AttachMessage::Suspend => {
424                    locked.store(true, Ordering::SeqCst);
425                    action_tx.send(ClientAttachAction::Suspend).map_err(|_| {
426                        ClientError::Io(io::Error::other("suspend request receiver closed"))
427                    })?;
428                }
429                AttachMessage::DetachKill => {
430                    closed.store(true, Ordering::SeqCst);
431                    action_tx
432                        .send(ClientAttachAction::DetachKill)
433                        .map_err(|_| {
434                            ClientError::Io(io::Error::other("detach request receiver closed"))
435                        })?;
436                    return Ok(());
437                }
438                AttachMessage::DetachExec(command) => {
439                    closed.store(true, Ordering::SeqCst);
440                    action_tx
441                        .send(ClientAttachAction::DetachExec(command))
442                        .map_err(|_| {
443                            ClientError::Io(io::Error::other("detach request receiver closed"))
444                        })?;
445                    return Ok(());
446                }
447                AttachMessage::DetachExecShellCommand(command) => {
448                    closed.store(true, Ordering::SeqCst);
449                    action_tx
450                        .send(ClientAttachAction::DetachExec(command.command().to_owned()))
451                        .map_err(|_| {
452                            ClientError::Io(io::Error::other("detach request receiver closed"))
453                        })?;
454                    return Ok(());
455                }
456                AttachMessage::Unlock => {
457                    return Err(ClientError::Protocol(RmuxError::Decode(
458                        "received unexpected unlock message from attach stream".to_owned(),
459                    )));
460                }
461                AttachMessage::Keystroke(_) => {
462                    return Err(ClientError::Protocol(RmuxError::Decode(
463                        "received unexpected keystroke message from attach stream".to_owned(),
464                    )));
465                }
466            }
467        }
468
469        let bytes_read = match stream.read(&mut read_buffer) {
470            Ok(0) => {
471                closed.store(true, Ordering::SeqCst);
472                if screen_tracker.was_stopped() {
473                    return Ok(());
474                }
475                return Err(ClientError::Io(io::Error::new(
476                    io::ErrorKind::UnexpectedEof,
477                    "attach stream closed before attach-stop sequence",
478                )));
479            }
480            Ok(bytes_read) => bytes_read,
481            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
482            Err(error)
483                if screen_tracker.was_stopped()
484                    && matches!(
485                        error.kind(),
486                        io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
487                    ) =>
488            {
489                return Ok(());
490            }
491            Err(error) => return Err(ClientError::Io(error)),
492        };
493
494        decoder.push_bytes(&read_buffer[..bytes_read]);
495    }
496}
497
498fn wait_for_output_thread(
499    output_thread: thread::JoinHandle<std::result::Result<(), ClientError>>,
500    raw_terminal: Option<&RawTerminal>,
501    lock_stream: &mut UnixStream,
502    locked: &Arc<AtomicBool>,
503    action_rx: mpsc::Receiver<ClientAttachAction>,
504) -> std::result::Result<std::result::Result<(), ClientError>, ClientError> {
505    loop {
506        match action_rx.recv_timeout(Duration::from_millis(20)) {
507            Ok(action) => handle_attach_action(raw_terminal, lock_stream, locked, action)?,
508            Err(mpsc::RecvTimeoutError::Timeout) if output_thread.is_finished() => break,
509            Err(mpsc::RecvTimeoutError::Timeout) => {}
510            Err(mpsc::RecvTimeoutError::Disconnected) => break,
511        }
512    }
513
514    while let Ok(action) = action_rx.try_recv() {
515        handle_attach_action(raw_terminal, lock_stream, locked, action)?;
516    }
517
518    join_attach_thread(output_thread)
519}
520
521fn handle_attach_action(
522    raw_terminal: Option<&RawTerminal>,
523    lock_stream: &mut UnixStream,
524    locked: &Arc<AtomicBool>,
525    action: ClientAttachAction,
526) -> std::result::Result<(), ClientError> {
527    match action {
528        ClientAttachAction::Lock(command) => {
529            let Some(raw_terminal) = raw_terminal else {
530                locked.store(false, Ordering::SeqCst);
531                return Err(ClientError::Protocol(RmuxError::Decode(
532                    "received unexpected lock request without a managed terminal".to_owned(),
533                )));
534            };
535            raw_terminal
536                .run_lock_command(&command)
537                .map_err(ClientError::from)?;
538            write_attach_message(lock_stream, AttachMessage::Unlock)?;
539            locked.store(false, Ordering::SeqCst);
540            Ok(())
541        }
542        ClientAttachAction::Suspend => {
543            let Some(raw_terminal) = raw_terminal else {
544                locked.store(false, Ordering::SeqCst);
545                return Err(ClientError::Protocol(RmuxError::Decode(
546                    "received unexpected suspend request without a managed terminal".to_owned(),
547                )));
548            };
549            raw_terminal.suspend_self().map_err(ClientError::from)?;
550            write_attach_message(lock_stream, AttachMessage::Unlock)?;
551            locked.store(false, Ordering::SeqCst);
552            Ok(())
553        }
554        ClientAttachAction::DetachKill => {
555            if let Some(raw_terminal) = raw_terminal {
556                raw_terminal.restore().map_err(ClientError::from)?;
557            }
558            kill_process(current_process_pid().map_err(ClientError::Io)?, Signal::HUP)
559                .map_err(|error| ClientError::Io(error.into()))?;
560            Ok(())
561        }
562        ClientAttachAction::DetachExec(command) => {
563            let Some(raw_terminal) = raw_terminal else {
564                return Err(ClientError::Protocol(RmuxError::Decode(
565                    "received unexpected detach exec request without a managed terminal".to_owned(),
566                )));
567            };
568            raw_terminal
569                .run_detach_exec_command(&command)
570                .map_err(ClientError::from)
571        }
572    }
573}
574
575fn drain_resize_events(
576    stream: &mut UnixStream,
577    resize_events: &mpsc::Receiver<TerminalGeometry>,
578    resize_geometry_enabled: bool,
579) -> std::result::Result<(), ClientError> {
580    while let Ok(geometry) = resize_events.try_recv() {
581        let message = if resize_geometry_enabled && geometry.pixels.is_some() {
582            AttachMessage::ResizeGeometry(geometry)
583        } else {
584            AttachMessage::Resize(geometry.size)
585        };
586        write_attach_message(stream, message)?;
587    }
588
589    Ok(())
590}
591
592fn write_attach_message(
593    stream: &mut UnixStream,
594    message: AttachMessage,
595) -> std::result::Result<(), ClientError> {
596    let frame = encode_attach_message(&message).map_err(ClientError::from)?;
597    stream.write_all(&frame).map_err(ClientError::Io)
598}
599
600fn join_attach_thread(
601    thread: thread::JoinHandle<std::result::Result<(), ClientError>>,
602) -> std::result::Result<std::result::Result<(), ClientError>, ClientError> {
603    thread
604        .join()
605        .map_err(|_| ClientError::Io(io::Error::other("attach thread panicked")))
606}
607
608fn shutdown_attach_writes(stream: &UnixStream) -> std::result::Result<(), ClientError> {
609    match stream.shutdown(Shutdown::Write) {
610        Ok(()) => Ok(()),
611        Err(error) if error.kind() == io::ErrorKind::NotConnected => Ok(()),
612        Err(error) => Err(ClientError::Io(error)),
613    }
614}
615
616#[derive(Debug)]
617enum ClientAttachAction {
618    Lock(String),
619    Suspend,
620    DetachKill,
621    DetachExec(String),
622}
623
624#[cfg(test)]
625mod tests;