1use 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 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}