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
27pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum SessionState {
59 Running,
60 Exited,
61}
62
63#[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#[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#[derive(Deserialize, Serialize)]
93pub struct CaptureResult {
94 pub shot: Shot,
95 pub reason: CaptureReason,
96}
97
98#[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#[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#[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#[derive(Clone, Copy, Debug, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum UnavailableReason {
142 Stale,
143 IncompatibleProtocol,
144}
145
146impl Session {
147 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 pub fn send(&mut self, input: &[u8]) -> Result<()> {
267 self.send_all(&[input.to_vec()], Duration::ZERO)
268 }
269
270 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 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 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 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 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 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 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 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 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 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 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 #[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 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}