Skip to main content

rmux_client/
attach.rs

1//! Raw terminal lifecycle and attach-stream helpers for attach-mode clients.
2
3use std::fs::File;
4use std::io::{self, Read, Write};
5use std::net::Shutdown;
6use std::os::fd::AsFd;
7use std::os::unix::net::UnixStream;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{mpsc, Arc};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use rmux_proto::{
14    decode_attach_data_frame, encode_attach_data, encode_attach_data_into_slice,
15    encode_attach_message, AttachFrameDecoder, AttachMessage, AttachShellCommand, RmuxError,
16    TerminalGeometry, TerminalSize, ATTACH_DATA_HEADER_LEN,
17};
18use rustix::event::{poll, PollFd, PollFlags, Timespec};
19use rustix::process::{kill_process, Signal};
20
21use crate::attach_lock_state::AttachLockState;
22use crate::ClientError;
23
24#[path = "attach/render_drain.rs"]
25mod render_drain;
26#[path = "attach/resize.rs"]
27mod resize;
28#[path = "attach/screen.rs"]
29mod screen;
30#[path = "attach/terminal.rs"]
31mod terminal;
32#[path = "attach/terminal_cleanup.rs"]
33mod terminal_cleanup;
34#[path = "attach/termination.rs"]
35mod termination;
36
37use render_drain::{drain_available_attach_stream, flush_pending_render};
38#[cfg(test)]
39use resize::terminal_size_from_fd;
40use resize::{terminal_geometry_from_fd, ResizeWatcher, SignalMaskGuard};
41use screen::{AttachScreenTracker, AttachStopDetector, AttachStopGeneration};
42use terminal::current_process_pid;
43pub use terminal::{AttachError, RawTerminal, Result};
44use termination::AttachTerminationGuard;
45
46#[cfg(test)]
47use terminal_cleanup::fallback_attach_stop_sequence;
48
49const READ_BUFFER_SIZE: usize = 8192;
50const STACK_ATTACH_DATA_PAYLOAD: usize = 1024;
51const POLL_TIMEOUT: Timespec = Timespec {
52    tv_sec: 0,
53    tv_nsec: 100_000_000,
54};
55const RENDER_MAX_PENDING: Duration = Duration::from_millis(8);
56const TERMINATION_POLL_INTERVAL: Duration = Duration::from_millis(100);
57const TERMINATION_OUTPUT_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(500);
58
59/// Runs the attach loop using the process stdin/stdout streams.
60pub fn attach_terminal(stream: UnixStream) -> std::result::Result<(), ClientError> {
61    attach_terminal_with_initial_bytes(stream, Vec::new())
62}
63
64/// Runs the attach loop using process stdin/stdout and pre-read stream bytes.
65pub fn attach_terminal_with_initial_bytes(
66    stream: UnixStream,
67    initial_bytes: Vec<u8>,
68) -> std::result::Result<(), ClientError> {
69    attach_terminal_with_initial_bytes_and_geometry_flag(stream, initial_bytes, false)
70}
71
72/// Runs the attach loop and sends resize events with pixel geometry.
73///
74/// Call this only after the daemon advertises the
75/// `stream.attach.resize_geometry` capability. Older daemons do not understand
76/// that attach-stream frame and would close the stream on decode.
77pub fn attach_terminal_with_initial_bytes_and_resize_geometry(
78    stream: UnixStream,
79    initial_bytes: Vec<u8>,
80) -> std::result::Result<(), ClientError> {
81    attach_terminal_with_initial_bytes_and_geometry_flag(stream, initial_bytes, true)
82}
83
84fn attach_terminal_with_initial_bytes_and_geometry_flag(
85    stream: UnixStream,
86    initial_bytes: Vec<u8>,
87    resize_geometry_enabled: bool,
88) -> std::result::Result<(), ClientError> {
89    let terminal = io::stdin();
90    let input = io::stdin();
91    let output = File::from(
92        io::stdout()
93            .as_fd()
94            .try_clone_to_owned()
95            .map_err(AttachError::from)?,
96    );
97
98    attach_with_terminal_with_initial_bytes(
99        stream,
100        initial_bytes,
101        &terminal,
102        input,
103        output,
104        resize_geometry_enabled,
105    )
106}
107
108/// Runs the attach loop with an explicit terminal file descriptor.
109///
110/// The `terminal` handle is used for raw-mode lifecycle and resize discovery,
111/// while `input` and `output` carry the byte stream.
112pub fn attach_with_terminal<Terminal, Input, Output>(
113    stream: UnixStream,
114    terminal: &Terminal,
115    input: Input,
116    output: Output,
117) -> std::result::Result<(), ClientError>
118where
119    Terminal: AsFd,
120    Input: Read + AsFd + Send + 'static,
121    Output: Write + Send + 'static,
122{
123    attach_with_terminal_with_initial_bytes(stream, Vec::new(), terminal, input, output, false)
124}
125
126fn attach_with_terminal_with_initial_bytes<Terminal, Input, Output>(
127    stream: UnixStream,
128    initial_bytes: Vec<u8>,
129    terminal: &Terminal,
130    input: Input,
131    output: Output,
132    resize_geometry_enabled: bool,
133) -> std::result::Result<(), ClientError>
134where
135    Terminal: AsFd,
136    Input: Read + AsFd + Send + 'static,
137    Output: Write + Send + 'static,
138{
139    let termination_guard = AttachTerminationGuard::install().map_err(ClientError::Io)?;
140    let raw_terminal = RawTerminal::from_fd(terminal).map_err(ClientError::from)?;
141    let _ = raw_terminal.flush_pending_input();
142    let screen_tracker = AttachScreenTracker::default();
143    let attach_state = AttachTerminalState {
144        stream,
145        initial_bytes,
146        terminal,
147        raw_terminal: &raw_terminal,
148        screen_tracker: &screen_tracker,
149        resize_geometry_enabled,
150        termination_signals_enabled: true,
151    };
152    let result = drive_attach_with_terminal_state(attach_state, input, output);
153    if result.is_err() {
154        if termination::was_requested() {
155            let _ = raw_terminal.restore_after_termination();
156        } else if !screen_tracker.was_stopped() {
157            let _ = raw_terminal.restore_attach_terminal_state();
158        }
159    }
160    let _ = raw_terminal.flush_pending_input();
161    drop(raw_terminal);
162    termination_guard.finish().map_err(ClientError::Io)?;
163    result
164}
165
166struct AttachTerminalState<'a, Terminal> {
167    stream: UnixStream,
168    initial_bytes: Vec<u8>,
169    terminal: &'a Terminal,
170    raw_terminal: &'a RawTerminal,
171    screen_tracker: &'a AttachScreenTracker,
172    resize_geometry_enabled: bool,
173    termination_signals_enabled: bool,
174}
175
176struct AttachStreamState<'a> {
177    stream: UnixStream,
178    initial_bytes: Vec<u8>,
179    raw_terminal: Option<&'a RawTerminal>,
180    screen_tracker: AttachScreenTracker,
181    resize_events: mpsc::Receiver<TerminalGeometry>,
182    resize_geometry_enabled: bool,
183    termination_signals_enabled: bool,
184}
185
186struct AttachInputReadLease<'a> {
187    state: &'a AttachLockState,
188}
189
190impl<'a> AttachInputReadLease<'a> {
191    fn acquire(state: &'a AttachLockState) -> Option<Self> {
192        state.begin_input_read().then_some(Self { state })
193    }
194}
195
196impl Drop for AttachInputReadLease<'_> {
197    fn drop(&mut self) {
198        self.state.finish_input_read();
199    }
200}
201
202fn drive_attach_with_terminal_state<Terminal, Input, Output>(
203    state: AttachTerminalState<'_, Terminal>,
204    input: Input,
205    output: Output,
206) -> std::result::Result<(), ClientError>
207where
208    Terminal: AsFd,
209    Input: Read + AsFd + Send + 'static,
210    Output: Write + Send + 'static,
211{
212    // This helper runs while the caller's `RawTerminal` guard is still alive,
213    // which keeps termios restoration as the last drop on every return path.
214    let _signal_mask = SignalMaskGuard::block_winch().map_err(ClientError::from)?;
215    let (resize_tx, resize_rx) = mpsc::channel();
216    let initial_geometry = terminal_geometry_from_fd(state.terminal).map_err(ClientError::from)?;
217    let terminal_fd = state
218        .terminal
219        .as_fd()
220        .try_clone_to_owned()
221        .map_err(AttachError::from)?;
222
223    if let Some(initial_geometry) = initial_geometry {
224        resize_tx.send(initial_geometry).map_err(|_| {
225            ClientError::Io(io::Error::other(
226                "resize channel closed before attach start",
227            ))
228        })?;
229    }
230
231    let resize_watcher = ResizeWatcher::spawn(terminal_fd, resize_tx)?;
232    let stream_state = AttachStreamState {
233        stream: state.stream,
234        initial_bytes: state.initial_bytes,
235        raw_terminal: Some(state.raw_terminal),
236        screen_tracker: state.screen_tracker.clone(),
237        resize_events: resize_rx,
238        resize_geometry_enabled: state.resize_geometry_enabled,
239        termination_signals_enabled: state.termination_signals_enabled,
240    };
241    let attach_result = drive_attach_stream_inner(stream_state, input, output);
242    drop(resize_watcher);
243    attach_result
244}
245
246/// Drives raw attach-stream byte forwarding over an upgraded Unix socket.
247pub fn drive_attach_stream<Input, Output>(
248    stream: UnixStream,
249    input: Input,
250    output: Output,
251    resize_events: mpsc::Receiver<TerminalSize>,
252) -> std::result::Result<(), ClientError>
253where
254    Input: Read + AsFd + Send + 'static,
255    Output: Write + Send + 'static,
256{
257    let resize_events = geometry_resize_events_from_size_events(resize_events);
258    let stream_state = AttachStreamState {
259        stream,
260        initial_bytes: Vec::new(),
261        raw_terminal: None,
262        screen_tracker: AttachScreenTracker::default(),
263        resize_events,
264        resize_geometry_enabled: false,
265        termination_signals_enabled: false,
266    };
267    drive_attach_stream_inner(stream_state, input, output)
268}
269
270fn drive_attach_stream_inner<Input, Output>(
271    state: AttachStreamState<'_>,
272    input: Input,
273    output: Output,
274) -> std::result::Result<(), ClientError>
275where
276    Input: Read + AsFd + Send + 'static,
277    Output: Write + Send + 'static,
278{
279    let control = state.stream.try_clone().map_err(ClientError::Io)?;
280    let mut lock_stream = state.stream.try_clone().map_err(ClientError::Io)?;
281    let input_stream = state.stream.try_clone().map_err(ClientError::Io)?;
282    let (input_wakeup, wake_input_thread) = UnixStream::pair().map_err(ClientError::Io)?;
283    let closed = Arc::new(AtomicBool::new(false));
284    let input_closed = Arc::clone(&closed);
285    let output_closed = Arc::clone(&closed);
286    let locked = Arc::new(AttachLockState::default());
287    let input_locked = Arc::clone(&locked);
288    let output_locked = Arc::clone(&locked);
289    let (event_tx, event_rx) = mpsc::channel();
290
291    let input_thread = thread::spawn(move || {
292        input_loop(
293            input_stream,
294            input,
295            state.resize_events,
296            state.resize_geometry_enabled,
297            input_closed,
298            input_locked,
299            wake_input_thread,
300        )
301    });
302    let output_screen_tracker = state.screen_tracker.clone();
303    let action_screen_tracker = state.screen_tracker.clone();
304    let output_thread = thread::spawn(move || {
305        let result = output_loop_with_termination(
306            state.stream,
307            state.initial_bytes,
308            output,
309            output_closed,
310            output_locked,
311            output_screen_tracker,
312            event_tx.clone(),
313            state.termination_signals_enabled,
314        );
315        let _ = event_tx.send(ClientAttachEvent::OutputDone);
316        result
317    });
318
319    let output_result = wait_for_output_thread(
320        output_thread,
321        state.raw_terminal,
322        &mut lock_stream,
323        &locked,
324        &action_screen_tracker,
325        event_rx,
326        state.termination_signals_enabled,
327    );
328    locked.close();
329    closed.store(true, Ordering::SeqCst);
330    let _ = control.shutdown(Shutdown::Both);
331    let _ = input_wakeup.shutdown(Shutdown::Both);
332    let input_result = join_attach_thread(input_thread)?;
333
334    let output_result = output_result?;
335    output_result?;
336    input_result
337}
338
339fn geometry_resize_events_from_size_events(
340    resize_events: mpsc::Receiver<TerminalSize>,
341) -> mpsc::Receiver<TerminalGeometry> {
342    let (geometry_tx, geometry_rx) = mpsc::channel();
343    let _forwarder = thread::spawn(move || {
344        while let Ok(size) = resize_events.recv() {
345            if geometry_tx.send(TerminalGeometry::from_size(size)).is_err() {
346                break;
347            }
348        }
349    });
350    geometry_rx
351}
352
353fn input_loop<Input>(
354    mut stream: UnixStream,
355    mut input: Input,
356    resize_events: mpsc::Receiver<TerminalGeometry>,
357    resize_geometry_enabled: bool,
358    closed: Arc<AtomicBool>,
359    locked: Arc<AttachLockState>,
360    wakeup: UnixStream,
361) -> std::result::Result<(), ClientError>
362where
363    Input: Read + AsFd,
364{
365    let mut read_buffer = [0_u8; READ_BUFFER_SIZE];
366
367    loop {
368        if closed.load(Ordering::SeqCst) {
369            return Ok(());
370        }
371
372        drain_resize_events(&mut stream, &resize_events, resize_geometry_enabled)?;
373        if locked.is_locked() {
374            thread::sleep(Duration::from_millis(20));
375            continue;
376        }
377
378        let mut fds = [
379            PollFd::new(&input, PollFlags::IN | PollFlags::ERR | PollFlags::HUP),
380            PollFd::new(&wakeup, PollFlags::IN | PollFlags::ERR | PollFlags::HUP),
381        ];
382        match poll(&mut fds, Some(&POLL_TIMEOUT)) {
383            Ok(0) => continue,
384            Ok(_) => {}
385            Err(rustix::io::Errno::INTR) => continue,
386            Err(error) => return Err(ClientError::Io(error.into())),
387        }
388
389        if !fds[1].revents().is_empty() {
390            return Ok(());
391        }
392
393        // The server can request a lock or suspend while this thread is
394        // asleep in poll. Recheck after the wakeup and before touching the
395        // shared terminal input so the lock command remains the sole reader.
396        if locked.is_locked() {
397            continue;
398        }
399
400        let ready = fds[0].revents();
401        if ready.is_empty() {
402            continue;
403        }
404        if closed.load(Ordering::SeqCst) {
405            return Ok(());
406        }
407        if !ready.contains(PollFlags::IN) {
408            if ready.contains(PollFlags::HUP) || ready.contains(PollFlags::ERR) {
409                shutdown_attach_writes(&stream)?;
410                return Ok(());
411            }
412            continue;
413        }
414
415        let Some(_input_read_lease) = AttachInputReadLease::acquire(&locked) else {
416            continue;
417        };
418
419        let bytes_read = match input.read(&mut read_buffer) {
420            Ok(0) => {
421                shutdown_attach_writes(&stream)?;
422                return Ok(());
423            }
424            Ok(bytes_read) => bytes_read,
425            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
426            Err(error) => return Err(ClientError::Io(error)),
427        };
428
429        write_attach_data(&mut stream, &read_buffer[..bytes_read])?;
430    }
431}
432
433#[cfg(test)]
434fn output_loop<Output>(
435    stream: UnixStream,
436    initial_bytes: Vec<u8>,
437    output: Output,
438    closed: Arc<AtomicBool>,
439    locked: Arc<AttachLockState>,
440    screen_tracker: AttachScreenTracker,
441    event_tx: mpsc::Sender<ClientAttachEvent>,
442) -> std::result::Result<(), ClientError>
443where
444    Output: Write,
445{
446    output_loop_with_termination(
447        stream,
448        initial_bytes,
449        output,
450        closed,
451        locked,
452        screen_tracker,
453        event_tx,
454        false,
455    )
456}
457
458#[allow(clippy::too_many_arguments)]
459fn output_loop_with_termination<Output>(
460    mut stream: UnixStream,
461    initial_bytes: Vec<u8>,
462    output: Output,
463    closed: Arc<AtomicBool>,
464    locked: Arc<AttachLockState>,
465    screen_tracker: AttachScreenTracker,
466    event_tx: mpsc::Sender<ClientAttachEvent>,
467    termination_signals_enabled: bool,
468) -> std::result::Result<(), ClientError>
469where
470    Output: Write,
471{
472    let mut output = termination::TerminationAwareWriter::new(output, termination_signals_enabled);
473    let mut decoder = AttachFrameDecoder::new();
474    decoder.push_bytes(&initial_bytes);
475    let mut read_buffer = [0_u8; READ_BUFFER_SIZE];
476    let mut stop_detector = AttachStopDetector::new(screen_tracker.clone());
477    let mut pending_render = None::<Vec<u8>>;
478    let mut pending_render_started_at = None::<Instant>;
479    let mut pending_render_drained_after_deadline = false;
480    let mut painted_render_frame = false;
481    let mut data_scratch = [0_u8; READ_BUFFER_SIZE];
482
483    loop {
484        fail_if_attach_termination_requested(termination_signals_enabled)?;
485        loop {
486            while let Some(bytes) = decoder
487                .next_data_payload_into(&mut data_scratch)
488                .map_err(ClientError::from)?
489            {
490                flush_pending_render_state(
491                    &mut output,
492                    &mut pending_render,
493                    &mut pending_render_started_at,
494                )?;
495                handle_attach_data_payload(&mut output, &locked, &mut stop_detector, bytes)?;
496            }
497
498            let Some(message) = decoder.next_message().map_err(ClientError::from)? else {
499                break;
500            };
501            match message {
502                AttachMessage::Data(bytes) => {
503                    flush_pending_render_state(
504                        &mut output,
505                        &mut pending_render,
506                        &mut pending_render_started_at,
507                    )?;
508                    handle_attach_data_payload(&mut output, &locked, &mut stop_detector, &bytes)?;
509                }
510                AttachMessage::Render(bytes) => {
511                    if locked.is_locked() {
512                        continue;
513                    }
514                    if pending_render.is_none() {
515                        pending_render_started_at = Some(Instant::now());
516                        pending_render_drained_after_deadline = false;
517                    }
518                    pending_render = Some(bytes);
519                    if !painted_render_frame
520                        && flush_pending_render_state(
521                            &mut output,
522                            &mut pending_render,
523                            &mut pending_render_started_at,
524                        )?
525                    {
526                        pending_render_drained_after_deadline = false;
527                        painted_render_frame = true;
528                    }
529                }
530                AttachMessage::KeyDispatched(_) => {}
531                AttachMessage::Resize(_) | AttachMessage::ResizeGeometry(_) => {
532                    flush_pending_render_state(
533                        &mut output,
534                        &mut pending_render,
535                        &mut pending_render_started_at,
536                    )?;
537                    return Err(ClientError::Protocol(RmuxError::Decode(
538                        "received unexpected resize message from attach stream".to_owned(),
539                    )));
540                }
541                AttachMessage::Lock(command) => {
542                    flush_pending_render_state(
543                        &mut output,
544                        &mut pending_render,
545                        &mut pending_render_started_at,
546                    )?;
547                    locked.lock();
548                    send_attach_action(
549                        &event_tx,
550                        ClientAttachAction::Lock {
551                            command,
552                            stop_generation: screen_tracker.current_stop_generation(),
553                        },
554                    )?;
555                }
556                AttachMessage::LockShellCommand(command) => {
557                    flush_pending_render_state(
558                        &mut output,
559                        &mut pending_render,
560                        &mut pending_render_started_at,
561                    )?;
562                    locked.lock();
563                    send_attach_action(
564                        &event_tx,
565                        ClientAttachAction::LockShell {
566                            command,
567                            stop_generation: screen_tracker.current_stop_generation(),
568                        },
569                    )?;
570                }
571                AttachMessage::Suspend => {
572                    flush_pending_render_state(
573                        &mut output,
574                        &mut pending_render,
575                        &mut pending_render_started_at,
576                    )?;
577                    locked.lock();
578                    send_attach_action(
579                        &event_tx,
580                        ClientAttachAction::Suspend {
581                            stop_generation: screen_tracker.current_stop_generation(),
582                        },
583                    )?;
584                }
585                AttachMessage::DetachKill => {
586                    flush_pending_render_state(
587                        &mut output,
588                        &mut pending_render,
589                        &mut pending_render_started_at,
590                    )?;
591                    closed.store(true, Ordering::SeqCst);
592                    send_attach_action(&event_tx, ClientAttachAction::DetachKill)?;
593                    return Ok(());
594                }
595                AttachMessage::DetachExec(command) => {
596                    flush_pending_render_state(
597                        &mut output,
598                        &mut pending_render,
599                        &mut pending_render_started_at,
600                    )?;
601                    closed.store(true, Ordering::SeqCst);
602                    send_attach_action(&event_tx, ClientAttachAction::DetachExec(command))?;
603                    return Ok(());
604                }
605                AttachMessage::DetachExecShellCommand(command) => {
606                    flush_pending_render_state(
607                        &mut output,
608                        &mut pending_render,
609                        &mut pending_render_started_at,
610                    )?;
611                    closed.store(true, Ordering::SeqCst);
612                    send_attach_action(&event_tx, ClientAttachAction::DetachExecShell(command))?;
613                    return Ok(());
614                }
615                AttachMessage::Unlock => {
616                    flush_pending_render_state(
617                        &mut output,
618                        &mut pending_render,
619                        &mut pending_render_started_at,
620                    )?;
621                    return Err(ClientError::Protocol(RmuxError::Decode(
622                        "received unexpected unlock message from attach stream".to_owned(),
623                    )));
624                }
625                AttachMessage::Keystroke(_) => {
626                    flush_pending_render_state(
627                        &mut output,
628                        &mut pending_render,
629                        &mut pending_render_started_at,
630                    )?;
631                    return Err(ClientError::Protocol(RmuxError::Decode(
632                        "received unexpected keystroke message from attach stream".to_owned(),
633                    )));
634                }
635            }
636        }
637
638        if pending_render.is_some() {
639            let pending_expired = pending_render_expired(pending_render_started_at);
640            if (!pending_expired || !pending_render_drained_after_deadline)
641                && drain_available_attach_stream(&mut stream, &mut decoder, &mut read_buffer)?
642            {
643                if pending_expired {
644                    pending_render_drained_after_deadline = true;
645                }
646                continue;
647            }
648        }
649        if pending_render.is_some() && !pending_render_expired(pending_render_started_at) {
650            sleep_until_pending_render_deadline(pending_render_started_at);
651            if drain_available_attach_stream(&mut stream, &mut decoder, &mut read_buffer)? {
652                continue;
653            }
654        }
655        if flush_pending_render_state(
656            &mut output,
657            &mut pending_render,
658            &mut pending_render_started_at,
659        )? {
660            pending_render_drained_after_deadline = false;
661            painted_render_frame = true;
662        }
663
664        let read_result =
665            read_attach_stream(&mut stream, &mut read_buffer, termination_signals_enabled);
666        let bytes_read = match read_result {
667            Ok(0) => {
668                closed.store(true, Ordering::SeqCst);
669                if screen_tracker.was_stopped() {
670                    return Ok(());
671                }
672                return Err(ClientError::Io(io::Error::new(
673                    io::ErrorKind::UnexpectedEof,
674                    "attach stream closed before attach-stop sequence",
675                )));
676            }
677            Ok(bytes_read) => bytes_read,
678            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
679            Err(error)
680                if screen_tracker.was_stopped()
681                    && matches!(
682                        error.kind(),
683                        io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
684                    ) =>
685            {
686                return Ok(());
687            }
688            Err(error) => return Err(ClientError::Io(error)),
689        };
690
691        let mut consumed = 0;
692        if decoder.is_empty() {
693            while consumed < bytes_read {
694                let Some(frame) = decode_attach_data_frame(&read_buffer[consumed..])
695                    .map_err(ClientError::from)?
696                else {
697                    break;
698                };
699                handle_attach_data_payload(
700                    &mut output,
701                    &locked,
702                    &mut stop_detector,
703                    frame.payload(),
704                )?;
705                consumed += frame.frame_len();
706            }
707        }
708        if consumed < bytes_read {
709            decoder.push_bytes(&read_buffer[consumed..bytes_read]);
710        }
711    }
712}
713
714fn read_attach_stream(
715    stream: &mut UnixStream,
716    read_buffer: &mut [u8],
717    termination_signals_enabled: bool,
718) -> io::Result<usize> {
719    if !termination_signals_enabled {
720        return stream.read(read_buffer);
721    }
722
723    loop {
724        if termination::was_requested() {
725            return Err(termination::interruption_error());
726        }
727        let mut fds = [PollFd::new(
728            &*stream,
729            PollFlags::IN | PollFlags::ERR | PollFlags::HUP,
730        )];
731        match poll(&mut fds, Some(&POLL_TIMEOUT)) {
732            Ok(0) => continue,
733            Ok(_) => {}
734            Err(rustix::io::Errno::INTR) => continue,
735            Err(error) => return Err(error.into()),
736        }
737        if termination::was_requested() {
738            return Err(termination::interruption_error());
739        }
740        if fds[0].revents().is_empty() {
741            continue;
742        }
743        return stream.read(read_buffer);
744    }
745}
746
747fn fail_if_attach_termination_requested(
748    termination_signals_enabled: bool,
749) -> std::result::Result<(), ClientError> {
750    if termination_signals_enabled && termination::was_requested() {
751        return Err(ClientError::Io(termination::interruption_error()));
752    }
753    Ok(())
754}
755
756fn handle_attach_data_payload<Output>(
757    output: &mut Output,
758    locked: &Arc<AttachLockState>,
759    stop_detector: &mut AttachStopDetector,
760    bytes: &[u8],
761) -> std::result::Result<(), ClientError>
762where
763    Output: Write,
764{
765    stop_detector.observe(bytes);
766    if locked.is_locked() {
767        return Ok(());
768    }
769    output.write_all(bytes).map_err(ClientError::Io)?;
770    output.flush().map_err(ClientError::Io)?;
771    Ok(())
772}
773
774fn pending_render_expired(started_at: Option<Instant>) -> bool {
775    started_at.is_some_and(|started_at| started_at.elapsed() >= RENDER_MAX_PENDING)
776}
777
778fn sleep_until_pending_render_deadline(started_at: Option<Instant>) {
779    let Some(started_at) = started_at else {
780        return;
781    };
782    let Some(remaining) = RENDER_MAX_PENDING.checked_sub(started_at.elapsed()) else {
783        return;
784    };
785    if !remaining.is_zero() {
786        thread::sleep(remaining);
787    }
788}
789
790fn flush_pending_render_state<Output>(
791    output: &mut Output,
792    pending_render: &mut Option<Vec<u8>>,
793    pending_render_started_at: &mut Option<Instant>,
794) -> std::result::Result<bool, ClientError>
795where
796    Output: Write,
797{
798    let flushed = pending_render.is_some();
799    flush_pending_render(output, pending_render)?;
800    *pending_render_started_at = None;
801    Ok(flushed)
802}
803
804fn wait_for_output_thread(
805    output_thread: thread::JoinHandle<std::result::Result<(), ClientError>>,
806    raw_terminal: Option<&RawTerminal>,
807    lock_stream: &mut UnixStream,
808    locked: &Arc<AttachLockState>,
809    screen_tracker: &AttachScreenTracker,
810    event_rx: mpsc::Receiver<ClientAttachEvent>,
811    termination_signals_enabled: bool,
812) -> std::result::Result<std::result::Result<(), ClientError>, ClientError> {
813    if termination_signals_enabled {
814        loop {
815            if termination::was_requested() {
816                wait_for_backpressured_output_shutdown(&output_thread, raw_terminal, &event_rx);
817                return Err(ClientError::Io(termination::interruption_error()));
818            }
819            match event_rx.recv_timeout(TERMINATION_POLL_INTERVAL) {
820                Ok(ClientAttachEvent::Action(action)) => {
821                    handle_attach_action(
822                        raw_terminal,
823                        lock_stream,
824                        locked,
825                        screen_tracker,
826                        action,
827                    )?;
828                }
829                Ok(ClientAttachEvent::OutputDone) | Err(mpsc::RecvTimeoutError::Disconnected) => {
830                    break;
831                }
832                Err(mpsc::RecvTimeoutError::Timeout) => {}
833            }
834        }
835    } else {
836        while let Ok(ClientAttachEvent::Action(action)) = event_rx.recv() {
837            handle_attach_action(raw_terminal, lock_stream, locked, screen_tracker, action)?;
838        }
839    }
840
841    while let Ok(event) = event_rx.try_recv() {
842        match event {
843            ClientAttachEvent::Action(action) => {
844                handle_attach_action(raw_terminal, lock_stream, locked, screen_tracker, action)?;
845            }
846            ClientAttachEvent::OutputDone => {}
847        }
848    }
849
850    join_attach_thread(output_thread)
851}
852
853fn wait_for_backpressured_output_shutdown(
854    output_thread: &thread::JoinHandle<std::result::Result<(), ClientError>>,
855    raw_terminal: Option<&RawTerminal>,
856    event_rx: &mpsc::Receiver<ClientAttachEvent>,
857) {
858    termination::interrupt_thread(output_thread);
859    let _nonblocking_output =
860        raw_terminal.and_then(|raw_terminal| raw_terminal.interrupt_output_writer().ok());
861    let deadline = Instant::now() + TERMINATION_OUTPUT_SHUTDOWN_TIMEOUT;
862    loop {
863        let remaining = deadline.saturating_duration_since(Instant::now());
864        if remaining.is_zero() {
865            break;
866        }
867        match event_rx.recv_timeout(remaining) {
868            Ok(ClientAttachEvent::OutputDone) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
869            Ok(ClientAttachEvent::Action(_)) => {}
870            Err(mpsc::RecvTimeoutError::Timeout) => break,
871        }
872    }
873}
874
875fn send_attach_action(
876    event_tx: &mpsc::Sender<ClientAttachEvent>,
877    action: ClientAttachAction,
878) -> std::result::Result<(), ClientError> {
879    event_tx
880        .send(ClientAttachEvent::Action(action))
881        .map_err(|_| ClientError::Io(io::Error::other("attach event receiver closed")))
882}
883
884fn handle_attach_action(
885    raw_terminal: Option<&RawTerminal>,
886    lock_stream: &mut UnixStream,
887    locked: &Arc<AttachLockState>,
888    screen_tracker: &AttachScreenTracker,
889    action: ClientAttachAction,
890) -> std::result::Result<(), ClientError> {
891    match action {
892        ClientAttachAction::Lock {
893            command,
894            stop_generation,
895        } => {
896            locked.wait_until_input_idle();
897            let Some(raw_terminal) = raw_terminal else {
898                locked.unlock();
899                return Err(ClientError::Protocol(RmuxError::Decode(
900                    "received unexpected lock request without a managed terminal".to_owned(),
901                )));
902            };
903            let result = raw_terminal
904                .run_lock_command(&command)
905                .map_err(ClientError::from)
906                .and_then(|()| write_attach_unlock(lock_stream, screen_tracker, stop_generation));
907            locked.unlock();
908            result
909        }
910        ClientAttachAction::LockShell {
911            command,
912            stop_generation,
913        } => {
914            locked.wait_until_input_idle();
915            let Some(raw_terminal) = raw_terminal else {
916                locked.unlock();
917                return Err(ClientError::Protocol(RmuxError::Decode(
918                    "received unexpected lock request without a managed terminal".to_owned(),
919                )));
920            };
921            let result = raw_terminal
922                .run_lock_shell_command(&command)
923                .map_err(ClientError::from)
924                .and_then(|()| write_attach_unlock(lock_stream, screen_tracker, stop_generation));
925            locked.unlock();
926            result
927        }
928        ClientAttachAction::Suspend { stop_generation } => {
929            locked.wait_until_input_idle();
930            let Some(raw_terminal) = raw_terminal else {
931                locked.unlock();
932                return Err(ClientError::Protocol(RmuxError::Decode(
933                    "received unexpected suspend request without a managed terminal".to_owned(),
934                )));
935            };
936            let result = raw_terminal
937                .suspend_self()
938                .map_err(ClientError::from)
939                .and_then(|()| write_attach_unlock(lock_stream, screen_tracker, stop_generation));
940            locked.unlock();
941            result
942        }
943        ClientAttachAction::DetachKill => {
944            if let Some(raw_terminal) = raw_terminal {
945                raw_terminal.restore().map_err(ClientError::from)?;
946            }
947            kill_process(current_process_pid().map_err(ClientError::Io)?, Signal::HUP)
948                .map_err(|error| ClientError::Io(error.into()))?;
949            Ok(())
950        }
951        ClientAttachAction::DetachExec(command) => {
952            let Some(raw_terminal) = raw_terminal else {
953                return Err(ClientError::Protocol(RmuxError::Decode(
954                    "received unexpected detach exec request without a managed terminal".to_owned(),
955                )));
956            };
957            raw_terminal
958                .run_detach_exec_command(&command)
959                .map_err(ClientError::from)
960        }
961        ClientAttachAction::DetachExecShell(command) => {
962            let Some(raw_terminal) = raw_terminal else {
963                return Err(ClientError::Protocol(RmuxError::Decode(
964                    "received unexpected detach exec request without a managed terminal".to_owned(),
965                )));
966            };
967            raw_terminal
968                .run_detach_exec_shell_command(&command)
969                .map_err(ClientError::from)
970        }
971    }
972}
973
974fn drain_resize_events(
975    stream: &mut UnixStream,
976    resize_events: &mpsc::Receiver<TerminalGeometry>,
977    resize_geometry_enabled: bool,
978) -> std::result::Result<(), ClientError> {
979    while let Ok(geometry) = resize_events.try_recv() {
980        let message = if resize_geometry_enabled && geometry.pixels.is_some() {
981            AttachMessage::ResizeGeometry(geometry)
982        } else {
983            AttachMessage::Resize(geometry.size)
984        };
985        write_attach_message(stream, message)?;
986    }
987
988    Ok(())
989}
990
991fn write_attach_message(
992    stream: &mut UnixStream,
993    message: AttachMessage,
994) -> std::result::Result<(), ClientError> {
995    let frame = encode_attach_message(&message).map_err(ClientError::from)?;
996    stream.write_all(&frame).map_err(ClientError::Io)
997}
998
999fn write_attach_unlock(
1000    stream: &mut UnixStream,
1001    screen_tracker: &AttachScreenTracker,
1002    stop_generation: Option<AttachStopGeneration>,
1003) -> std::result::Result<(), ClientError> {
1004    // Rearm only the stop published by this lock/suspend prelude. A newer
1005    // generation belongs to a concurrent detach or session exit and must stay
1006    // authoritative even when the local action completes later.
1007    let rearmed =
1008        stop_generation.is_some_and(|generation| screen_tracker.rearm_if_current(generation));
1009    if !rearmed && screen_tracker.was_stopped() {
1010        return Ok(());
1011    }
1012
1013    match write_attach_message(stream, AttachMessage::Unlock) {
1014        Err(ClientError::Io(error))
1015            if screen_tracker.was_stopped()
1016                && matches!(
1017                    error.kind(),
1018                    io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
1019                ) =>
1020        {
1021            Ok(())
1022        }
1023        result => result,
1024    }
1025}
1026
1027fn write_attach_data(
1028    stream: &mut UnixStream,
1029    bytes: &[u8],
1030) -> std::result::Result<(), ClientError> {
1031    if bytes.len() <= STACK_ATTACH_DATA_PAYLOAD {
1032        let mut frame = [0_u8; STACK_ATTACH_DATA_PAYLOAD + ATTACH_DATA_HEADER_LEN];
1033        let len = encode_attach_data_into_slice(bytes, &mut frame).map_err(ClientError::from)?;
1034        return stream.write_all(&frame[..len]).map_err(ClientError::Io);
1035    }
1036
1037    let frame = encode_attach_data(bytes).map_err(ClientError::from)?;
1038    stream.write_all(&frame).map_err(ClientError::Io)
1039}
1040
1041fn join_attach_thread(
1042    thread: thread::JoinHandle<std::result::Result<(), ClientError>>,
1043) -> std::result::Result<std::result::Result<(), ClientError>, ClientError> {
1044    thread
1045        .join()
1046        .map_err(|_| ClientError::Io(io::Error::other("attach thread panicked")))
1047}
1048
1049fn shutdown_attach_writes(stream: &UnixStream) -> std::result::Result<(), ClientError> {
1050    match stream.shutdown(Shutdown::Write) {
1051        Ok(()) => Ok(()),
1052        Err(error) if error.kind() == io::ErrorKind::NotConnected => Ok(()),
1053        Err(error) => Err(ClientError::Io(error)),
1054    }
1055}
1056
1057#[derive(Debug)]
1058enum ClientAttachAction {
1059    Lock {
1060        command: String,
1061        stop_generation: Option<AttachStopGeneration>,
1062    },
1063    LockShell {
1064        command: AttachShellCommand,
1065        stop_generation: Option<AttachStopGeneration>,
1066    },
1067    Suspend {
1068        stop_generation: Option<AttachStopGeneration>,
1069    },
1070    DetachKill,
1071    DetachExec(String),
1072    DetachExecShell(AttachShellCommand),
1073}
1074
1075#[derive(Debug)]
1076enum ClientAttachEvent {
1077    Action(ClientAttachAction),
1078    OutputDone,
1079}
1080
1081#[cfg(test)]
1082mod tests;