Skip to main content

fxrs_process/
terminal_host_server.rs

1//! Unix supervisor lifecycle shared by the detached host binary and tests.
2
3use std::fs::{self, File, OpenOptions};
4use std::io;
5use std::os::unix::ffi::OsStrExt as _;
6use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
7use std::os::unix::net::{UnixListener, UnixStream};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
11use std::sync::{Arc, Mutex};
12use std::thread;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14use std::{collections::BTreeMap, sync::mpsc};
15
16use fs4::TryLockError;
17use fx_core::{CancellationSignal, ToolError, ToolOutput};
18use serde_json::{Value, json};
19use sha2::{Digest as _, Sha256};
20use uuid::Uuid;
21
22use crate::native_terminal::{
23    ClosePolicy, NamedKey, SessionBackend, StartSpec, TerminalSessionHost, TerminalSignal,
24    WaitCondition, WritePayload, cursor, failure_output, success_output, valid_dimensions,
25};
26use crate::terminal_host::RoutingTerminalHost;
27use crate::terminal_host_protocol::{
28    CAPABILITY_CANCELLATION, CAPABILITY_TERMINAL_MONITORS, CAPABILITY_TERMINAL_SESSIONS,
29    HostRequest, HostResponse, ProtocolHello, ReturnCondition, TerminalBackend,
30    TerminalClosePolicy, TerminalKey, TerminalOperation, TerminalSignal as WireSignal,
31    TerminalWrite, read_frame, write_frame,
32};
33use crate::terminal_monitor_store::{MonitorContext, MonitorStore};
34use crate::terminal_observation::{ObservedLifecycle, TerminalMonitorSource};
35
36pub const INTERNAL_MODE: &str = "--fx-internal-terminal-host";
37pub const ENDPOINT_NAME: &str = "host.sock";
38const LOCK_NAME: &str = "host.lock";
39const ACCEPT_POLL: Duration = Duration::from_millis(10);
40const IO_TIMEOUT: Duration = Duration::from_secs(5);
41const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
42
43#[derive(Clone, Debug)]
44pub struct HostServerConfig {
45    pub state_directory: PathBuf,
46    pub idle_grace: Duration,
47}
48
49struct HostRuntime {
50    sessions: RoutingTerminalHost,
51    monitor_store: Mutex<MonitorStore>,
52    monitors_present: AtomicBool,
53    monitor_failure: Mutex<Option<String>>,
54    cancellations: Mutex<BTreeMap<String, Arc<HostCancellation>>>,
55}
56
57impl HostRuntime {
58    fn new(state_directory: &Path) -> Result<Self, HostServerError> {
59        let monitor_store = MonitorStore::new(state_directory)
60            .map_err(|error| HostServerError::MonitorState(error.to_string()))?;
61        let monitors_present = !monitor_store
62            .monitored_sessions()
63            .map_err(|error| HostServerError::MonitorState(error.to_string()))?
64            .is_empty();
65        Ok(Self {
66            sessions: RoutingTerminalHost::local(),
67            monitor_store: Mutex::new(monitor_store),
68            monitors_present: AtomicBool::new(monitors_present),
69            monitor_failure: Mutex::new(None),
70            cancellations: Mutex::new(BTreeMap::new()),
71        })
72    }
73
74    fn register(&self, request_id: &str) -> Result<Arc<HostCancellation>, HostServerError> {
75        if !valid_request_id(request_id) {
76            return Err(HostServerError::InvalidRequestId);
77        }
78        let mut requests = self
79            .cancellations
80            .lock()
81            .map_err(|_| HostServerError::RequestMapPoisoned)?;
82        if requests.contains_key(request_id) {
83            return Err(HostServerError::DuplicateRequestId);
84        }
85        let cancellation = Arc::new(HostCancellation::default());
86        requests.insert(request_id.to_owned(), cancellation.clone());
87        Ok(cancellation)
88    }
89
90    fn finish(&self, request_id: &str) {
91        if let Ok(mut requests) = self.cancellations.lock() {
92            requests.remove(request_id);
93        }
94    }
95
96    fn cancel(&self, request_id: &str) -> Result<bool, HostServerError> {
97        if !valid_request_id(request_id) {
98            return Err(HostServerError::InvalidRequestId);
99        }
100        let requests = self
101            .cancellations
102            .lock()
103            .map_err(|_| HostServerError::RequestMapPoisoned)?;
104        let Some(cancellation) = requests.get(request_id) else {
105            return Ok(false);
106        };
107        cancellation.cancelled.store(true, Ordering::Release);
108        Ok(true)
109    }
110
111    fn evaluate_monitors(&self) -> Result<(), ToolError> {
112        if !self.monitors_present.load(Ordering::Acquire) {
113            return Ok(());
114        }
115        let session_ids = self
116            .monitor_store
117            .lock()
118            .map_err(|_| monitor_store_poisoned())?
119            .monitored_sessions()?;
120        for session_id in session_ids {
121            let request = self
122                .monitor_store
123                .lock()
124                .map_err(|_| monitor_store_poisoned())?
125                .observation_request(&session_id)?;
126            let Some((cursor_offset, include_screen)) = request else {
127                continue;
128            };
129            let observation =
130                self.sessions
131                    .observe_terminal(&session_id, cursor_offset, include_screen)?;
132            let store = self
133                .monitor_store
134                .lock()
135                .map_err(|_| monitor_store_poisoned())?;
136            if let Some(observation) = observation {
137                store.evaluate_terminal(&session_id, &observation, now_ms()?)?;
138            } else {
139                store.end_missing_session(&session_id, now_ms()?)?;
140            }
141        }
142        self.refresh_monitors_present()
143    }
144
145    fn refresh_monitors_present(&self) -> Result<(), ToolError> {
146        let present = !self
147            .monitor_store
148            .lock()
149            .map_err(|_| monitor_store_poisoned())?
150            .monitored_sessions()?
151            .is_empty();
152        self.monitors_present.store(present, Ordering::Release);
153        Ok(())
154    }
155
156    fn record_monitor_failure(&self, error: ToolError) {
157        if let Ok(mut failure) = self.monitor_failure.lock()
158            && failure.is_none()
159        {
160            *failure = Some(error.to_string());
161        }
162    }
163
164    fn take_monitor_failure(&self) -> Result<Option<String>, HostServerError> {
165        self.monitor_failure
166            .lock()
167            .map(|mut failure| failure.take())
168            .map_err(|_| HostServerError::MonitorState("failure lock is poisoned".into()))
169    }
170}
171
172#[derive(Default)]
173struct HostCancellation {
174    cancelled: AtomicBool,
175}
176
177impl CancellationSignal for HostCancellation {
178    fn is_cancelled(&self) -> bool {
179        self.cancelled.load(Ordering::Acquire)
180    }
181}
182
183impl HostServerConfig {
184    pub fn from_environment() -> Result<Self, HostServerError> {
185        let state_directory = match std::env::var_os("FX_TERMINAL_HOST_DIR") {
186            Some(path) => PathBuf::from(path),
187            None => PathBuf::from(std::env::var_os("HOME").ok_or(HostServerError::MissingHome)?)
188                .join(".fx/terminal-host-rs"),
189        };
190        if !state_directory.is_absolute() {
191            return Err(HostServerError::UnsafeStateDirectory);
192        }
193        let idle_ms = std::env::var("FX_TERMINAL_HOST_IDLE_MS")
194            .ok()
195            .map(|value| value.parse::<u64>())
196            .transpose()
197            .map_err(|_| HostServerError::InvalidIdleGrace)?
198            .unwrap_or(30_000);
199        if idle_ms == 0 {
200            return Err(HostServerError::InvalidIdleGrace);
201        }
202        Ok(Self {
203            state_directory,
204            idle_grace: Duration::from_millis(idle_ms),
205        })
206    }
207
208    pub fn endpoint(&self) -> PathBuf {
209        self.transport_directory().join(ENDPOINT_NAME)
210    }
211
212    fn transport_directory(&self) -> PathBuf {
213        let preferred = self.state_directory.join(ENDPOINT_NAME);
214        if preferred.as_os_str().as_bytes().len() < endpoint_path_limit() {
215            return self.state_directory.clone();
216        }
217        let digest = Sha256::digest(self.state_directory.as_os_str().as_bytes());
218        let hash = hex::encode(&digest[..16]);
219        let uid = unsafe { nix::libc::getuid() };
220        runtime_base().join(format!("fx-terminal-rs-{uid}-{hash}"))
221    }
222}
223
224pub fn run(config: HostServerConfig) -> Result<(), HostServerError> {
225    prepare_private_directory(&config.state_directory)?;
226    let lock = open_private_file(&config.state_directory.join(LOCK_NAME))?;
227    match fs4::FileExt::try_lock(&lock) {
228        Ok(()) => {}
229        Err(TryLockError::WouldBlock) => return Ok(()),
230        Err(TryLockError::Error(error)) => return Err(HostServerError::Io(error)),
231    }
232
233    let transport_directory = config.transport_directory();
234    if transport_directory != config.state_directory {
235        prepare_private_directory(&transport_directory)?;
236    }
237    let endpoint = config.endpoint();
238    reject_symlink(&endpoint)?;
239    match fs::remove_file(&endpoint) {
240        Ok(()) => {}
241        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
242        Err(error) => return Err(HostServerError::Io(error)),
243    }
244    let listener = UnixListener::bind(&endpoint).map_err(HostServerError::Io)?;
245    fs::set_permissions(&endpoint, fs::Permissions::from_mode(0o600))
246        .map_err(HostServerError::Io)?;
247    let endpoint_guard = EndpointGuard {
248        endpoint,
249        transport_directory: (transport_directory != config.state_directory)
250            .then_some(transport_directory),
251    };
252    listener
253        .set_nonblocking(true)
254        .map_err(HostServerError::Io)?;
255    let instance_id = Uuid::new_v4().simple().to_string();
256    let runtime = Arc::new(HostRuntime::new(&config.state_directory)?);
257    let _monitor_worker = MonitorWorker::spawn(runtime.clone())?;
258    let mut last_activity = Instant::now();
259    let active_connections = Arc::new(AtomicUsize::new(0));
260
261    loop {
262        match listener.accept() {
263            Ok((stream, _)) => {
264                last_activity = Instant::now();
265                active_connections.fetch_add(1, Ordering::AcqRel);
266                let active = active_connections.clone();
267                let instance_id = instance_id.clone();
268                let runtime = runtime.clone();
269                thread::Builder::new()
270                    .name("fx-terminal-host-client".into())
271                    .spawn(move || {
272                        let mut stream = stream;
273                        if configure_stream(&stream).is_ok() {
274                            let _ = handle_connection(&mut stream, &instance_id, runtime.as_ref());
275                        }
276                        active.fetch_sub(1, Ordering::AcqRel);
277                    })
278                    .map_err(|error| {
279                        active_connections.fetch_sub(1, Ordering::AcqRel);
280                        HostServerError::Io(error)
281                    })?;
282            }
283            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
284                if let Some(failure) = runtime.take_monitor_failure()? {
285                    return Err(HostServerError::MonitorState(failure));
286                }
287                if active_connections.load(Ordering::Acquire) > 0
288                    || runtime.sessions.has_process_local_sessions()
289                    || runtime.monitors_present.load(Ordering::Acquire)
290                {
291                    last_activity = Instant::now();
292                } else if last_activity.elapsed() >= config.idle_grace {
293                    break;
294                }
295                thread::sleep(ACCEPT_POLL.min(config.idle_grace - last_activity.elapsed()));
296            }
297            Err(error) => return Err(HostServerError::Io(error)),
298        }
299    }
300    drop(endpoint_guard);
301    drop(lock);
302    Ok(())
303}
304
305struct MonitorWorker {
306    stopping: Arc<AtomicBool>,
307    handle: Option<thread::JoinHandle<()>>,
308}
309
310impl MonitorWorker {
311    fn spawn(runtime: Arc<HostRuntime>) -> Result<Self, HostServerError> {
312        let stopping = Arc::new(AtomicBool::new(false));
313        let worker_stopping = stopping.clone();
314        let handle = thread::Builder::new()
315            .name("fx-terminal-monitor".into())
316            .spawn(move || {
317                while !worker_stopping.load(Ordering::Acquire) {
318                    if let Err(error) = runtime.evaluate_monitors() {
319                        runtime.record_monitor_failure(error);
320                        break;
321                    }
322                    let delay = if runtime.monitors_present.load(Ordering::Acquire) {
323                        Duration::from_millis(10)
324                    } else {
325                        Duration::from_millis(50)
326                    };
327                    thread::sleep(delay);
328                }
329            })
330            .map_err(HostServerError::Io)?;
331        Ok(Self {
332            stopping,
333            handle: Some(handle),
334        })
335    }
336}
337
338impl Drop for MonitorWorker {
339    fn drop(&mut self) {
340        self.stopping.store(true, Ordering::Release);
341        if let Some(handle) = self.handle.take() {
342            let _ = handle.join();
343        }
344    }
345}
346
347pub fn request(endpoint: &Path, request: &HostRequest) -> Result<HostResponse, HostServerError> {
348    request_with_timeout(endpoint, request, IO_TIMEOUT)
349}
350
351fn request_with_timeout(
352    endpoint: &Path,
353    request: &HostRequest,
354    timeout: Duration,
355) -> Result<HostResponse, HostServerError> {
356    let mut stream = UnixStream::connect(endpoint).map_err(HostServerError::Io)?;
357    configure_stream_with_timeout(&stream, timeout)?;
358    write_frame(&mut stream, request).map_err(HostServerError::Protocol)?;
359    read_frame(&mut stream).map_err(HostServerError::Protocol)
360}
361
362#[derive(Clone, Debug)]
363pub struct HostClient {
364    endpoint: PathBuf,
365    instance_id: String,
366}
367
368impl HostClient {
369    pub fn connect_or_spawn(
370        config: &HostServerConfig,
371        executable: &Path,
372    ) -> Result<Self, HostServerError> {
373        if let Ok(client) = Self::connect(config.endpoint()) {
374            return Ok(client);
375        }
376        if !executable.is_absolute() || !executable.is_file() {
377            return Err(HostServerError::InvalidExecutable);
378        }
379        use std::os::unix::process::CommandExt as _;
380        let mut child = Command::new(executable)
381            .arg(INTERNAL_MODE)
382            .env("FX_TERMINAL_HOST_DIR", &config.state_directory)
383            .env(
384                "FX_TERMINAL_HOST_IDLE_MS",
385                u64::try_from(config.idle_grace.as_millis())
386                    .unwrap_or(u64::MAX)
387                    .to_string(),
388            )
389            .stdin(Stdio::null())
390            .stdout(Stdio::null())
391            .stderr(Stdio::null())
392            .process_group(0)
393            .spawn()
394            .map_err(HostServerError::Io)?;
395        let deadline = Instant::now() + STARTUP_TIMEOUT;
396        loop {
397            if let Ok(client) = Self::connect(config.endpoint()) {
398                reap(child);
399                return Ok(client);
400            }
401            if Instant::now() >= deadline {
402                let _ = child.kill();
403                let _ = child.wait();
404                return Err(HostServerError::StartupTimeout);
405            }
406            if let Some(status) = child.try_wait().map_err(HostServerError::Io)?
407                && !status.success()
408            {
409                return Err(HostServerError::StartupFailed(status.code()));
410            }
411            thread::sleep(Duration::from_millis(10));
412        }
413    }
414
415    pub fn connect(endpoint: PathBuf) -> Result<Self, HostServerError> {
416        let response = request(
417            &endpoint,
418            &HostRequest::Hello {
419                hello: ProtocolHello::local(),
420            },
421        )?;
422        let HostResponse::Hello {
423            hello,
424            negotiated,
425            instance_id,
426        } = response
427        else {
428            return Err(HostServerError::HandshakeFailed);
429        };
430        let expected = ProtocolHello::local()
431            .negotiate(hello)
432            .map_err(|_| HostServerError::HandshakeFailed)?;
433        let required =
434            CAPABILITY_TERMINAL_SESSIONS | CAPABILITY_CANCELLATION | CAPABILITY_TERMINAL_MONITORS;
435        if negotiated != expected
436            || hello.capabilities & required != required
437            || instance_id.len() != 32
438        {
439            return Err(HostServerError::HandshakeFailed);
440        }
441        Ok(Self {
442            endpoint,
443            instance_id,
444        })
445    }
446
447    pub fn endpoint(&self) -> &Path {
448        &self.endpoint
449    }
450
451    pub fn instance_id(&self) -> &str {
452        &self.instance_id
453    }
454
455    pub fn ping(&self) -> Result<(), HostServerError> {
456        match request(&self.endpoint, &HostRequest::Ping)? {
457            HostResponse::Pong => Ok(()),
458            _ => Err(HostServerError::HandshakeFailed),
459        }
460    }
461
462    pub fn terminal(&self, operation: TerminalOperation) -> Result<ToolOutput, HostServerError> {
463        let request_id = Uuid::new_v4().simple().to_string();
464        let timeout = operation_timeout(&operation);
465        let response = request_with_timeout(
466            &self.endpoint,
467            &HostRequest::Terminal {
468                request_id,
469                operation: Box::new(operation),
470            },
471            timeout,
472        )?;
473        terminal_response(response)
474    }
475
476    pub fn terminal_cancellable(
477        &self,
478        operation: TerminalOperation,
479        cancellation: Arc<dyn CancellationSignal>,
480    ) -> Result<ToolOutput, HostServerError> {
481        let request_id = Uuid::new_v4().simple().to_string();
482        let endpoint = self.endpoint.clone();
483        let timeout = operation_timeout(&operation);
484        let worker_id = request_id.clone();
485        let (sender, receiver) = mpsc::sync_channel(1);
486        thread::Builder::new()
487            .name("fx-terminal-host-request".into())
488            .spawn(move || {
489                let result = request_with_timeout(
490                    &endpoint,
491                    &HostRequest::Terminal {
492                        request_id: worker_id,
493                        operation: Box::new(operation),
494                    },
495                    timeout,
496                );
497                let _ = sender.send(result);
498            })
499            .map_err(HostServerError::Io)?;
500        let mut cancellation_accepted = false;
501        loop {
502            match receiver.recv_timeout(Duration::from_millis(10)) {
503                Ok(response) => return terminal_response(response?),
504                Err(mpsc::RecvTimeoutError::Disconnected) => {
505                    return Err(HostServerError::RequestWorkerStopped);
506                }
507                Err(mpsc::RecvTimeoutError::Timeout) => {}
508            }
509            if cancellation.is_cancelled() && !cancellation_accepted {
510                cancellation_accepted = matches!(
511                    request(
512                        &self.endpoint,
513                        &HostRequest::Cancel {
514                            request_id: request_id.clone()
515                        }
516                    ),
517                    Ok(HostResponse::Cancelled { accepted: true })
518                );
519            }
520        }
521    }
522}
523
524fn terminal_response(response: HostResponse) -> Result<ToolOutput, HostServerError> {
525    match response {
526        HostResponse::Terminal { output } => Ok(output),
527        HostResponse::Failure { code, message } => {
528            Err(HostServerError::RemoteFailure { code, message })
529        }
530        _ => Err(HostServerError::HandshakeFailed),
531    }
532}
533
534fn operation_timeout(operation: &TerminalOperation) -> Duration {
535    let wait_ms = match operation {
536        TerminalOperation::Start {
537            wait_ceiling_ms, ..
538        } => *wait_ceiling_ms,
539        TerminalOperation::Wait {
540            wait_ceiling_ms, ..
541        } => Some(*wait_ceiling_ms),
542        _ => None,
543    };
544    wait_ms.map_or(IO_TIMEOUT, |milliseconds| {
545        Duration::from_millis(milliseconds).saturating_add(IO_TIMEOUT)
546    })
547}
548
549fn reap(mut child: std::process::Child) {
550    let _ = thread::Builder::new()
551        .name("fx-terminal-host-reaper".into())
552        .spawn(move || {
553            let _ = child.wait();
554        });
555}
556
557fn handle_connection(
558    stream: &mut UnixStream,
559    instance_id: &str,
560    runtime: &HostRuntime,
561) -> Result<(), HostServerError> {
562    let request: HostRequest = read_frame(stream).map_err(HostServerError::Protocol)?;
563    let response = match request {
564        HostRequest::Hello { hello } => match ProtocolHello::local().negotiate(hello) {
565            Ok(negotiated) => HostResponse::Hello {
566                hello: ProtocolHello::local(),
567                negotiated,
568                instance_id: instance_id.to_owned(),
569            },
570            Err(error) => HostResponse::Failure {
571                code: "incompatible_protocol".into(),
572                message: error.to_string(),
573            },
574        },
575        HostRequest::Ping => HostResponse::Pong,
576        HostRequest::Terminal {
577            request_id,
578            operation,
579        } => match runtime.register(&request_id) {
580            Ok(cancellation) => {
581                let result = execute_terminal(runtime, *operation, cancellation);
582                runtime.finish(&request_id);
583                match result {
584                    Ok(output) => HostResponse::Terminal { output },
585                    Err(error) => HostResponse::Failure {
586                        code: "terminal_operation_failed".into(),
587                        message: error.to_string(),
588                    },
589                }
590            }
591            Err(error) => HostResponse::Failure {
592                code: "invalid_request".into(),
593                message: error.to_string(),
594            },
595        },
596        HostRequest::Cancel { request_id } => match runtime.cancel(&request_id) {
597            Ok(accepted) => HostResponse::Cancelled { accepted },
598            Err(error) => HostResponse::Failure {
599                code: "invalid_request".into(),
600                message: error.to_string(),
601            },
602        },
603    };
604    write_frame(stream, &response).map_err(HostServerError::Protocol)
605}
606
607fn execute_terminal(
608    runtime: &HostRuntime,
609    operation: TerminalOperation,
610    cancellation: Arc<dyn CancellationSignal>,
611) -> Result<ToolOutput, ToolError> {
612    let sessions = &runtime.sessions;
613    match operation {
614        TerminalOperation::Start {
615            backend,
616            workspace_root,
617            cwd,
618            initial_monitors,
619            command,
620            shell,
621            arguments,
622            sandbox_profile,
623            rows,
624            columns,
625            return_when,
626            wait_ceiling_ms,
627        } => {
628            validate_start(StartValidation {
629                workspace_root: &workspace_root,
630                cwd: &cwd,
631                command: command.as_deref(),
632                shell: &shell,
633                arguments: &arguments,
634                sandbox_profile: sandbox_profile.as_deref(),
635                rows,
636                columns,
637                return_when: return_when.as_ref(),
638                wait_ceiling_ms,
639            })?;
640            let installed_at = now_ms()?;
641            runtime
642                .monitor_store
643                .lock()
644                .map_err(|_| monitor_store_poisoned())?
645                .validate_initial(&initial_monitors, &workspace_root, &cwd, installed_at)?;
646            let requested_return = return_when.map(map_return).transpose()?;
647            let output = sessions.start(
648                StartSpec {
649                    backend: map_backend(backend),
650                    workspace_root: workspace_root.clone(),
651                    cwd: cwd.clone(),
652                    initial_monitors: Vec::new(),
653                    command,
654                    shell,
655                    arguments,
656                    sandbox_profile,
657                    rows,
658                    columns,
659                    return_when: Some(WaitCondition::Started),
660                    wait_ceiling: None,
661                },
662                cancellation.clone(),
663            )?;
664            if output.is_error {
665                return Ok(output);
666            }
667            let session_id = output_session_id(&output, "start")?;
668            let source = sessions
669                .observe_terminal(&session_id, 0, false)?
670                .ok_or_else(|| ToolError::Execution("new terminal session was lost".into()))?;
671            let installed = runtime
672                .monitor_store
673                .lock()
674                .map_err(|_| monitor_store_poisoned())?
675                .install_initial(
676                    &session_id,
677                    &initial_monitors,
678                    MonitorContext {
679                        current_cursor: 0,
680                        lifecycle: source.lifecycle,
681                        workspace_root: &workspace_root,
682                        cwd: &cwd,
683                        now_ms: installed_at,
684                    },
685                );
686            if let Err(error) = installed {
687                let _ = sessions.close(&session_id, ClosePolicy::Force);
688                return Err(error);
689            }
690            runtime.refresh_monitors_present()?;
691            let output = match requested_return {
692                None | Some(WaitCondition::Started) => output,
693                Some(condition) => rename_success_action(
694                    sessions.wait(
695                        &session_id,
696                        condition,
697                        Duration::from_millis(wait_ceiling_ms.unwrap_or_default()),
698                        cancellation,
699                    )?,
700                    "wait",
701                    "start",
702                )?,
703            };
704            decorate_terminal_output(runtime, "start", Some(&session_id), output)
705        }
706        TerminalOperation::Read {
707            session_id,
708            cursor_segment,
709            cursor_offset,
710        } => {
711            let output = sessions.read(&session_id, cursor_segment, cursor_offset)?;
712            decorate_terminal_output(runtime, "read", Some(&session_id), output)
713        }
714        TerminalOperation::Screen { session_id } => {
715            let output = sessions.screen(&session_id)?;
716            decorate_terminal_output(runtime, "screen", Some(&session_id), output)
717        }
718        TerminalOperation::Write { session_id, write } => {
719            let output = sessions.write(&session_id, map_write(write)?)?;
720            decorate_terminal_output(runtime, "write", Some(&session_id), output)
721        }
722        TerminalOperation::Wait {
723            session_id,
724            return_when,
725            wait_ceiling_ms,
726        } => {
727            if wait_ceiling_ms == 0 {
728                return Err(ToolError::InvalidArguments(
729                    "terminal wait ceiling must be positive".into(),
730                ));
731            }
732            let output = sessions.wait(
733                &session_id,
734                map_return(return_when)?,
735                Duration::from_millis(wait_ceiling_ms),
736                cancellation,
737            )?;
738            decorate_terminal_output(runtime, "wait", Some(&session_id), output)
739        }
740        TerminalOperation::Monitor {
741            session_id,
742            operation,
743        } => {
744            let Some(mut facts) = session_facts(sessions, &session_id)? else {
745                return Ok(failure_output(
746                    "monitor",
747                    Some(&session_id),
748                    "session_not_found",
749                    false,
750                ));
751            };
752            let observation = observation_from_facts(&facts)?;
753            let Some(source) =
754                sessions.observe_terminal(&session_id, observation.cursor_offset, false)?
755            else {
756                return Ok(failure_output(
757                    "monitor",
758                    Some(&session_id),
759                    "session_not_found",
760                    false,
761                ));
762            };
763            let (set, monitor_id) = {
764                runtime
765                    .monitor_store
766                    .lock()
767                    .map_err(|_| monitor_store_poisoned())?
768                    .operate(
769                        &session_id,
770                        operation,
771                        MonitorContext {
772                            current_cursor: observation.cursor_offset,
773                            lifecycle: observation.lifecycle,
774                            workspace_root: &source.workspace_root,
775                            cwd: &source.cwd,
776                            now_ms: now_ms()?,
777                        },
778                    )?
779            };
780            runtime.refresh_monitors_present()?;
781            project_monitor_facts(&mut facts, set.active_count())?;
782            Ok(success_output(
783                "monitor",
784                json!({"session": facts, "monitor_id": monitor_id}),
785            ))
786        }
787        TerminalOperation::Inspect {
788            session_id,
789            after_event_id,
790            acknowledge_event_id,
791            max_events,
792        } => inspect_with_monitors(
793            runtime,
794            &session_id,
795            after_event_id,
796            acknowledge_event_id,
797            usize::from(max_events),
798        ),
799        TerminalOperation::List { backend } => {
800            decorate_terminal_list(runtime, sessions.list(backend.map(map_backend))?)
801        }
802        TerminalOperation::Resize {
803            session_id,
804            rows,
805            columns,
806        } => {
807            let output = sessions.resize(&session_id, rows, columns)?;
808            decorate_terminal_output(runtime, "resize", Some(&session_id), output)
809        }
810        TerminalOperation::Signal { session_id, signal } => {
811            let output = sessions.signal(&session_id, map_signal(signal))?;
812            decorate_terminal_output(runtime, "signal", Some(&session_id), output)
813        }
814        TerminalOperation::Close {
815            session_id,
816            close_policy,
817        } => {
818            let output = sessions.close(
819                &session_id,
820                match close_policy {
821                    TerminalClosePolicy::Graceful => ClosePolicy::Graceful,
822                    TerminalClosePolicy::Force => ClosePolicy::Force,
823                },
824            )?;
825            runtime
826                .monitor_store
827                .lock()
828                .map_err(|_| monitor_store_poisoned())?
829                .end_session(&session_id, ObservedLifecycle::Closed, now_ms()?)?;
830            runtime.refresh_monitors_present()?;
831            decorate_terminal_output(runtime, "close", Some(&session_id), output)
832        }
833    }
834}
835
836fn output_session_id(output: &ToolOutput, action: &str) -> Result<String, ToolError> {
837    output
838        .structured
839        .as_ref()
840        .and_then(|root| root.pointer(&format!("/success/{action}/session/session_id")))
841        .and_then(Value::as_str)
842        .map(str::to_owned)
843        .ok_or_else(|| ToolError::Execution("terminal host omitted session id".into()))
844}
845
846fn rename_success_action(
847    output: ToolOutput,
848    source_action: &str,
849    target_action: &str,
850) -> Result<ToolOutput, ToolError> {
851    if output.is_error {
852        return Ok(output);
853    }
854    let payload = output
855        .structured
856        .as_ref()
857        .and_then(|root| root.pointer(&format!("/success/{source_action}")))
858        .cloned()
859        .ok_or_else(|| ToolError::Execution("terminal host returned an invalid result".into()))?;
860    Ok(success_output(target_action, payload))
861}
862
863fn decorate_terminal_output(
864    runtime: &HostRuntime,
865    action: &str,
866    expected_session_id: Option<&str>,
867    output: ToolOutput,
868) -> Result<ToolOutput, ToolError> {
869    if output.is_error {
870        return Ok(output);
871    }
872    let mut payload = output
873        .structured
874        .as_ref()
875        .and_then(|root| root.pointer(&format!("/success/{action}")))
876        .cloned()
877        .ok_or_else(|| ToolError::Execution("terminal host returned an invalid result".into()))?;
878    let facts = payload
879        .get_mut("session")
880        .ok_or_else(|| ToolError::Execution("terminal host omitted session facts".into()))?;
881    let session_id = facts
882        .get("session_id")
883        .and_then(Value::as_str)
884        .map(str::to_owned)
885        .ok_or_else(|| {
886            ToolError::Execution("terminal host returned an invalid session id".into())
887        })?;
888    if expected_session_id.is_some_and(|expected| expected != session_id) {
889        return Err(ToolError::Execution(
890            "terminal host returned mismatched session facts".into(),
891        ));
892    }
893    let active_count = runtime
894        .monitor_store
895        .lock()
896        .map_err(|_| monitor_store_poisoned())?
897        .active_count_for(&session_id)?;
898    project_monitor_facts(facts, active_count)?;
899    Ok(success_output(action, payload))
900}
901
902fn decorate_terminal_list(
903    runtime: &HostRuntime,
904    output: ToolOutput,
905) -> Result<ToolOutput, ToolError> {
906    if output.is_error {
907        return Ok(output);
908    }
909    let mut payload = output
910        .structured
911        .as_ref()
912        .and_then(|root| root.pointer("/success/list"))
913        .cloned()
914        .ok_or_else(|| {
915            ToolError::Execution("terminal host returned an invalid list result".into())
916        })?;
917    let sessions = payload
918        .get_mut("sessions")
919        .and_then(Value::as_array_mut)
920        .ok_or_else(|| {
921            ToolError::Execution("terminal host returned an invalid session list".into())
922        })?;
923    let store = runtime
924        .monitor_store
925        .lock()
926        .map_err(|_| monitor_store_poisoned())?;
927    for facts in sessions {
928        let session_id = facts
929            .get("session_id")
930            .and_then(Value::as_str)
931            .map(str::to_owned)
932            .ok_or_else(|| {
933                ToolError::Execution("terminal host returned an invalid session id".into())
934            })?;
935        let active_count = store.active_count_for(&session_id)?;
936        project_monitor_facts(facts, active_count)?;
937    }
938    Ok(success_output("list", payload))
939}
940
941#[derive(Clone, Copy)]
942struct SessionObservation {
943    lifecycle: ObservedLifecycle,
944    cursor_offset: u64,
945}
946
947fn session_facts(
948    sessions: &RoutingTerminalHost,
949    session_id: &str,
950) -> Result<Option<Value>, ToolError> {
951    let output = sessions.inspect(session_id, None, None, 256)?;
952    if output.is_error {
953        return Ok(None);
954    }
955    output
956        .structured
957        .as_ref()
958        .and_then(|root| root.pointer("/success/inspect/session"))
959        .cloned()
960        .map(Some)
961        .ok_or_else(|| ToolError::Execution("terminal host returned invalid session facts".into()))
962}
963
964fn observation_from_facts(facts: &Value) -> Result<SessionObservation, ToolError> {
965    let lifecycle = match facts.get("lifecycle").and_then(Value::as_str) {
966        Some("running") => ObservedLifecycle::Running,
967        Some("exited") => ObservedLifecycle::Exited,
968        Some("lost") => ObservedLifecycle::Lost,
969        Some("closed") => ObservedLifecycle::Closed,
970        _ => {
971            return Err(ToolError::Execution(
972                "terminal host returned an invalid lifecycle".into(),
973            ));
974        }
975    };
976    let cursor_offset = facts
977        .pointer("/output_cursor/offset")
978        .and_then(Value::as_u64)
979        .ok_or_else(|| ToolError::Execution("terminal host returned an invalid cursor".into()))?;
980    Ok(SessionObservation {
981        lifecycle,
982        cursor_offset,
983    })
984}
985
986fn project_monitor_facts(facts: &mut Value, active_count: usize) -> Result<(), ToolError> {
987    let monitor_allowed = facts
988        .get("lifecycle")
989        .and_then(Value::as_str)
990        .map(|lifecycle| lifecycle != "closed")
991        .ok_or_else(|| {
992            ToolError::Execution("terminal host returned an invalid lifecycle".into())
993        })?;
994    let object = facts.as_object_mut().ok_or_else(|| {
995        ToolError::Execution("terminal host returned invalid session facts".into())
996    })?;
997    object.insert(
998        "active_monitor_count".into(),
999        Value::from(u64::try_from(active_count).unwrap_or(u64::MAX)),
1000    );
1001    object
1002        .get_mut("next_actions")
1003        .and_then(Value::as_object_mut)
1004        .ok_or_else(|| ToolError::Execution("terminal host returned invalid next actions".into()))?
1005        .insert("monitor".into(), Value::Bool(monitor_allowed));
1006    Ok(())
1007}
1008
1009fn inspect_with_monitors(
1010    runtime: &HostRuntime,
1011    session_id: &str,
1012    after_event_id: Option<u64>,
1013    acknowledge_event_id: Option<u64>,
1014    max_events: usize,
1015) -> Result<ToolOutput, ToolError> {
1016    let output = runtime
1017        .sessions
1018        .inspect(session_id, None, None, max_events)?;
1019    if output.is_error {
1020        return Ok(output);
1021    }
1022    let mut payload = output
1023        .structured
1024        .as_ref()
1025        .and_then(|root| root.pointer("/success/inspect"))
1026        .cloned()
1027        .ok_or_else(|| {
1028            ToolError::Execution("terminal host returned an invalid inspect result".into())
1029        })?;
1030    let page = runtime
1031        .monitor_store
1032        .lock()
1033        .map_err(|_| monitor_store_poisoned())?
1034        .inspect(session_id, after_event_id, acknowledge_event_id, max_events)?;
1035    let active_count = page
1036        .monitors
1037        .iter()
1038        .filter(|summary| {
1039            !matches!(
1040                summary.state,
1041                crate::monitor::MonitorState::Paused | crate::monitor::MonitorState::Degraded
1042            )
1043        })
1044        .count();
1045    let object = payload.as_object_mut().ok_or_else(|| {
1046        ToolError::Execution("terminal host returned an invalid inspect result".into())
1047    })?;
1048    let facts = object.get_mut("session").ok_or_else(|| {
1049        ToolError::Execution("terminal host omitted inspect session facts".into())
1050    })?;
1051    project_monitor_facts(facts, active_count)?;
1052    object.insert(
1053        "monitors".into(),
1054        serde_json::to_value(&page.monitors)
1055            .map_err(|error| ToolError::Execution(format!("encode terminal monitors: {error}")))?,
1056    );
1057    object.insert(
1058        "events".into(),
1059        Value::Array(
1060            page.events
1061                .iter()
1062                .map(|event| {
1063                    json!({
1064                        "event_id": event.event_id,
1065                        "monitor_id": event.monitor_id,
1066                        "reason": event.reason,
1067                        "lifecycle": event.lifecycle,
1068                        "cursor": cursor(event.cursor_offset),
1069                        "created_at_ms": event.created_at_ms
1070                    })
1071                })
1072                .collect(),
1073        ),
1074    );
1075    object.insert("event_gap_through".into(), page.event_gap_through.into());
1076    object.insert("next_event_id".into(), page.next_event_id.into());
1077    Ok(success_output("inspect", payload))
1078}
1079
1080fn now_ms() -> Result<i64, ToolError> {
1081    let elapsed = SystemTime::now()
1082        .duration_since(UNIX_EPOCH)
1083        .map_err(|error| {
1084            ToolError::Execution(format!("terminal host clock is invalid: {error}"))
1085        })?;
1086    i64::try_from(elapsed.as_millis())
1087        .map_err(|_| ToolError::Execution("terminal host clock overflowed".into()))
1088}
1089
1090fn monitor_store_poisoned() -> ToolError {
1091    ToolError::Execution("terminal monitor store lock is poisoned".into())
1092}
1093
1094struct StartValidation<'a> {
1095    workspace_root: &'a Path,
1096    cwd: &'a Path,
1097    command: Option<&'a str>,
1098    shell: &'a Path,
1099    arguments: &'a [String],
1100    sandbox_profile: Option<&'a str>,
1101    rows: u16,
1102    columns: u16,
1103    return_when: Option<&'a ReturnCondition>,
1104    wait_ceiling_ms: Option<u64>,
1105}
1106
1107fn validate_start(spec: StartValidation<'_>) -> Result<(), ToolError> {
1108    let argument_bytes = spec
1109        .arguments
1110        .iter()
1111        .map(String::len)
1112        .try_fold(0_usize, usize::checked_add);
1113    if !spec.workspace_root.is_absolute()
1114        || !spec.workspace_root.is_dir()
1115        || !spec.cwd.is_absolute()
1116        || !spec.cwd.is_dir()
1117        || !spec.cwd.starts_with(spec.workspace_root)
1118        || !spec.shell.is_absolute()
1119        || !spec.shell.is_file()
1120        || !matches!(
1121            spec.shell.file_name().and_then(|name| name.to_str()),
1122            Some("bash" | "zsh")
1123        )
1124        || !valid_dimensions(spec.rows, spec.columns)
1125        || spec
1126            .command
1127            .is_some_and(|value| value.len() > 64 * 1024 || value.contains('\0'))
1128        || spec.arguments.len() > 64
1129        || spec.arguments.iter().any(|value| value.contains('\0'))
1130        || argument_bytes.is_none_or(|bytes| bytes > 64 * 1024)
1131        || spec
1132            .sandbox_profile
1133            .is_some_and(|profile| profile.len() > 64 * 1024 || profile.contains('\0'))
1134        || spec.wait_ceiling_ms == Some(0)
1135        || spec.return_when.is_some_and(|condition| {
1136            !matches!(condition, ReturnCondition::Started) && spec.wait_ceiling_ms.is_none()
1137        })
1138        || match spec.command {
1139            Some(command) => {
1140                spec.arguments.len() < 2
1141                    || spec.arguments[spec.arguments.len() - 2] != "-c"
1142                    || spec.arguments.last().is_none_or(|value| value != command)
1143            }
1144            None => spec.arguments.iter().any(|argument| argument == "-c"),
1145        }
1146    {
1147        return Err(ToolError::InvalidArguments(
1148            "terminal host start request is invalid".into(),
1149        ));
1150    }
1151    Ok(())
1152}
1153
1154fn map_backend(backend: TerminalBackend) -> SessionBackend {
1155    match backend {
1156        TerminalBackend::Native => SessionBackend::Native,
1157        TerminalBackend::Tmux => SessionBackend::Tmux,
1158    }
1159}
1160
1161fn map_return(condition: ReturnCondition) -> Result<WaitCondition, ToolError> {
1162    match condition {
1163        ReturnCondition::Started => Ok(WaitCondition::Started),
1164        ReturnCondition::Exit => Ok(WaitCondition::Exit),
1165        ReturnCondition::Quiet { duration_ms } if duration_ms > 0 => {
1166            Ok(WaitCondition::Quiet(Duration::from_millis(duration_ms)))
1167        }
1168        ReturnCondition::Match { pattern }
1169            if !pattern.is_empty() && pattern.len() <= 4_096 && !pattern.contains('\0') =>
1170        {
1171            Ok(WaitCondition::Match(pattern))
1172        }
1173        _ => Err(ToolError::InvalidArguments(
1174            "terminal host return condition is invalid".into(),
1175        )),
1176    }
1177}
1178
1179fn map_write(write: TerminalWrite) -> Result<WritePayload, ToolError> {
1180    match write {
1181        TerminalWrite::Text { text } | TerminalWrite::Paste { text }
1182            if text.is_empty() || text.len() > 64 * 1024 =>
1183        {
1184            Err(ToolError::InvalidArguments(
1185                "terminal host text write is invalid".into(),
1186            ))
1187        }
1188        TerminalWrite::Text { text } => Ok(WritePayload::Text(text)),
1189        TerminalWrite::Paste { text } => Ok(WritePayload::Paste(text)),
1190        TerminalWrite::Keys { keys } if keys.is_empty() || keys.len() > 4_096 => Err(
1191            ToolError::InvalidArguments("terminal host key write is invalid".into()),
1192        ),
1193        TerminalWrite::Keys { keys } => {
1194            Ok(WritePayload::Keys(keys.into_iter().map(map_key).collect()))
1195        }
1196        TerminalWrite::Controls { controls }
1197            if controls.is_empty()
1198                || controls.len() > 4_096
1199                || controls
1200                    .iter()
1201                    .any(|value| !matches!(value, b'?' | b'@'..=b'_' | b'a'..=b'z')) =>
1202        {
1203            Err(ToolError::InvalidArguments(
1204                "terminal host control write is invalid".into(),
1205            ))
1206        }
1207        TerminalWrite::Controls { controls } => Ok(WritePayload::Controls(controls)),
1208    }
1209}
1210
1211fn map_key(key: TerminalKey) -> NamedKey {
1212    match key {
1213        TerminalKey::Enter => NamedKey::Enter,
1214        TerminalKey::Tab => NamedKey::Tab,
1215        TerminalKey::Escape => NamedKey::Escape,
1216        TerminalKey::Backspace => NamedKey::Backspace,
1217        TerminalKey::Delete => NamedKey::Delete,
1218        TerminalKey::Insert => NamedKey::Insert,
1219        TerminalKey::ArrowUp => NamedKey::ArrowUp,
1220        TerminalKey::ArrowDown => NamedKey::ArrowDown,
1221        TerminalKey::ArrowLeft => NamedKey::ArrowLeft,
1222        TerminalKey::ArrowRight => NamedKey::ArrowRight,
1223        TerminalKey::Home => NamedKey::Home,
1224        TerminalKey::End => NamedKey::End,
1225        TerminalKey::PageUp => NamedKey::PageUp,
1226        TerminalKey::PageDown => NamedKey::PageDown,
1227    }
1228}
1229
1230fn map_signal(signal: WireSignal) -> TerminalSignal {
1231    match signal {
1232        WireSignal::Hangup => TerminalSignal::Hangup,
1233        WireSignal::Interrupt => TerminalSignal::Interrupt,
1234        WireSignal::Quit => TerminalSignal::Quit,
1235        WireSignal::Terminate => TerminalSignal::Terminate,
1236        WireSignal::Kill => TerminalSignal::Kill,
1237    }
1238}
1239
1240fn configure_stream(stream: &UnixStream) -> Result<(), HostServerError> {
1241    configure_stream_with_timeout(stream, IO_TIMEOUT)
1242}
1243
1244fn configure_stream_with_timeout(
1245    stream: &UnixStream,
1246    timeout: Duration,
1247) -> Result<(), HostServerError> {
1248    stream
1249        .set_read_timeout(Some(timeout))
1250        .and_then(|()| stream.set_write_timeout(Some(timeout)))
1251        .map_err(HostServerError::Io)
1252}
1253
1254fn prepare_private_directory(path: &Path) -> Result<(), HostServerError> {
1255    if !path.is_absolute() {
1256        return Err(HostServerError::UnsafeStateDirectory);
1257    }
1258    fs::create_dir_all(path).map_err(HostServerError::Io)?;
1259    let metadata = fs::symlink_metadata(path).map_err(HostServerError::Io)?;
1260    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1261        return Err(HostServerError::UnsafeStateDirectory);
1262    }
1263    fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(HostServerError::Io)
1264}
1265
1266fn open_private_file(path: &Path) -> Result<File, HostServerError> {
1267    reject_symlink(path)?;
1268    let file = OpenOptions::new()
1269        .create(true)
1270        .truncate(false)
1271        .read(true)
1272        .write(true)
1273        .mode(0o600)
1274        .custom_flags(nix::libc::O_NOFOLLOW)
1275        .open(path)
1276        .map_err(HostServerError::Io)?;
1277    file.set_permissions(fs::Permissions::from_mode(0o600))
1278        .map_err(HostServerError::Io)?;
1279    Ok(file)
1280}
1281
1282fn reject_symlink(path: &Path) -> Result<(), HostServerError> {
1283    match fs::symlink_metadata(path) {
1284        Ok(metadata) if metadata.file_type().is_symlink() => {
1285            Err(HostServerError::UnsafeStateDirectory)
1286        }
1287        Ok(_) => Ok(()),
1288        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1289        Err(error) => Err(HostServerError::Io(error)),
1290    }
1291}
1292
1293fn valid_request_id(value: &str) -> bool {
1294    value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
1295}
1296
1297struct EndpointGuard {
1298    endpoint: PathBuf,
1299    transport_directory: Option<PathBuf>,
1300}
1301
1302impl Drop for EndpointGuard {
1303    fn drop(&mut self) {
1304        let _ = fs::remove_file(&self.endpoint);
1305        if let Some(directory) = &self.transport_directory {
1306            let _ = fs::remove_dir(directory);
1307        }
1308    }
1309}
1310
1311const fn endpoint_path_limit() -> usize {
1312    if cfg!(target_os = "macos") { 104 } else { 108 }
1313}
1314
1315fn runtime_base() -> PathBuf {
1316    if cfg!(target_os = "macos") {
1317        PathBuf::from("/private/tmp")
1318    } else {
1319        PathBuf::from("/tmp")
1320    }
1321}
1322
1323#[derive(Debug)]
1324pub enum HostServerError {
1325    Io(io::Error),
1326    Protocol(crate::terminal_host_protocol::ProtocolError),
1327    MissingHome,
1328    InvalidIdleGrace,
1329    UnsafeStateDirectory,
1330    InvalidExecutable,
1331    HandshakeFailed,
1332    StartupTimeout,
1333    StartupFailed(Option<i32>),
1334    RemoteFailure { code: String, message: String },
1335    InvalidRequestId,
1336    DuplicateRequestId,
1337    RequestMapPoisoned,
1338    RequestWorkerStopped,
1339    MonitorState(String),
1340}
1341
1342impl std::fmt::Display for HostServerError {
1343    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1344        match self {
1345            Self::Io(error) => write!(formatter, "terminal host I/O: {error}"),
1346            Self::Protocol(error) => write!(formatter, "terminal host protocol: {error}"),
1347            Self::MissingHome => formatter.write_str("HOME is required for the terminal host"),
1348            Self::InvalidIdleGrace => formatter.write_str("terminal host idle grace is invalid"),
1349            Self::UnsafeStateDirectory => {
1350                formatter.write_str("terminal host state directory is unsafe")
1351            }
1352            Self::InvalidExecutable => formatter.write_str("terminal host executable is invalid"),
1353            Self::HandshakeFailed => formatter.write_str("terminal host handshake failed"),
1354            Self::StartupTimeout => formatter.write_str("terminal host startup timed out"),
1355            Self::StartupFailed(code) => write!(
1356                formatter,
1357                "terminal host startup failed{}",
1358                code.map_or_else(String::new, |code| format!(" with exit code {code}"))
1359            ),
1360            Self::RemoteFailure { code, message } => {
1361                write!(formatter, "terminal host {code}: {message}")
1362            }
1363            Self::InvalidRequestId => formatter.write_str("terminal host request id is invalid"),
1364            Self::DuplicateRequestId => formatter.write_str("terminal host request id is active"),
1365            Self::RequestMapPoisoned => {
1366                formatter.write_str("terminal host request map is poisoned")
1367            }
1368            Self::RequestWorkerStopped => {
1369                formatter.write_str("terminal host request worker stopped")
1370            }
1371            Self::MonitorState(message) => {
1372                write!(formatter, "terminal host monitor state: {message}")
1373            }
1374        }
1375    }
1376}
1377
1378impl std::error::Error for HostServerError {}
1379
1380#[cfg(test)]
1381mod tests {
1382    use std::time::{SystemTime, UNIX_EPOCH};
1383
1384    use super::*;
1385
1386    #[test]
1387    fn server_negotiates_pings_enforces_single_instance_and_retires_idle() {
1388        let nonce = SystemTime::now()
1389            .duration_since(UNIX_EPOCH)
1390            .unwrap()
1391            .as_nanos();
1392        let root = PathBuf::from("/tmp").join(format!(
1393            "fx-terminal-host-test-{}-{nonce}",
1394            std::process::id()
1395        ));
1396        let config = HostServerConfig {
1397            state_directory: root.clone(),
1398            idle_grace: Duration::from_millis(150),
1399        };
1400        let endpoint = config.endpoint();
1401        let server_config = config.clone();
1402        let server = thread::spawn(move || run(server_config));
1403        let deadline = Instant::now() + Duration::from_secs(2);
1404        while !endpoint.exists() && Instant::now() < deadline {
1405            thread::sleep(Duration::from_millis(5));
1406        }
1407        assert!(endpoint.exists());
1408
1409        let hello = request(
1410            &endpoint,
1411            &HostRequest::Hello {
1412                hello: ProtocolHello::local(),
1413            },
1414        )
1415        .unwrap();
1416        let HostResponse::Hello {
1417            negotiated,
1418            instance_id,
1419            ..
1420        } = hello
1421        else {
1422            panic!("host did not negotiate")
1423        };
1424        assert_eq!(negotiated, crate::terminal_host_protocol::PROTOCOL_CURRENT);
1425        assert_eq!(instance_id.len(), 32);
1426        assert_eq!(
1427            request(&endpoint, &HostRequest::Ping).unwrap(),
1428            HostResponse::Pong
1429        );
1430
1431        // A second server sees the held advisory lock and exits without
1432        // unlinking or replacing the live endpoint.
1433        run(config).unwrap();
1434        assert!(endpoint.exists());
1435        server.join().unwrap().unwrap();
1436        assert!(!endpoint.exists());
1437        fs::remove_dir_all(root).unwrap();
1438    }
1439}