1use std::collections::BTreeMap;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21use anyhow::Result;
22use uuid::Uuid;
23
24use agentos_sidecar_client::wire::{self, EventPayload, StreamChannel};
25
26use crate::agent_os::{AcpTerminalEntry, AgentOs, ShellEntry};
27use crate::error::ClientError;
28use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
29
30const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
32
33const ACP_TERMINAL_LIMIT: usize = 1024;
35
36const DEFAULT_SHELL_COMMAND: &str = "sh";
39
40#[derive(Default)]
46pub struct OpenShellOptions {
47 pub command: Option<String>,
48 pub args: Vec<String>,
49 pub env: BTreeMap<String, String>,
50 pub cwd: Option<String>,
51 pub cols: Option<u16>,
52 pub rows: Option<u16>,
53}
54
55#[derive(Default)]
61pub struct ConnectTerminalOptions {
62 pub base: OpenShellOptions,
63 pub on_data: Option<OutputCallback>,
64 pub on_stderr: Option<OutputCallback>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ShellHandle {
70 pub shell_id: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ShellData {
75 pub shell_id: String,
76 pub data: Vec<u8>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ShellExit {
81 pub shell_id: String,
82 pub exit_code: i32,
83}
84
85fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
91 ClientError::Kernel {
92 code: rejected.code,
93 message: rejected.message,
94 }
95}
96
97fn stdin_chunk(data: StdinInput) -> Vec<u8> {
101 match data {
102 StdinInput::Text(text) => text.into_bytes(),
103 StdinInput::Bytes(bytes) => bytes,
104 }
105}
106
107fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
108 counter
109 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
110 (count < limit).then_some(count + 1)
111 })
112 .is_ok()
113}
114
115fn release_counter(counter: &AtomicUsize) {
116 let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
117 Some(count.saturating_sub(1))
118 });
119}
120
121struct AcpTerminalReservation<'a> {
122 agent: &'a AgentOs,
123 active: bool,
124}
125
126impl<'a> AcpTerminalReservation<'a> {
127 fn new(agent: &'a AgentOs) -> std::result::Result<Self, ClientError> {
128 if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
129 return Err(ClientError::Sidecar(format!(
130 "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
131 )));
132 }
133 Ok(Self {
134 agent,
135 active: true,
136 })
137 }
138
139 fn disarm(&mut self) {
140 self.active = false;
141 }
142}
143
144impl Drop for AcpTerminalReservation<'_> {
145 fn drop(&mut self) {
146 if self.active {
147 release_counter(&self.agent.inner().acp_terminal_count);
148 }
149 }
150}
151
152impl AgentOs {
153 fn vm_ownership(&self) -> wire::OwnershipScope {
155 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
156 connection_id: self.connection_id().to_string(),
157 session_id: self.wire_session_id().to_string(),
158 vm_id: self.vm_id().to_string(),
159 })
160 }
161
162 pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
163 if self.inner().acp_terminals.remove(process_id).is_some() {
164 release_counter(&self.inner().acp_terminal_count);
165 }
166 }
167
168 async fn start_acp_terminal(
169 &self,
170 execute: wire::ExecuteRequest,
171 ownership: wire::OwnershipScope,
172 pid_tx: tokio::sync::oneshot::Sender<std::result::Result<u32, ClientError>>,
173 process_id: &str,
174 ) -> Option<u32> {
175 {
176 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
177 if self.inner().disposed.load(Ordering::SeqCst) {
178 let error = ClientError::Sidecar(
179 "cannot connect terminal after VM shutdown has started".to_string(),
180 );
181 let _ = pid_tx.send(Err(error));
182 self.finish_acp_terminal(process_id);
183 return None;
184 }
185 }
186
187 let result = match self
188 .transport()
189 .request_wire(ownership, wire::RequestPayload::ExecuteRequest(execute))
190 .await
191 {
192 Ok(wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
193 pid,
194 ..
195 })) => pid.ok_or_else(|| {
196 ClientError::Sidecar("connect_terminal: sidecar did not return a pid".to_string())
197 }),
198 Ok(wire::ResponsePayload::RejectedResponse(rejected)) => {
199 Err(rejected_to_error(rejected))
200 }
201 Ok(other) => Err(ClientError::Sidecar(format!(
202 "unexpected response to connect_terminal: {other:?}"
203 ))),
204 Err(error) => Err(error.into()),
205 };
206
207 match result {
208 Ok(pid) => {
209 let _ = pid_tx.send(Ok(pid));
210 Some(pid)
211 }
212 Err(error) => {
213 let _ = pid_tx.send(Err(error));
214 self.finish_acp_terminal(process_id);
215 None
216 }
217 }
218 }
219}
220
221impl AgentOs {
230 pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
241 let inner = self.inner();
242 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
243 let shell_id = format!("shell-{counter}");
244 let process_id = format!("shell-{}", Uuid::new_v4());
246
247 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
248 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
249 let (spawned_tx, _) = tokio::sync::watch::channel(false);
251 let (exit_tx, _) = tokio::sync::watch::channel(None::<i32>);
253
254 let entry = ShellEntry {
257 pid: 0,
258 data_tx: data_tx.clone(),
259 stderr_tx: stderr_tx.clone(),
260 process_id: process_id.clone(),
261 spawned_tx: spawned_tx.clone(),
262 exit_tx: exit_tx.clone(),
263 };
264 let _ = inner.shells.insert(shell_id.clone(), entry);
266
267 let command = options
268 .command
269 .clone()
270 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
271 options
272 .env
273 .insert(String::from("AGENTOS_EXEC_TTY"), String::from("1"));
274 if let Some(cols) = options.cols {
276 options
277 .env
278 .insert(String::from("COLUMNS"), cols.to_string());
279 }
280 if let Some(rows) = options.rows {
281 options.env.insert(String::from("LINES"), rows.to_string());
282 }
283 let execute = wire::ExecuteRequest {
284 process_id: process_id.clone(),
285 command: Some(command),
286 runtime: None,
287 entrypoint: None,
288 args: options.args.clone(),
289 env: options.env.clone().into_iter().collect(),
290 cwd: options.cwd.clone(),
291 wasm_permission_tier: None,
292 };
293
294 let agent = self.clone();
298 let ownership = self.vm_ownership();
299 let route_process_id = process_id.clone();
300 let exit_shell_id = shell_id.clone();
301 let exit_key = counter;
302 let handle = tokio::spawn(async move {
303 let mut events = agent.transport().subscribe_wire_events();
304
305 let response = match agent
306 .transport()
307 .request_wire(
308 ownership.clone(),
309 wire::RequestPayload::ExecuteRequest(execute),
310 )
311 .await
312 {
313 Ok(response) => response,
314 Err(error) => {
315 tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed");
316 agent.inner().shells.remove(&exit_shell_id);
318 agent.inner().pending_shell_exits.remove(&exit_key);
319 return;
320 }
321 };
322
323 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
326 pid: Some(pid),
327 ..
328 }) = response
329 {
330 agent
331 .inner()
332 .shells
333 .update(&exit_shell_id, |_, existing| existing.pid = pid);
334 }
335 let _ = spawned_tx.send_replace(true);
341
342 loop {
343 let (_scope, payload) = match events.recv().await {
344 Ok(value) => value,
345 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
346 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
347 };
348 match payload {
349 EventPayload::ProcessOutputEvent(output) => {
350 if output.process_id != route_process_id {
351 continue;
352 }
353 let _ = data_tx.send(output.chunk.clone());
356 if output.channel == StreamChannel::Stderr {
357 let _ = stderr_tx.send(output.chunk);
359 }
360 }
361 EventPayload::ProcessExitedEvent(exited) => {
362 if exited.process_id == route_process_id {
363 {
367 let mut retained = agent.inner().closed_shell_exit_codes.lock();
368 retained.push_back((exit_shell_id.clone(), exited.exit_code));
369 while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT
370 {
371 retained.pop_front();
372 }
373 }
374 let _ = exit_tx.send(Some(exited.exit_code));
375 break;
376 }
377 }
378 EventPayload::VmLifecycleEvent(_)
379 | EventPayload::ExecutionOutputEvent(_)
380 | EventPayload::ExecutionCompletedEvent(_)
381 | EventPayload::StructuredEvent(_)
382 | EventPayload::ExtEnvelope(_) => {}
383 }
384 }
385
386 agent.inner().pending_shell_exits.remove(&exit_key);
389 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
390 existing.process_id == route_process_id
391 });
392 });
394
395 let _ = inner.pending_shell_exits.insert(counter, handle);
396
397 Ok(ShellHandle { shell_id })
398 }
399
400 pub(crate) fn acp_open_terminal(
407 &self,
408 options: OpenShellOptions,
409 exit_tx: tokio::sync::watch::Sender<Option<i32>>,
410 on_output: impl Fn(&[u8]) + Send + Sync + 'static,
411 ) -> Result<ShellHandle> {
412 let inner = self.inner();
413 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
414 let shell_id = format!("shell-{counter}");
415 let process_id = format!("shell-{}", Uuid::new_v4());
416
417 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
418 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
419 let (spawned_tx, _) = tokio::sync::watch::channel(false);
420
421 let entry = ShellEntry {
422 pid: 0,
423 data_tx: data_tx.clone(),
424 stderr_tx: stderr_tx.clone(),
425 process_id: process_id.clone(),
426 spawned_tx: spawned_tx.clone(),
427 exit_tx: exit_tx.clone(),
429 };
430 let _ = inner.shells.insert(shell_id.clone(), entry);
431
432 let command = options
433 .command
434 .clone()
435 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
436 let execute = wire::ExecuteRequest {
437 process_id: process_id.clone(),
438 command: Some(command),
439 runtime: None,
440 entrypoint: None,
441 args: options.args.clone(),
442 env: options.env.clone().into_iter().collect(),
443 cwd: options.cwd.clone(),
444 wasm_permission_tier: None,
445 };
446
447 let agent = self.clone();
448 let ownership = self.vm_ownership();
449 let route_process_id = process_id.clone();
450 let exit_shell_id = shell_id.clone();
451 let exit_key = counter;
452 let on_output = std::sync::Arc::new(on_output);
453 let handle = tokio::spawn(async move {
454 let mut events = agent.transport().subscribe_wire_events();
455
456 let response = match agent
457 .transport()
458 .request_wire(
459 ownership.clone(),
460 wire::RequestPayload::ExecuteRequest(execute),
461 )
462 .await
463 {
464 Ok(response) => response,
465 Err(error) => {
466 tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed");
467 agent.inner().shells.remove(&exit_shell_id);
468 agent.inner().pending_shell_exits.remove(&exit_key);
469 let _ = exit_tx.send(Some(1));
470 return;
471 }
472 };
473
474 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
475 pid: Some(pid),
476 ..
477 }) = response
478 {
479 agent
480 .inner()
481 .shells
482 .update(&exit_shell_id, |_, existing| existing.pid = pid);
483 }
484 let _ = spawned_tx.send_replace(true);
490
491 let mut exit_code: i32 = 0;
492 loop {
493 let (_scope, payload) = match events.recv().await {
494 Ok(value) => value,
495 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
496 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
497 };
498 match payload {
499 EventPayload::ProcessOutputEvent(output) => {
500 if output.process_id != route_process_id {
501 continue;
502 }
503 let _ = data_tx.send(output.chunk.clone());
504 if output.channel == StreamChannel::Stderr {
505 let _ = stderr_tx.send(output.chunk.clone());
506 }
507 on_output(&output.chunk);
509 }
510 EventPayload::ProcessExitedEvent(exited) => {
511 if exited.process_id == route_process_id {
512 exit_code = exited.exit_code;
513 break;
514 }
515 }
516 EventPayload::VmLifecycleEvent(_)
517 | EventPayload::ExecutionOutputEvent(_)
518 | EventPayload::ExecutionCompletedEvent(_)
519 | EventPayload::StructuredEvent(_)
520 | EventPayload::ExtEnvelope(_) => {}
521 }
522 }
523
524 agent.inner().pending_shell_exits.remove(&exit_key);
525 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
526 existing.process_id == route_process_id
527 });
528 let _ = exit_tx.send(Some(exit_code));
529 });
530
531 let _ = inner.pending_shell_exits.insert(counter, handle);
535 Ok(ShellHandle { shell_id })
536 }
537
538 pub(crate) fn acp_kill_terminal_shell(
542 &self,
543 shell_id: &str,
544 ) -> std::result::Result<(), ClientError> {
545 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
546 let agent = self.clone();
547 let ownership = self.vm_ownership();
548 tokio::spawn(async move {
549 wait_for_spawn(spawned_rx).await;
550 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
551 process_id,
552 signal: String::from("SIGTERM"),
553 });
554 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
555 tracing::warn!(?error, "acp_kill_terminal_shell failed");
556 }
557 });
558 Ok(())
559 }
560
561 pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
570 let ConnectTerminalOptions {
571 base,
572 on_data,
573 on_stderr,
574 } = options;
575
576 let process_id = format!("terminal-{}", Uuid::new_v4());
577 let command = base
578 .command
579 .clone()
580 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
581 let (data_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
582 let (stderr_tx, _) =
583 tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
584
585 if let Some(cb) = on_data {
588 install_output_callback(data_tx.clone(), cb);
589 }
590 if let Some(cb) = on_stderr {
591 install_output_callback(stderr_tx.clone(), cb);
592 }
593
594 let execute = wire::ExecuteRequest {
595 process_id: process_id.clone(),
596 command: Some(command),
597 runtime: None,
598 entrypoint: None,
599 args: base.args.clone(),
600 env: base.env.clone().into_iter().collect(),
601 cwd: base.cwd.clone(),
602 wasm_permission_tier: None,
603 };
604
605 let events = self.transport().subscribe_wire_events();
607 let ownership = self.vm_ownership();
608 let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
609 let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
610 let agent = self.clone();
611 let route_process_id = process_id.clone();
612 let exit_task = tokio::spawn(async move {
613 if start_rx.await.is_err() {
614 return;
615 }
616 let terminal_pid = match agent
617 .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
618 .await
619 {
620 Some(pid) => pid,
621 None => return,
622 };
623 let mut events = events;
624 loop {
625 let (_scope, payload) = match events.recv().await {
626 Ok(value) => value,
627 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
628 if terminal_process_finished(&agent, terminal_pid).await {
629 break;
630 }
631 continue;
632 }
633 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
634 };
635 match payload {
636 EventPayload::ProcessOutputEvent(output) => {
637 if output.process_id != route_process_id {
638 continue;
639 }
640 let _ = data_tx.send(output.chunk.clone());
641 if output.channel == StreamChannel::Stderr {
642 let _ = stderr_tx.send(output.chunk);
643 }
644 }
645 EventPayload::ProcessExitedEvent(exited) => {
646 if exited.process_id == route_process_id {
647 break;
648 }
649 }
650 EventPayload::VmLifecycleEvent(_)
651 | EventPayload::ExecutionOutputEvent(_)
652 | EventPayload::ExecutionCompletedEvent(_)
653 | EventPayload::StructuredEvent(_)
654 | EventPayload::ExtEnvelope(_) => {}
655 }
656 }
657 agent.finish_acp_terminal(&route_process_id);
658 });
659
660 {
661 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
662 if self.inner().disposed.load(Ordering::SeqCst) {
663 exit_task.abort();
664 return Err(ClientError::Sidecar(
665 "cannot connect terminal after VM shutdown has started".to_string(),
666 )
667 .into());
668 }
669 let mut terminal_reservation = AcpTerminalReservation::new(self)?;
670 match self
671 .inner()
672 .acp_terminals
673 .insert(process_id.clone(), AcpTerminalEntry { exit_task })
674 {
675 Ok(()) => {}
676 Err((_, entry)) => {
677 entry.exit_task.abort();
678 return Err(ClientError::Sidecar(format!(
679 "terminal process id collision while tracking ACP terminal: {process_id}"
680 ))
681 .into());
682 }
683 }
684 terminal_reservation.disarm();
685 if start_tx.send(()).is_err() {
686 self.finish_acp_terminal(&process_id);
687 return Err(ClientError::Sidecar(
688 "terminal startup task ended before registration completed".to_string(),
689 )
690 .into());
691 }
692 }
693
694 pid_rx
695 .await
696 .map_err(|_| {
697 ClientError::Sidecar(
698 "terminal startup task ended before returning a pid".to_string(),
699 )
700 })?
701 .map_err(Into::into)
702 }
703
704 pub fn write_shell(
706 &self,
707 shell_id: &str,
708 data: StdinInput,
709 ) -> std::result::Result<(), ClientError> {
710 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
711 let chunk = stdin_chunk(data);
712
713 let agent = self.clone();
718 let ownership = self.vm_ownership();
719 tokio::spawn(async move {
720 wait_for_spawn(spawned_rx).await;
721 let payload = wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
722 process_id,
723 chunk,
724 });
725 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
726 tracing::warn!(?error, "write_shell failed");
727 }
728 });
729
730 Ok(())
731 }
732
733 pub async fn write_shell_awaited(
737 &self,
738 shell_id: &str,
739 data: StdinInput,
740 ) -> std::result::Result<(), ClientError> {
741 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
742 let chunk = stdin_chunk(data);
743 tracing::debug!(shell_id, "write_shell_awaited: waiting for spawn gate");
744 wait_for_spawn(spawned_rx).await;
745 tracing::debug!(shell_id, "write_shell_awaited: issuing wire write");
746 let payload =
747 wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest { process_id, chunk });
748 let response = self
749 .transport()
750 .request_wire(self.vm_ownership(), payload)
751 .await?;
752 tracing::debug!(shell_id, "write_shell_awaited: wire write acked");
753 match response {
754 wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
755 _ => Ok(()),
756 }
757 }
758
759 pub fn on_shell_data(
764 &self,
765 shell_id: &str,
766 mut handler: impl FnMut(ShellData) + Send + 'static,
767 ) -> std::result::Result<crate::stream::Subscription, ClientError> {
768 let mut rx = self
769 .inner()
770 .shells
771 .read(shell_id, |_, entry| entry.data_tx.subscribe())
772 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
773 let shell_id = shell_id.to_string();
774 let task = tokio::spawn(async move {
775 loop {
776 match rx.recv().await {
777 Ok(data) => handler(ShellData {
778 shell_id: shell_id.clone(),
779 data,
780 }),
781 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
782 Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
783 }
784 }
785 });
786 Ok(crate::stream::Subscription::new(move || task.abort()))
787 }
788
789 pub fn on_shell_stderr(
794 &self,
795 shell_id: &str,
796 mut handler: impl FnMut(ShellData) + Send + 'static,
797 ) -> std::result::Result<crate::stream::Subscription, ClientError> {
798 let mut rx = self
799 .inner()
800 .shells
801 .read(shell_id, |_, entry| entry.stderr_tx.subscribe())
802 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
803 let shell_id = shell_id.to_string();
804 let task = tokio::spawn(async move {
805 loop {
806 match rx.recv().await {
807 Ok(data) => handler(ShellData {
808 shell_id: shell_id.clone(),
809 data,
810 }),
811 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
812 Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
813 }
814 }
815 });
816 Ok(crate::stream::Subscription::new(move || task.abort()))
817 }
818
819 pub fn on_shell_exit(
820 &self,
821 shell_id: &str,
822 handler: impl FnOnce(ShellExit) + Send + 'static,
823 ) -> std::result::Result<crate::stream::Subscription, ClientError> {
824 let mut rx = self
825 .inner()
826 .shells
827 .read(shell_id, |_, entry| entry.exit_tx.subscribe())
828 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))?;
829 if let Some(exit_code) = *rx.borrow() {
830 handler(ShellExit {
831 shell_id: shell_id.to_string(),
832 exit_code,
833 });
834 return Ok(crate::stream::Subscription::noop());
835 }
836 let shell_id = shell_id.to_string();
837 let task = tokio::spawn(async move {
838 while rx.changed().await.is_ok() {
839 if let Some(exit_code) = *rx.borrow() {
840 handler(ShellExit {
841 shell_id,
842 exit_code,
843 });
844 return;
845 }
846 }
847 });
848 Ok(crate::stream::Subscription::new(move || task.abort()))
849 }
850
851 pub fn resize_shell(
855 &self,
856 shell_id: &str,
857 cols: u16,
858 rows: u16,
859 ) -> std::result::Result<(), ClientError> {
860 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
862
863 let agent = self.clone();
864 let ownership = self.vm_ownership();
865 tokio::spawn(async move {
866 wait_for_spawn(spawned_rx).await;
867 let payload = wire::RequestPayload::ResizePtyRequest(wire::ResizePtyRequest {
868 process_id,
869 cols,
870 rows,
871 });
872 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
873 tracing::warn!(?error, "resize_shell failed");
874 }
875 });
876
877 Ok(())
878 }
879
880 pub async fn wait_shell(&self, shell_id: &str) -> std::result::Result<i32, ClientError> {
884 let exit_rx = self
885 .inner()
886 .shells
887 .read(shell_id, |_, entry| entry.exit_tx.subscribe());
888 let Some(mut exit_rx) = exit_rx else {
889 let retained = self.inner().closed_shell_exit_codes.lock();
891 return retained
892 .iter()
893 .rev()
894 .find(|(id, _)| id == shell_id)
895 .map(|(_, code)| *code)
896 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
897 };
898 loop {
899 if let Some(code) = *exit_rx.borrow_and_update() {
900 return Ok(code);
901 }
902 if exit_rx.changed().await.is_err() {
903 let retained = self.inner().closed_shell_exit_codes.lock();
906 return retained
907 .iter()
908 .rev()
909 .find(|(id, _)| id == shell_id)
910 .map(|(_, code)| *code)
911 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
912 }
913 }
914 }
915
916 pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> {
919 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
920
921 self.inner().shells.remove(shell_id);
924
925 let agent = self.clone();
927 let ownership = self.vm_ownership();
928 tokio::spawn(async move {
929 wait_for_spawn(spawned_rx).await;
930 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
931 process_id,
932 signal: String::from("SIGTERM"),
933 });
934 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
935 tracing::warn!(?error, "close_shell kill failed");
936 }
937 });
938
939 Ok(())
940 }
941
942 fn shell_wire_handle(
945 &self,
946 shell_id: &str,
947 ) -> std::result::Result<(String, tokio::sync::watch::Receiver<bool>), ClientError> {
948 self.inner()
949 .shells
950 .read(shell_id, |_, entry| {
951 (entry.process_id.clone(), entry.spawned_tx.subscribe())
952 })
953 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
954 }
955}
956
957async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver<bool>) {
960 if *spawned_rx.borrow() {
961 return;
962 }
963 while spawned_rx.changed().await.is_ok() {
964 if *spawned_rx.borrow() {
965 return;
966 }
967 }
968}
969
970async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
971 match agent.all_processes().await {
972 Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
973 Some(process) => process.status != ProcessStatus::Running,
974 None => true,
975 },
976 Err(error) => {
977 tracing::warn!(?error, pid, "terminal process snapshot failed");
978 false
979 }
980 }
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986
987 #[test]
988 fn reserve_counter_enforces_limit_and_release_reopens_slot() {
989 let counter = AtomicUsize::new(0);
990
991 assert!(try_reserve_counter(&counter, 2));
992 assert!(try_reserve_counter(&counter, 2));
993 assert!(!try_reserve_counter(&counter, 2));
994 release_counter(&counter);
995 assert!(try_reserve_counter(&counter, 2));
996 }
997}