Skip to main content

terminal_control/
session.rs

1use std::fs;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::sync::mpsc::{self, Receiver, TryRecvError};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use anyhow::{Context, Result, bail};
9use portable_pty::{Child, CommandBuilder, ExitStatus, MasterPty, PtySize, native_pty_system};
10use serde::{Deserialize, Serialize};
11use vt100::Parser;
12
13use crate::frame::from_screen;
14use crate::recording::{self, InputOrigin};
15use crate::shot::{self, Host, Options, Shot};
16
17const OUTPUT_BATCH: usize = 1;
18const OUTPUT_QUEUE: usize = 4;
19const OUTPUT_CHUNK: usize = 1024;
20const SCROLLBACK_ROWS: usize = 10_000;
21
22struct Output {
23    at_ms: u64,
24    bytes: Vec<u8>,
25}
26
27/// One running terminal application controlled in-process by its caller.
28///
29/// `Session` is the embedded equivalent of the CLI `session` lifecycle. It owns a PTY and the
30/// visible terminal state, so callers can send input, wait for content, take shots, and resize
31/// without spawning a new `termctrl` command for each action.
32pub struct Session {
33    master: Box<dyn MasterPty + Send>,
34    child: Box<dyn Child + Send>,
35    #[cfg(unix)]
36    process_group: Option<i32>,
37    parser: Parser,
38    ansi: Vec<u8>,
39    host: Host,
40    receive: Receiver<Option<Output>>,
41    max_bytes: usize,
42    ansi_truncated: bool,
43    output_closed: bool,
44    stopped: bool,
45    exit: Option<ProcessExit>,
46    last_output: Option<Instant>,
47    recording: Option<recording::Writer>,
48    cols: u16,
49    rows: u16,
50    cell_width: u16,
51    cell_height: u16,
52    launch: SessionLaunch,
53}
54
55/// Lifecycle state of a running or completed session.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum SessionState {
59    Running,
60    Exited,
61}
62
63/// Termination information observed for a completed terminal application.
64#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
65pub struct ProcessExit {
66    pub code: u32,
67    pub signal: Option<String>,
68    pub success: bool,
69}
70
71impl From<ExitStatus> for ProcessExit {
72    fn from(status: ExitStatus) -> Self {
73        Self {
74            code: status.exit_code(),
75            signal: status.signal().map(str::to_owned),
76            success: status.success(),
77        }
78    }
79}
80
81/// Reason a session capture returned its visible shot.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
83#[serde(rename_all = "lowercase")]
84pub enum CaptureReason {
85    Idle,
86    Deadline,
87    Exited,
88    OutputClosed,
89}
90
91/// A visible shot together with the condition that made it observable.
92#[derive(Deserialize, Serialize)]
93pub struct CaptureResult {
94    pub shot: Shot,
95    pub reason: CaptureReason,
96}
97
98/// Observable state of one embedded or named terminal session.
99#[derive(Clone, Debug, Deserialize, Serialize)]
100pub struct SessionStatus {
101    pub state: SessionState,
102    pub exit: Option<ProcessExit>,
103    pub cols: u16,
104    pub rows: u16,
105    pub cell_width: u16,
106    pub cell_height: u16,
107    pub idle_for_ms: Option<u64>,
108    pub has_visible_content: bool,
109    pub recording: bool,
110    pub logs_truncated: bool,
111    pub launch: SessionLaunch,
112}
113
114/// Non-secret launch settings retained for status display and named-session restart.
115#[derive(Clone, Debug, Deserialize, Serialize)]
116pub struct SessionLaunch {
117    pub command: Vec<String>,
118    pub cwd: PathBuf,
119    pub record: Option<PathBuf>,
120    pub cols: u16,
121    pub rows: u16,
122    pub cell_width: u16,
123    pub cell_height: u16,
124    pub max_bytes: usize,
125    pub opentui_host: bool,
126    pub color: shot::ColorMode,
127}
128
129/// One named daemon session discovered in the local runtime directory.
130#[derive(Debug, Serialize)]
131pub struct NamedSessionStatus {
132    pub name: String,
133    pub status: Option<SessionStatus>,
134    pub error: Option<String>,
135    pub unavailable: Option<UnavailableReason>,
136}
137
138/// Why a named session socket could not report normal status.
139#[derive(Clone, Copy, Debug, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum UnavailableReason {
142    Stale,
143    IncompatibleProtocol,
144}
145
146impl Session {
147    /// Start `command` inside a live PTY-backed session.
148    pub fn start(
149        command: &[String],
150        cwd: Option<&Path>,
151        record: Option<&Path>,
152        options: &Options,
153    ) -> Result<Self> {
154        if command.is_empty() {
155            bail!("provide a command after --");
156        }
157        if options.cols == 0 || options.rows == 0 {
158            bail!("terminal dimensions must be greater than zero");
159        }
160        let cwd = match cwd {
161            Some(cwd) if cwd.is_absolute() => cwd.to_owned(),
162            Some(cwd) => std::env::current_dir()
163                .context("resolve session working directory")?
164                .join(cwd),
165            None => std::env::current_dir().context("resolve session working directory")?,
166        };
167        let cwd = fs::canonicalize(&cwd).context("canonicalize session working directory")?;
168        let started = Instant::now();
169        let recording = record
170            .map(|path| {
171                recording::Writer::new(
172                    path,
173                    started,
174                    options.cols,
175                    options.rows,
176                    options.cell_width,
177                    options.cell_height,
178                )
179            })
180            .transpose()?;
181        let pair = native_pty_system()
182            .openpty(PtySize {
183                rows: options.rows,
184                cols: options.cols,
185                pixel_width: options.cell_width,
186                pixel_height: options.cell_height,
187            })
188            .context("open session pseudo-terminal")?;
189        let mut builder = CommandBuilder::new(&command[0]);
190        builder.args(&command[1..]);
191        shot::configure_pty_environment(&mut builder, options);
192        builder.cwd(&cwd);
193        let mut reader = pair
194            .master
195            .try_clone_reader()
196            .context("open session PTY reader")?;
197        let writer = pair
198            .master
199            .take_writer()
200            .context("open session PTY writer")?;
201        let child = pair
202            .slave
203            .spawn_command(builder)
204            .context("spawn session command")?;
205        drop(pair.slave);
206        #[cfg(unix)]
207        let process_group = child.process_id().and_then(|pid| i32::try_from(pid).ok());
208        let (send, receive) = mpsc::sync_channel(OUTPUT_QUEUE);
209        thread::spawn(move || {
210            let mut buffer = [0_u8; OUTPUT_CHUNK];
211            loop {
212                match reader.read(&mut buffer) {
213                    Ok(0) => break,
214                    Ok(len) => {
215                        if send
216                            .send(Some(Output {
217                                at_ms: started.elapsed().as_millis() as u64,
218                                bytes: buffer[..len].to_vec(),
219                            }))
220                            .is_err()
221                        {
222                            return;
223                        }
224                    }
225                    Err(_) => break,
226                }
227            }
228            let _ = send.send(None);
229        });
230        Ok(Self {
231            master: pair.master,
232            child,
233            #[cfg(unix)]
234            process_group,
235            parser: session_terminal(options.rows, options.cols),
236            ansi: Vec::new(),
237            host: Host::new(writer, options),
238            receive,
239            max_bytes: options.max_bytes,
240            ansi_truncated: false,
241            output_closed: false,
242            stopped: false,
243            exit: None,
244            last_output: None,
245            recording,
246            cols: options.cols,
247            rows: options.rows,
248            cell_width: options.cell_width,
249            cell_height: options.cell_height,
250            launch: SessionLaunch {
251                command: command.to_vec(),
252                cwd,
253                record: record.map(Path::to_owned),
254                cols: options.cols,
255                rows: options.rows,
256                cell_width: options.cell_width,
257                cell_height: options.cell_height,
258                max_bytes: options.max_bytes,
259                opentui_host: options.opentui_host,
260                color: options.color,
261            },
262        })
263    }
264
265    /// Send one input burst to the terminal application.
266    pub fn send(&mut self, input: &[u8]) -> Result<()> {
267        self.send_all(&[input.to_vec()], Duration::ZERO)
268    }
269
270    /// Send ordered input bursts, optionally pacing them for recorded interactions.
271    pub fn send_all(&mut self, input: &[Vec<u8>], pace: Duration) -> Result<()> {
272        self.consume_batch()?;
273        if self.has_exited()? || self.stopped {
274            bail!("session command has exited");
275        }
276        let last = input.len().saturating_sub(1);
277        for (index, bytes) in input.iter().enumerate() {
278            self.host.send(bytes)?;
279            if let Some(recording) = &mut self.recording {
280                recording.input(InputOrigin::Client, bytes)?;
281            }
282            if !pace.is_zero() && index < last {
283                thread::sleep(pace);
284                self.consume_batch()?;
285            }
286        }
287        Ok(())
288    }
289
290    /// Wait until visible terminal text contains `text`.
291    pub fn wait_for_text(&mut self, text: &str, timeout: Duration) -> Result<()> {
292        let deadline = Instant::now() + timeout;
293        loop {
294            self.consume_batch()?;
295            if self.parser.screen().contents().contains(text) {
296                return Ok(());
297            }
298            if self.has_exited()? || self.stopped {
299                bail!("session ended before visible terminal included {text:?}");
300            }
301            if Instant::now() >= deadline {
302                bail!("timed out waiting for visible terminal text {text:?}");
303            }
304            thread::sleep(Duration::from_millis(10));
305        }
306    }
307
308    /// Wait until no terminal output has arrived for `settle`.
309    pub fn wait_for_idle(&mut self, settle: Duration, timeout: Duration) -> Result<()> {
310        let started = Instant::now();
311        let deadline = started + timeout;
312        loop {
313            self.consume_batch()?;
314            if self.output_closed || self.last_output.unwrap_or(started).elapsed() >= settle {
315                return Ok(());
316            }
317            if Instant::now() >= deadline {
318                bail!("timed out waiting for terminal output to settle");
319            }
320            thread::sleep(Duration::from_millis(10));
321        }
322    }
323
324    /// Wait for the terminal application to exit, returning `None` on timeout.
325    pub fn wait_for_exit(&mut self, timeout: Duration) -> Result<Option<ProcessExit>> {
326        let deadline = Instant::now() + timeout;
327        loop {
328            self.consume_batch()?;
329            if self.has_exited()? || self.stopped {
330                return Ok(self.exit.clone());
331            }
332            if Instant::now() >= deadline {
333                return Ok(None);
334            }
335            thread::sleep(Duration::from_millis(10));
336        }
337    }
338
339    /// Capture visible terminal state and report whether it settled, exited, or reached a limit.
340    pub fn capture(&mut self, settle: Duration, deadline: Duration) -> Result<CaptureResult> {
341        let started = Instant::now();
342        let deadline = started + deadline;
343        loop {
344            self.consume_batch()?;
345            let reason = if self.has_exited()? || self.stopped {
346                Some(CaptureReason::Exited)
347            } else if self.output_closed {
348                Some(CaptureReason::OutputClosed)
349            } else if self.last_output.unwrap_or(started).elapsed() >= settle {
350                Some(CaptureReason::Idle)
351            } else if Instant::now() >= deadline {
352                Some(CaptureReason::Deadline)
353            } else {
354                None
355            };
356            if let Some(reason) = reason {
357                return Ok(CaptureResult {
358                    shot: Shot {
359                        frame: from_screen(self.parser.screen()),
360                        ansi: self.ansi.clone(),
361                    },
362                    reason,
363                });
364            }
365            thread::sleep(Duration::from_millis(10));
366        }
367    }
368
369    /// Inspect session lifecycle, geometry, and whether a visible frame is available.
370    pub fn status(&mut self) -> Result<SessionStatus> {
371        self.consume_batch()?;
372        Ok(SessionStatus {
373            state: if self.has_exited()? || self.stopped {
374                SessionState::Exited
375            } else {
376                SessionState::Running
377            },
378            exit: self.exit.clone(),
379            cols: self.cols,
380            rows: self.rows,
381            cell_width: self.cell_width,
382            cell_height: self.cell_height,
383            idle_for_ms: self
384                .last_output
385                .map(|last| last.elapsed().as_millis() as u64),
386            has_visible_content: from_screen(self.parser.screen()).has_visible_content(),
387            recording: self.recording.is_some(),
388            logs_truncated: self.ansi_truncated,
389            launch: self.launch.clone(),
390        })
391    }
392
393    /// Return readable normal-screen scrollback, or the exact retained ANSI/VT stream.
394    pub fn logs(&mut self, ansi: bool) -> Result<Vec<u8>> {
395        self.consume_batch()?;
396        if ansi {
397            return Ok(self.ansi.clone());
398        }
399        let mut screen = self.parser.screen().clone();
400        screen.set_scrollback(usize::MAX);
401        let mut offset = screen.scrollback();
402        let mut lines = Vec::new();
403        while offset > 0 {
404            screen.set_scrollback(offset);
405            let count = offset.min(usize::from(self.rows));
406            lines.extend(
407                screen
408                    .rows(0, self.cols)
409                    .take(count)
410                    .map(|line| line.trim_end().to_owned()),
411            );
412            offset = offset.saturating_sub(usize::from(self.rows));
413        }
414        screen.set_scrollback(0);
415        lines.extend(
416            screen
417                .rows(0, self.cols)
418                .map(|line| line.trim_end().to_owned()),
419        );
420        Ok(lines.join("\n").trim_end().as_bytes().to_vec())
421    }
422
423    /// Resize the PTY and reflow subsequent terminal parsing at the new dimensions.
424    pub fn resize(
425        &mut self,
426        cols: u16,
427        rows: u16,
428        cell_width: u16,
429        cell_height: u16,
430    ) -> Result<()> {
431        if cols == 0 || rows == 0 {
432            bail!("terminal dimensions must be greater than zero");
433        }
434        if self.ansi_truncated {
435            bail!("resizing sessions after retained output is truncated is not yet supported");
436        }
437        self.consume_batch()?;
438        self.master
439            .resize(PtySize {
440                rows,
441                cols,
442                pixel_width: cell_width,
443                pixel_height: cell_height,
444            })
445            .context("resize session pseudo-terminal")?;
446        self.host.resize(cols, rows, cell_width, cell_height);
447        self.parser = session_terminal(rows, cols);
448        self.parser.process(&self.ansi);
449        self.cols = cols;
450        self.rows = rows;
451        self.cell_width = cell_width;
452        self.cell_height = cell_height;
453        if let Some(recording) = &mut self.recording {
454            recording.resize(cols, rows, cell_width, cell_height)?;
455        }
456        Ok(())
457    }
458
459    /// Add a named moment to the active recording timeline.
460    pub fn mark(&mut self, name: &str) -> Result<()> {
461        self.consume_batch()?;
462        let recording = self
463            .recording
464            .as_mut()
465            .context("session was not started with --record")?;
466        recording.marker(name)
467    }
468
469    /// Terminate the application owned by this session.
470    pub fn stop(&mut self) -> Result<()> {
471        self.terminate();
472        Ok(())
473    }
474
475    pub(crate) fn pump(&mut self) -> Result<()> {
476        self.consume_batch()
477    }
478
479    fn consume_batch(&mut self) -> Result<()> {
480        for _ in 0..OUTPUT_BATCH {
481            if !self.consume_one()? {
482                break;
483            }
484        }
485        Ok(())
486    }
487
488    fn consume_one(&mut self) -> Result<bool> {
489        match self.receive.try_recv() {
490            Ok(Some(output)) => {
491                self.apply_output(output)?;
492                Ok(true)
493            }
494            Ok(None) | Err(TryRecvError::Disconnected) => {
495                self.output_closed = true;
496                Ok(false)
497            }
498            Err(TryRecvError::Empty) => Ok(false),
499        }
500    }
501
502    fn has_exited(&mut self) -> Result<bool> {
503        if self.exit.is_some() {
504            return Ok(true);
505        }
506        if let Some(status) = self.child.try_wait().context("poll session command")? {
507            self.exit = Some(status.into());
508            self.finish_exited_output()?;
509            return Ok(true);
510        }
511        Ok(false)
512    }
513
514    fn terminate(&mut self) {
515        if self.stopped {
516            return;
517        }
518        #[cfg(unix)]
519        if let Some(process_group) = self.process_group.take() {
520            unsafe {
521                libc::kill(-process_group, libc::SIGKILL);
522            }
523        }
524        let _ = self.child.kill();
525        let deadline = Instant::now() + Duration::from_secs(1);
526        while self.exit.is_none() && Instant::now() < deadline {
527            // The PTY reader may be blocked by the bounded queue while the child exits.
528            // Keep draining one chunk at a time so forced shutdown cannot deadlock on backpressure.
529            let _ = self.consume_one();
530            if let Ok(Some(status)) = self.child.try_wait() {
531                self.exit = Some(status.into());
532                break;
533            }
534            thread::sleep(Duration::from_millis(1));
535        }
536        self.output_closed = true;
537        self.stopped = true;
538    }
539
540    fn finish_exited_output(&mut self) -> Result<()> {
541        let kill_after = Instant::now() + Duration::from_millis(50);
542        let deadline = Instant::now() + Duration::from_secs(1);
543        while !self.output_closed && Instant::now() < deadline {
544            // A cleanly exited application should close the PTY promptly. Only signal its
545            // saved group if output remains open long enough to indicate a live descendant.
546            #[cfg(unix)]
547            if Instant::now() >= kill_after
548                && let Some(process_group) = self.process_group.take()
549            {
550                unsafe {
551                    libc::kill(-process_group, libc::SIGKILL);
552                }
553            }
554            match self.receive.recv_timeout(Duration::from_millis(10)) {
555                Ok(Some(output)) => self.apply_output(output)?,
556                Ok(None) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
557                    self.output_closed = true;
558                }
559                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
560            }
561        }
562        #[cfg(unix)]
563        if self.output_closed {
564            self.process_group.take();
565        }
566        Ok(())
567    }
568
569    fn apply_output(&mut self, output: Output) -> Result<()> {
570        if let Some(recording) = &mut self.recording {
571            recording.output(output.at_ms, &output.bytes)?;
572        }
573        let response = self.host.respond(&output.bytes)?;
574        if !response.is_empty()
575            && let Some(recording) = &mut self.recording
576        {
577            recording.input(InputOrigin::Host, &response)?;
578        }
579        retain_recent(
580            &mut self.ansi,
581            &output.bytes,
582            self.max_bytes,
583            &mut self.ansi_truncated,
584        );
585        self.parser.process(&output.bytes);
586        self.last_output = Some(Instant::now());
587        Ok(())
588    }
589}
590
591fn retain_recent(ansi: &mut Vec<u8>, bytes: &[u8], max_bytes: usize, truncated: &mut bool) {
592    if max_bytes == 0 {
593        *truncated |= !bytes.is_empty();
594        ansi.clear();
595        return;
596    }
597    if bytes.len() >= max_bytes {
598        *truncated |= !ansi.is_empty() || bytes.len() > max_bytes;
599        ansi.clear();
600        ansi.extend_from_slice(&bytes[bytes.len() - max_bytes..]);
601        return;
602    }
603    let excess = ansi
604        .len()
605        .saturating_add(bytes.len())
606        .saturating_sub(max_bytes);
607    if excess > 0 {
608        ansi.drain(..excess);
609        *truncated = true;
610    }
611    ansi.extend_from_slice(bytes);
612}
613
614fn session_terminal(rows: u16, cols: u16) -> Parser {
615    Parser::new(rows, cols, SCROLLBACK_ROWS)
616}
617
618impl Drop for Session {
619    fn drop(&mut self) {
620        self.terminate();
621    }
622}
623
624#[derive(Serialize, Deserialize)]
625enum Request {
626    Ping,
627    Status,
628    Wait {
629        text: String,
630        timeout_ms: u64,
631    },
632    Send {
633        input: Vec<Vec<u8>>,
634        pace_ms: u64,
635    },
636    Show {
637        settle_ms: u64,
638        deadline_ms: u64,
639    },
640    Logs {
641        ansi: bool,
642    },
643    Resize {
644        cols: u16,
645        rows: u16,
646        cell_width: Option<u16>,
647        cell_height: Option<u16>,
648    },
649    Mark {
650        name: String,
651    },
652    Stop,
653}
654
655#[derive(Serialize, Deserialize)]
656struct Response {
657    error: Option<String>,
658    captured: Option<Shot>,
659    status: Option<SessionStatus>,
660    logs: Option<Vec<u8>>,
661}
662
663#[doc(hidden)]
664pub fn start(
665    name: &str,
666    command: &[String],
667    cwd: Option<&Path>,
668    record: Option<&Path>,
669    options: &Options,
670) -> Result<()> {
671    validate_name(name)?;
672    implementation::start(name, command, cwd, record, options)
673}
674
675#[doc(hidden)]
676pub fn restart(
677    name: &str,
678    command: &[String],
679    cwd: Option<&Path>,
680    record: Option<&Path>,
681    options: &Options,
682) -> Result<()> {
683    validate_name(name)?;
684    implementation::restart(name, command, cwd, record, options)
685}
686
687#[doc(hidden)]
688pub fn wait(name: &str, text: String, timeout: Duration) -> Result<()> {
689    request(
690        name,
691        Request::Wait {
692            text,
693            timeout_ms: timeout.as_millis() as u64,
694        },
695    )?;
696    Ok(())
697}
698
699#[doc(hidden)]
700pub fn status(name: &str) -> Result<SessionStatus> {
701    request(name, Request::Status)?
702        .status
703        .ok_or_else(|| anyhow::anyhow!("session did not return status"))
704}
705
706#[doc(hidden)]
707pub fn send(name: &str, input: Vec<Vec<u8>>, pace: Duration) -> Result<()> {
708    request(
709        name,
710        Request::Send {
711            input,
712            pace_ms: pace.as_millis() as u64,
713        },
714    )?;
715    Ok(())
716}
717
718#[doc(hidden)]
719pub fn show(name: &str, settle: Duration, deadline: Duration) -> Result<Shot> {
720    request(
721        name,
722        Request::Show {
723            settle_ms: settle.as_millis() as u64,
724            deadline_ms: deadline.as_millis() as u64,
725        },
726    )?
727    .captured
728    .ok_or_else(|| anyhow::anyhow!("session did not return a visible screen"))
729}
730
731#[doc(hidden)]
732pub fn resize(
733    name: &str,
734    cols: u16,
735    rows: u16,
736    cell_width: Option<u16>,
737    cell_height: Option<u16>,
738) -> Result<()> {
739    request(
740        name,
741        Request::Resize {
742            cols,
743            rows,
744            cell_width,
745            cell_height,
746        },
747    )?;
748    Ok(())
749}
750
751#[doc(hidden)]
752pub fn mark(name: &str, marker: String) -> Result<()> {
753    request(name, Request::Mark { name: marker })?;
754    Ok(())
755}
756
757#[doc(hidden)]
758pub fn logs(name: &str, ansi: bool) -> Result<Vec<u8>> {
759    request(name, Request::Logs { ansi })?
760        .logs
761        .ok_or_else(|| anyhow::anyhow!("session did not return logs"))
762}
763
764#[doc(hidden)]
765pub fn list() -> Result<Vec<NamedSessionStatus>> {
766    implementation::list()
767}
768
769#[doc(hidden)]
770pub fn stop(name: &str) -> Result<()> {
771    request(name, Request::Stop)?;
772    Ok(())
773}
774
775#[doc(hidden)]
776pub fn serve(
777    socket: PathBuf,
778    command: Vec<String>,
779    cwd: Option<PathBuf>,
780    record: Option<PathBuf>,
781    options: Options,
782) -> Result<()> {
783    implementation::serve(socket, command, cwd, record, options)
784}
785
786fn request(name: &str, request: Request) -> Result<Response> {
787    validate_name(name)?;
788    let response = implementation::request(socket_path(name)?, &request)?;
789    if let Some(error) = response.error {
790        bail!(error);
791    }
792    Ok(response)
793}
794
795fn validate_name(name: &str) -> Result<()> {
796    if name.is_empty()
797        || !name
798            .chars()
799            .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
800    {
801        bail!("session names may contain only ASCII letters, digits, '.', '-', and '_'");
802    }
803    Ok(())
804}
805
806fn socket_path(name: &str) -> Result<PathBuf> {
807    Ok(implementation::runtime_dir()?.join(format!("{name}.sock")))
808}
809
810#[cfg(unix)]
811mod implementation {
812    use std::fs;
813    use std::fs::OpenOptions;
814    use std::io::{ErrorKind, Read, Write};
815    use std::os::fd::AsRawFd;
816    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
817    use std::os::unix::net::{UnixListener, UnixStream};
818    use std::path::{Path, PathBuf};
819    use std::process::{Command, Stdio};
820    use std::thread;
821    use std::time::{Duration, Instant};
822
823    use anyhow::{Context, Result, bail};
824
825    use super::{NamedSessionStatus, Request, Response, Session, UnavailableReason};
826    use crate::shot::{self, Options};
827
828    const MAX_REQUEST_BYTES: u64 = 1024 * 1024;
829    const CONTROL_TIMEOUT: Duration = Duration::from_secs(2);
830
831    struct StartLock(fs::File);
832
833    impl StartLock {
834        fn acquire(path: &Path) -> Result<Self> {
835            let file = OpenOptions::new()
836                .create(true)
837                .truncate(false)
838                .write(true)
839                .open(path)
840                .with_context(|| format!("open {}", path.display()))?;
841            let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
842            if result != 0 {
843                bail!("another session operation is already starting this name");
844            }
845            Ok(Self(file))
846        }
847    }
848
849    impl Drop for StartLock {
850        fn drop(&mut self) {
851            unsafe {
852                libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
853            }
854        }
855    }
856    pub fn runtime_dir() -> Result<PathBuf> {
857        let path = std::env::var_os("TERMCTRL_RUNTIME_DIR")
858            .map(PathBuf::from)
859            .unwrap_or_else(|| {
860                PathBuf::from(format!("/tmp/termctrl-{}", unsafe { libc::geteuid() }))
861            });
862        match fs::symlink_metadata(&path) {
863            Ok(metadata) => require_private_runtime_dir(&path, &metadata)?,
864            Err(error) if error.kind() == ErrorKind::NotFound => {
865                fs::DirBuilder::new()
866                    .mode(0o700)
867                    .create(&path)
868                    .with_context(|| format!("create {}", path.display()))?;
869            }
870            Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())),
871        }
872        fs::set_permissions(&path, fs::Permissions::from_mode(0o700))
873            .with_context(|| format!("secure {}", path.display()))?;
874        Ok(path)
875    }
876
877    fn require_private_runtime_dir(path: &Path, metadata: &fs::Metadata) -> Result<()> {
878        if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
879            bail!(
880                "session runtime path must be a real directory: {}",
881                path.display()
882            );
883        }
884        if metadata.uid() != unsafe { libc::geteuid() } {
885            bail!(
886                "session runtime directory is not owned by the current user: {}",
887                path.display()
888            );
889        }
890        Ok(())
891    }
892
893    pub fn start(
894        name: &str,
895        command: &[String],
896        cwd: Option<&Path>,
897        record: Option<&Path>,
898        options: &Options,
899    ) -> Result<()> {
900        if command.is_empty() {
901            bail!("provide a command after --");
902        }
903        let runtime = runtime_dir()?;
904        ensure_socket_path(&runtime.join(format!("{name}.sock")))?;
905        let _lock = StartLock::acquire(&runtime.join(format!("{name}.lock")))?;
906        start_locked(name, command, cwd, record, options, &runtime)
907    }
908
909    pub fn restart(
910        name: &str,
911        command: &[String],
912        cwd: Option<&Path>,
913        record: Option<&Path>,
914        options: &Options,
915    ) -> Result<()> {
916        if command.is_empty() {
917            bail!("provide a command after --");
918        }
919        let runtime = runtime_dir()?;
920        ensure_socket_path(&runtime.join(format!("{name}.sock")))?;
921        let _lock = StartLock::acquire(&runtime.join(format!("{name}.lock")))?;
922        let socket = runtime.join(format!("{name}.sock"));
923        if let Ok(response) = request(socket.clone(), &Request::Stop) {
924            if let Some(error) = response.error {
925                bail!(error);
926            }
927            let deadline = Instant::now() + Duration::from_secs(2);
928            while request(socket.clone(), &Request::Ping).is_ok() {
929                if Instant::now() >= deadline {
930                    bail!("timed out stopping session {name:?} before restart");
931                }
932                thread::sleep(Duration::from_millis(10));
933            }
934        }
935        start_locked(name, command, cwd, record, options, &runtime)
936    }
937
938    fn start_locked(
939        name: &str,
940        command: &[String],
941        cwd: Option<&Path>,
942        record: Option<&Path>,
943        options: &Options,
944        runtime: &Path,
945    ) -> Result<()> {
946        let socket = runtime.join(format!("{name}.sock"));
947        if socket.exists() {
948            if request(socket.clone(), &Request::Ping).is_ok() {
949                bail!("session {name:?} is already running");
950            }
951            match fs::remove_file(&socket) {
952                Ok(()) => {}
953                Err(error) if error.kind() == ErrorKind::NotFound => {}
954                Err(error) => {
955                    return Err(error)
956                        .with_context(|| format!("remove stale {}", socket.display()));
957                }
958            }
959        }
960        let mut daemon =
961            Command::new(std::env::current_exe().context("locate termctrl executable")?);
962        daemon
963            .arg("__serve")
964            .arg("--socket")
965            .arg(&socket)
966            .arg("--cols")
967            .arg(options.cols.to_string())
968            .arg("--rows")
969            .arg(options.rows.to_string())
970            .arg("--cell-width")
971            .arg(options.cell_width.to_string())
972            .arg("--cell-height")
973            .arg(options.cell_height.to_string())
974            .arg("--max-bytes")
975            .arg(options.max_bytes.to_string());
976        if options.opentui_host {
977            daemon.arg("--opentui-host");
978        }
979        match options.color {
980            shot::ColorMode::Auto => {}
981            shot::ColorMode::Always => {
982                daemon.arg("--color").arg("always");
983            }
984            shot::ColorMode::Never => {
985                daemon.arg("--color").arg("never");
986            }
987        }
988        if let Some(cwd) = cwd {
989            daemon.arg("--cwd").arg(cwd);
990        }
991        if let Some(record) = record {
992            let record = if record.is_absolute() {
993                record.to_owned()
994            } else {
995                std::env::current_dir()
996                    .context("resolve recording output directory")?
997                    .join(record)
998            };
999            daemon.arg("--record").arg(record);
1000        }
1001        daemon
1002            .arg("--")
1003            .args(command)
1004            .stdin(Stdio::null())
1005            .stdout(Stdio::null())
1006            .stderr(Stdio::null());
1007        let mut daemon = daemon.spawn().context("start session daemon")?;
1008        let deadline = Instant::now() + Duration::from_secs(5);
1009        loop {
1010            if request(socket.clone(), &Request::Ping).is_ok() {
1011                return Ok(());
1012            }
1013            if let Some(status) = daemon.try_wait().context("poll session daemon")? {
1014                bail!("session daemon exited before becoming ready: {status}");
1015            }
1016            if Instant::now() >= deadline {
1017                let _ = daemon.kill();
1018                bail!("timed out starting session {name:?}");
1019            }
1020            thread::sleep(Duration::from_millis(20));
1021        }
1022    }
1023
1024    pub fn request(socket: PathBuf, request: &Request) -> Result<Response> {
1025        ensure_socket_path(&socket)?;
1026        let mut stream = UnixStream::connect(&socket).with_context(|| {
1027            format!("connect to session at {}; is it running?", socket.display())
1028        })?;
1029        serde_json::to_writer(&mut stream, request).context("write session request")?;
1030        stream
1031            .shutdown(std::net::Shutdown::Write)
1032            .context("finish session request")?;
1033        serde_json::from_reader(stream).context("read session response")
1034    }
1035
1036    pub fn list() -> Result<Vec<NamedSessionStatus>> {
1037        let mut sessions = Vec::new();
1038        for entry in fs::read_dir(runtime_dir()?).context("read session runtime directory")? {
1039            let path = entry.context("read session runtime entry")?.path();
1040            if path.extension().and_then(|extension| extension.to_str()) != Some("sock") {
1041                continue;
1042            }
1043            let Some(name) = path
1044                .file_stem()
1045                .and_then(|name| name.to_str())
1046                .map(str::to_owned)
1047            else {
1048                continue;
1049            };
1050            let (status, error, unavailable) = match request(path, &Request::Status) {
1051                Ok(response) => (response.status, response.error, None),
1052                Err(error) => {
1053                    let error = format!("{error:#}");
1054                    let reason = if error.contains("read session response") {
1055                        UnavailableReason::IncompatibleProtocol
1056                    } else {
1057                        UnavailableReason::Stale
1058                    };
1059                    (None, Some(error), Some(reason))
1060                }
1061            };
1062            sessions.push(NamedSessionStatus {
1063                name,
1064                status,
1065                error,
1066                unavailable,
1067            });
1068        }
1069        sessions.sort_by(|left, right| left.name.cmp(&right.name));
1070        Ok(sessions)
1071    }
1072
1073    pub fn serve(
1074        socket: PathBuf,
1075        command: Vec<String>,
1076        cwd: Option<PathBuf>,
1077        record: Option<PathBuf>,
1078        options: Options,
1079    ) -> Result<()> {
1080        ensure_socket_path(&socket)?;
1081        if command.is_empty() {
1082            bail!("provide a command after --");
1083        }
1084        let result = (|| {
1085            let listener = UnixListener::bind(&socket)
1086                .with_context(|| format!("bind {}", socket.display()))?;
1087            fs::set_permissions(&socket, fs::Permissions::from_mode(0o600))
1088                .with_context(|| format!("secure {}", socket.display()))?;
1089            listener
1090                .set_nonblocking(true)
1091                .context("set session socket nonblocking")?;
1092            let mut session =
1093                Session::start(&command, cwd.as_deref(), record.as_deref(), &options)?;
1094            let result = run(&listener, &mut session);
1095            let _ = session.stop();
1096            result
1097        })();
1098        let _ = fs::remove_file(&socket);
1099        result
1100    }
1101
1102    fn ensure_socket_path(path: &Path) -> Result<()> {
1103        if path.as_os_str().as_encoded_bytes().len() >= 100 {
1104            bail!(
1105                "session socket path is too long for portable Unix sockets: {}; set TERMCTRL_RUNTIME_DIR to a shorter directory",
1106                path.display()
1107            );
1108        }
1109        Ok(())
1110    }
1111
1112    fn run(listener: &UnixListener, session: &mut Session) -> Result<()> {
1113        loop {
1114            // Keep parsing and recording output even when no control request is in flight.
1115            session.consume_batch()?;
1116            match listener.accept() {
1117                Ok((stream, _)) => {
1118                    if handle(stream, session)? {
1119                        return Ok(());
1120                    }
1121                }
1122                Err(error) if error.kind() == ErrorKind::WouldBlock => {
1123                    thread::sleep(Duration::from_millis(10));
1124                }
1125                Err(error) => return Err(error).context("accept session request"),
1126            }
1127        }
1128    }
1129
1130    fn handle(mut stream: UnixStream, session: &mut Session) -> Result<bool> {
1131        stream
1132            .set_nonblocking(false)
1133            .context("set session connection blocking")?;
1134        stream
1135            .set_read_timeout(Some(CONTROL_TIMEOUT))
1136            .context("set session request timeout")?;
1137        stream
1138            .set_write_timeout(Some(CONTROL_TIMEOUT))
1139            .context("set session response timeout")?;
1140        let mut bytes = Vec::new();
1141        let response = match Read::by_ref(&mut stream)
1142            .take(MAX_REQUEST_BYTES + 1)
1143            .read_to_end(&mut bytes)
1144        {
1145            Ok(_) if bytes.len() as u64 > MAX_REQUEST_BYTES => Response {
1146                error: Some("session request exceeds 1 MiB".to_owned()),
1147                captured: None,
1148                status: None,
1149                logs: None,
1150            },
1151            Ok(_) => match serde_json::from_slice::<Request>(&bytes) {
1152                Ok(request) => {
1153                    let stop = matches!(request, Request::Stop);
1154                    let response = match respond(session, request) {
1155                        Ok(response) => response,
1156                        Err(error) => Response {
1157                            error: Some(format!("{error:#}")),
1158                            captured: None,
1159                            status: None,
1160                            logs: None,
1161                        },
1162                    };
1163                    if write_response(&mut stream, &response).is_ok() && stop {
1164                        return Ok(true);
1165                    }
1166                    return Ok(false);
1167                }
1168                Err(error) => Response {
1169                    error: Some(format!("invalid session request: {error}")),
1170                    captured: None,
1171                    status: None,
1172                    logs: None,
1173                },
1174            },
1175            Err(error) => Response {
1176                error: Some(format!("failed to read session request: {error}")),
1177                captured: None,
1178                status: None,
1179                logs: None,
1180            },
1181        };
1182        let _ = write_response(&mut stream, &response);
1183        Ok(false)
1184    }
1185
1186    fn write_response(stream: &mut UnixStream, response: &Response) -> Result<()> {
1187        serde_json::to_writer(&mut *stream, response).context("write session response")?;
1188        stream.flush().context("flush session response")
1189    }
1190
1191    fn respond(session: &mut Session, request: Request) -> Result<Response> {
1192        let mut response = Response {
1193            error: None,
1194            captured: None,
1195            status: None,
1196            logs: None,
1197        };
1198        match request {
1199            Request::Ping => {}
1200            Request::Status => response.status = Some(session.status()?),
1201            Request::Send { input, pace_ms } => {
1202                session.send_all(&input, Duration::from_millis(pace_ms))?;
1203            }
1204            Request::Wait { text, timeout_ms } => {
1205                session.wait_for_text(&text, Duration::from_millis(timeout_ms))?;
1206            }
1207            Request::Show {
1208                settle_ms,
1209                deadline_ms,
1210            } => {
1211                response.captured = Some(
1212                    session
1213                        .capture(
1214                            Duration::from_millis(settle_ms),
1215                            Duration::from_millis(deadline_ms),
1216                        )?
1217                        .shot,
1218                );
1219            }
1220            Request::Logs { ansi } => response.logs = Some(session.logs(ansi)?),
1221            Request::Resize {
1222                cols,
1223                rows,
1224                cell_width,
1225                cell_height,
1226            } => {
1227                let status = session.status()?;
1228                session.resize(
1229                    cols,
1230                    rows,
1231                    cell_width.unwrap_or(status.cell_width),
1232                    cell_height.unwrap_or(status.cell_height),
1233                )?;
1234            }
1235            Request::Mark { name } => session.mark(&name)?,
1236            Request::Stop => session.stop()?,
1237        }
1238        Ok(response)
1239    }
1240
1241    #[cfg(test)]
1242    mod tests {
1243        use super::*;
1244
1245        #[test]
1246        fn name_start_lock_rejects_a_concurrent_owner() {
1247            let path = std::env::temp_dir().join(format!(
1248                "termctrl-start-lock-test-{}.lock",
1249                std::process::id()
1250            ));
1251            let held = StartLock::acquire(&path).unwrap();
1252
1253            assert!(StartLock::acquire(&path).is_err());
1254            drop(held);
1255            assert!(StartLock::acquire(&path).is_ok());
1256            let _ = fs::remove_file(path);
1257        }
1258    }
1259}
1260
1261#[cfg(not(unix))]
1262mod implementation {
1263    use super::{NamedSessionStatus, Options, Request, Response};
1264    use anyhow::{Result, bail};
1265    use std::path::{Path, PathBuf};
1266
1267    pub fn runtime_dir() -> Result<PathBuf> {
1268        bail!("persistent sessions require Unix sockets")
1269    }
1270    pub fn start(
1271        _: &str,
1272        _: &[String],
1273        _: Option<&Path>,
1274        _: Option<&Path>,
1275        _: &Options,
1276    ) -> Result<()> {
1277        bail!("persistent sessions require Unix sockets")
1278    }
1279    pub fn restart(
1280        _: &str,
1281        _: &[String],
1282        _: Option<&Path>,
1283        _: Option<&Path>,
1284        _: &Options,
1285    ) -> Result<()> {
1286        bail!("persistent sessions require Unix sockets")
1287    }
1288    pub fn request(_: PathBuf, _: &Request) -> Result<Response> {
1289        bail!("persistent sessions require Unix sockets")
1290    }
1291    pub fn list() -> Result<Vec<NamedSessionStatus>> {
1292        bail!("persistent sessions require Unix sockets")
1293    }
1294    pub fn serve(
1295        _: PathBuf,
1296        _: Vec<String>,
1297        _: Option<PathBuf>,
1298        _: Option<PathBuf>,
1299        _: Options,
1300    ) -> Result<()> {
1301        bail!("persistent sessions require Unix sockets")
1302    }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308
1309    #[cfg(unix)]
1310    #[test]
1311    fn embedded_session_waits_sends_resizes_and_captures_the_screen() {
1312        let mut session = Session::start(
1313            &[
1314                "sh".to_owned(),
1315                "-c".to_owned(),
1316                "printf ready; IFS= read -r line; printf '\\r\\ngot:%s' \"$line\"; sleep 1"
1317                    .to_owned(),
1318            ],
1319            None,
1320            None,
1321            &Options {
1322                cols: 20,
1323                rows: 4,
1324                settle: Duration::from_millis(10),
1325                deadline: Duration::from_secs(2),
1326                ..Options::default()
1327            },
1328        )
1329        .unwrap();
1330
1331        session
1332            .wait_for_text("ready", Duration::from_secs(2))
1333            .unwrap();
1334        session.send(b"hello\r").unwrap();
1335        session
1336            .wait_for_text("got:hello", Duration::from_secs(2))
1337            .unwrap();
1338        assert_eq!(session.status().unwrap().state, SessionState::Running);
1339        session
1340            .wait_for_idle(Duration::from_millis(10), Duration::from_secs(2))
1341            .unwrap();
1342        session.resize(30, 5, 9, 18).unwrap();
1343        let shot = session
1344            .capture(Duration::from_millis(10), Duration::from_secs(2))
1345            .unwrap();
1346
1347        assert_eq!((shot.shot.frame.cols, shot.shot.frame.rows), (30, 5));
1348        assert!(shot.shot.frame.text().contains("got:hello"));
1349        session.stop().unwrap();
1350        assert_eq!(session.status().unwrap().state, SessionState::Exited);
1351        assert!(session.capture(Duration::ZERO, Duration::ZERO).is_ok());
1352    }
1353
1354    #[cfg(unix)]
1355    #[test]
1356    fn capture_reports_a_deadline_instead_of_implying_idle() {
1357        let mut session = Session::start(
1358            &[
1359                "sh".to_owned(),
1360                "-c".to_owned(),
1361                "while :; do printf xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx; sleep 0.001; done"
1362                    .to_owned(),
1363            ],
1364            None,
1365            None,
1366            &Options::default(),
1367        )
1368        .unwrap();
1369
1370        let capture = session
1371            .capture(Duration::from_secs(1), Duration::from_millis(50))
1372            .unwrap();
1373
1374        assert_eq!(capture.reason, CaptureReason::Deadline);
1375        session.stop().unwrap();
1376    }
1377
1378    #[cfg(unix)]
1379    #[test]
1380    fn session_retains_recent_output_without_failing_after_limit() {
1381        let mut session = Session::start(
1382            &[
1383                "sh".to_owned(),
1384                "-c".to_owned(),
1385                "printf '123456789'; sleep 1".to_owned(),
1386            ],
1387            None,
1388            None,
1389            &Options {
1390                max_bytes: 4,
1391                ..Options::default()
1392            },
1393        )
1394        .unwrap();
1395        session
1396            .wait_for_text("123456789", Duration::from_secs(2))
1397            .unwrap();
1398
1399        assert_eq!(session.logs(true).unwrap(), b"6789");
1400        assert!(session.status().unwrap().logs_truncated);
1401        session.stop().unwrap();
1402    }
1403
1404    #[cfg(unix)]
1405    #[test]
1406    fn status_preserves_the_observed_process_exit() {
1407        let mut session = Session::start(
1408            &["sh".to_owned(), "-c".to_owned(), "exit 7".to_owned()],
1409            None,
1410            None,
1411            &Options::default(),
1412        )
1413        .unwrap();
1414        let deadline = Instant::now() + Duration::from_secs(2);
1415        let status = loop {
1416            let status = session.status().unwrap();
1417            if status.state == SessionState::Exited {
1418                break status;
1419            }
1420            assert!(Instant::now() < deadline, "child did not exit");
1421            thread::sleep(Duration::from_millis(10));
1422        };
1423
1424        assert_eq!(status.exit.unwrap().code, 7);
1425    }
1426
1427    #[cfg(unix)]
1428    #[test]
1429    fn status_retains_canonical_launch_details() {
1430        let mut session = Session::start(
1431            &["sh".to_owned(), "-c".to_owned(), "sleep 1".to_owned()],
1432            Some(Path::new("/tmp")),
1433            None,
1434            &Options::default(),
1435        )
1436        .unwrap();
1437
1438        let status = session.status().unwrap();
1439        assert_eq!(status.launch.command[0], "sh");
1440        assert_eq!(status.launch.cwd, std::fs::canonicalize("/tmp").unwrap());
1441        session.stop().unwrap();
1442    }
1443
1444    #[cfg(unix)]
1445    #[test]
1446    fn recorded_session_encodes_resize_in_its_timeline() {
1447        let record = std::env::temp_dir().join(format!(
1448            "termctrl-recorded-resize-test-{}.termctrl",
1449            std::process::id()
1450        ));
1451        let mut session = Session::start(
1452            &["sh".to_owned(), "-c".to_owned(), "sleep 1".to_owned()],
1453            None,
1454            Some(&record),
1455            &Options::default(),
1456        )
1457        .unwrap();
1458
1459        session.resize(100, 32, 9, 18).unwrap();
1460        session.stop().unwrap();
1461        let recording = recording::read(&record).unwrap();
1462        assert!(matches!(
1463            recording.events.last(),
1464            Some(recording::Entry::Resize {
1465                cols: 100,
1466                rows: 32,
1467                ..
1468            })
1469        ));
1470        let _ = std::fs::remove_file(record);
1471    }
1472
1473    #[cfg(unix)]
1474    #[test]
1475    fn waits_for_exit_without_polling_status() {
1476        let mut session = Session::start(
1477            &["sh".to_owned(), "-c".to_owned(), "exit 3".to_owned()],
1478            None,
1479            None,
1480            &Options::default(),
1481        )
1482        .unwrap();
1483
1484        assert_eq!(
1485            session
1486                .wait_for_exit(Duration::from_secs(2))
1487                .unwrap()
1488                .unwrap()
1489                .code,
1490            3
1491        );
1492    }
1493
1494    #[cfg(unix)]
1495    #[test]
1496    fn logs_expose_normal_screen_scrollback_and_raw_stream() {
1497        let mut session = Session::start(
1498            &[
1499                "sh".to_owned(),
1500                "-c".to_owned(),
1501                "printf 'one\r\ntwo\r\nthree\r\nfour\r\nfive\r\n'; sleep 1".to_owned(),
1502            ],
1503            None,
1504            None,
1505            &Options {
1506                cols: 20,
1507                rows: 2,
1508                ..Options::default()
1509            },
1510        )
1511        .unwrap();
1512        session
1513            .wait_for_text("five", Duration::from_secs(2))
1514            .unwrap();
1515
1516        let logs = String::from_utf8(session.logs(false).unwrap()).unwrap();
1517        assert!(logs.contains("one"));
1518        assert!(logs.contains("five"));
1519        assert!(
1520            session
1521                .logs(true)
1522                .unwrap()
1523                .windows(3)
1524                .any(|bytes| bytes == b"one")
1525        );
1526        session.stop().unwrap();
1527    }
1528
1529    #[cfg(unix)]
1530    #[test]
1531    fn stopping_after_pty_eof_terminates_still_running_process() {
1532        let pid_path = std::env::temp_dir().join(format!(
1533            "termctrl-pty-eof-owner-test-{}.pid",
1534            std::process::id()
1535        ));
1536        let script = format!(
1537            "printf '%s' $$ > '{}'; exec >/dev/null 2>&1; sleep 30",
1538            pid_path.display()
1539        );
1540        let mut session = Session::start(
1541            &["sh".to_owned(), "-c".to_owned(), script],
1542            None,
1543            None,
1544            &Options::default(),
1545        )
1546        .unwrap();
1547        let deadline = Instant::now() + Duration::from_secs(2);
1548        let pid = loop {
1549            if let Ok(pid) = std::fs::read_to_string(&pid_path) {
1550                break pid.parse::<i32>().unwrap();
1551            }
1552            assert!(Instant::now() < deadline, "child did not write its pid");
1553            thread::sleep(Duration::from_millis(10));
1554        };
1555        thread::sleep(Duration::from_millis(20));
1556
1557        assert_eq!(session.status().unwrap().state, SessionState::Running);
1558        session.stop().unwrap();
1559        assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
1560        let _ = std::fs::remove_file(pid_path);
1561    }
1562
1563    #[cfg(unix)]
1564    #[test]
1565    fn natural_parent_exit_terminates_pty_holding_descendants() {
1566        let pid_path = std::env::temp_dir().join(format!(
1567            "termctrl-exited-owner-test-{}.pid",
1568            std::process::id()
1569        ));
1570        let script = format!(
1571            "sleep 30 & printf '%s' $! > '{}'; exit 0",
1572            pid_path.display()
1573        );
1574        let mut session = Session::start(
1575            &["sh".to_owned(), "-c".to_owned(), script],
1576            None,
1577            None,
1578            &Options::default(),
1579        )
1580        .unwrap();
1581
1582        session
1583            .wait_for_exit(Duration::from_secs(2))
1584            .unwrap()
1585            .unwrap();
1586        let pid = std::fs::read_to_string(&pid_path)
1587            .unwrap()
1588            .parse::<i32>()
1589            .unwrap();
1590
1591        assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
1592        let _ = std::fs::remove_file(pid_path);
1593    }
1594
1595    #[cfg(unix)]
1596    #[test]
1597    fn daemon_start_failure_removes_bound_socket() {
1598        let socket = std::env::temp_dir().join(format!(
1599            "termctrl-failed-daemon-start-{}.sock",
1600            std::process::id()
1601        ));
1602        let result = serve(
1603            socket.clone(),
1604            vec!["/definitely/not/a/termctrl-command".to_owned()],
1605            None,
1606            None,
1607            Options::default(),
1608        );
1609
1610        assert!(result.is_err());
1611        assert!(!socket.exists());
1612    }
1613}