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};
29use crate::stream::ByteStream;
30
31const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
33
34const ACP_TERMINAL_LIMIT: usize = 1024;
36
37const DEFAULT_SHELL_COMMAND: &str = "sh";
40
41#[derive(Default)]
51pub struct OpenShellOptions {
52 pub command: Option<String>,
53 pub args: Vec<String>,
54 pub env: BTreeMap<String, String>,
55 pub cwd: Option<String>,
56 pub cols: Option<u16>,
57 pub rows: Option<u16>,
58 pub on_stderr: Option<OutputCallback>,
59}
60
61#[derive(Default)]
67pub struct ConnectTerminalOptions {
68 pub base: OpenShellOptions,
69 pub on_data: Option<OutputCallback>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ShellHandle {
75 pub shell_id: String,
76}
77
78fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
84 ClientError::Kernel {
85 code: rejected.code,
86 message: rejected.message,
87 }
88}
89
90fn stdin_chunk(data: StdinInput) -> Vec<u8> {
94 match data {
95 StdinInput::Text(text) => text.into_bytes(),
96 StdinInput::Bytes(bytes) => bytes,
97 }
98}
99
100fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
101 counter
102 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
103 (count < limit).then_some(count + 1)
104 })
105 .is_ok()
106}
107
108fn release_counter(counter: &AtomicUsize) {
109 let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
110 Some(count.saturating_sub(1))
111 });
112}
113
114struct AcpTerminalReservation<'a> {
115 agent: &'a AgentOs,
116 active: bool,
117}
118
119impl<'a> AcpTerminalReservation<'a> {
120 fn new(agent: &'a AgentOs) -> std::result::Result<Self, ClientError> {
121 if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
122 return Err(ClientError::Sidecar(format!(
123 "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
124 )));
125 }
126 Ok(Self {
127 agent,
128 active: true,
129 })
130 }
131
132 fn disarm(&mut self) {
133 self.active = false;
134 }
135}
136
137impl Drop for AcpTerminalReservation<'_> {
138 fn drop(&mut self) {
139 if self.active {
140 release_counter(&self.agent.inner().acp_terminal_count);
141 }
142 }
143}
144
145impl AgentOs {
146 fn vm_ownership(&self) -> wire::OwnershipScope {
148 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
149 connection_id: self.connection_id().to_string(),
150 session_id: self.wire_session_id().to_string(),
151 vm_id: self.vm_id().to_string(),
152 })
153 }
154
155 pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
156 if self.inner().acp_terminals.remove(process_id).is_some() {
157 release_counter(&self.inner().acp_terminal_count);
158 }
159 }
160
161 async fn start_acp_terminal(
162 &self,
163 execute: wire::ExecuteRequest,
164 ownership: wire::OwnershipScope,
165 pid_tx: tokio::sync::oneshot::Sender<std::result::Result<u32, ClientError>>,
166 process_id: &str,
167 ) -> Option<u32> {
168 {
169 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
170 if self.inner().disposed.load(Ordering::SeqCst) {
171 let error = ClientError::Sidecar(
172 "cannot connect terminal after VM shutdown has started".to_string(),
173 );
174 let _ = pid_tx.send(Err(error));
175 self.finish_acp_terminal(process_id);
176 return None;
177 }
178 }
179
180 let result = match self
181 .transport()
182 .request_wire(ownership, wire::RequestPayload::ExecuteRequest(execute))
183 .await
184 {
185 Ok(wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
186 pid,
187 ..
188 })) => pid.ok_or_else(|| {
189 ClientError::Sidecar("connect_terminal: sidecar did not return a pid".to_string())
190 }),
191 Ok(wire::ResponsePayload::RejectedResponse(rejected)) => {
192 Err(rejected_to_error(rejected))
193 }
194 Ok(other) => Err(ClientError::Sidecar(format!(
195 "unexpected response to connect_terminal: {other:?}"
196 ))),
197 Err(error) => Err(error.into()),
198 };
199
200 match result {
201 Ok(pid) => {
202 let _ = pid_tx.send(Ok(pid));
203 Some(pid)
204 }
205 Err(error) => {
206 let _ = pid_tx.send(Err(error));
207 self.finish_acp_terminal(process_id);
208 None
209 }
210 }
211 }
212}
213
214impl AgentOs {
223 pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
234 let inner = self.inner();
235 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
236 let shell_id = format!("shell-{counter}");
237 let process_id = format!("shell-{}", Uuid::new_v4());
239
240 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
241 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
242 let (spawned_tx, _) = tokio::sync::watch::channel(false);
244 let (exit_tx, _) = tokio::sync::watch::channel(None::<i32>);
246
247 if let Some(cb) = options.on_stderr.take() {
250 install_output_callback(stderr_tx.clone(), cb);
251 }
252
253 let entry = ShellEntry {
256 pid: 0,
257 data_tx: data_tx.clone(),
258 stderr_tx: stderr_tx.clone(),
259 process_id: process_id.clone(),
260 spawned_tx: spawned_tx.clone(),
261 exit_tx: exit_tx.clone(),
262 };
263 let _ = inner.shells.insert(shell_id.clone(), entry);
265
266 let command = options
267 .command
268 .clone()
269 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
270 options
271 .env
272 .insert(String::from("AGENTOS_EXEC_TTY"), String::from("1"));
273 if let Some(cols) = options.cols {
275 options
276 .env
277 .insert(String::from("COLUMNS"), cols.to_string());
278 }
279 if let Some(rows) = options.rows {
280 options.env.insert(String::from("LINES"), rows.to_string());
281 }
282 let execute = wire::ExecuteRequest {
283 process_id: process_id.clone(),
284 command: Some(command),
285 runtime: None,
286 entrypoint: None,
287 args: options.args.clone(),
288 env: options.env.clone().into_iter().collect(),
289 cwd: options.cwd.clone(),
290 wasm_permission_tier: None,
291 };
292
293 let agent = self.clone();
297 let ownership = self.vm_ownership();
298 let route_process_id = process_id.clone();
299 let exit_shell_id = shell_id.clone();
300 let exit_key = counter;
301 let handle = tokio::spawn(async move {
302 let mut events = agent.transport().subscribe_wire_events();
303
304 let response = match agent
305 .transport()
306 .request_wire(
307 ownership.clone(),
308 wire::RequestPayload::ExecuteRequest(execute),
309 )
310 .await
311 {
312 Ok(response) => response,
313 Err(error) => {
314 tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed");
315 agent.inner().shells.remove(&exit_shell_id);
317 agent.inner().pending_shell_exits.remove(&exit_key);
318 return;
319 }
320 };
321
322 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
325 pid: Some(pid),
326 ..
327 }) = response
328 {
329 agent
330 .inner()
331 .shells
332 .update(&exit_shell_id, |_, existing| existing.pid = pid);
333 }
334 let _ = spawned_tx.send_replace(true);
340
341 loop {
342 let (_scope, payload) = match events.recv().await {
343 Ok(value) => value,
344 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
345 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
346 };
347 match payload {
348 EventPayload::ProcessOutputEvent(output) => {
349 if output.process_id != route_process_id {
350 continue;
351 }
352 let _ = data_tx.send(output.chunk.clone());
355 if output.channel == StreamChannel::Stderr {
356 let _ = stderr_tx.send(output.chunk);
358 }
359 }
360 EventPayload::ProcessExitedEvent(exited) => {
361 if exited.process_id == route_process_id {
362 {
366 let mut retained = agent.inner().closed_shell_exit_codes.lock();
367 retained.push_back((exit_shell_id.clone(), exited.exit_code));
368 while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT
369 {
370 retained.pop_front();
371 }
372 }
373 let _ = exit_tx.send(Some(exited.exit_code));
374 break;
375 }
376 }
377 EventPayload::VmLifecycleEvent(_)
378 | EventPayload::StructuredEvent(_)
379 | EventPayload::ExtEnvelope(_) => {}
380 }
381 }
382
383 agent.inner().pending_shell_exits.remove(&exit_key);
386 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
387 existing.process_id == route_process_id
388 });
389 });
391
392 let _ = inner.pending_shell_exits.insert(counter, handle);
393
394 Ok(ShellHandle { shell_id })
395 }
396
397 pub(crate) fn acp_open_terminal(
404 &self,
405 options: OpenShellOptions,
406 exit_tx: tokio::sync::watch::Sender<Option<i32>>,
407 on_output: impl Fn(&[u8]) + Send + Sync + 'static,
408 ) -> Result<ShellHandle> {
409 let inner = self.inner();
410 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
411 let shell_id = format!("shell-{counter}");
412 let process_id = format!("shell-{}", Uuid::new_v4());
413
414 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
415 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
416 let (spawned_tx, _) = tokio::sync::watch::channel(false);
417
418 let entry = ShellEntry {
419 pid: 0,
420 data_tx: data_tx.clone(),
421 stderr_tx: stderr_tx.clone(),
422 process_id: process_id.clone(),
423 spawned_tx: spawned_tx.clone(),
424 exit_tx: exit_tx.clone(),
426 };
427 let _ = inner.shells.insert(shell_id.clone(), entry);
428
429 let command = options
430 .command
431 .clone()
432 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
433 let execute = wire::ExecuteRequest {
434 process_id: process_id.clone(),
435 command: Some(command),
436 runtime: None,
437 entrypoint: None,
438 args: options.args.clone(),
439 env: options.env.clone().into_iter().collect(),
440 cwd: options.cwd.clone(),
441 wasm_permission_tier: None,
442 };
443
444 let agent = self.clone();
445 let ownership = self.vm_ownership();
446 let route_process_id = process_id.clone();
447 let exit_shell_id = shell_id.clone();
448 let exit_key = counter;
449 let on_output = std::sync::Arc::new(on_output);
450 let handle = tokio::spawn(async move {
451 let mut events = agent.transport().subscribe_wire_events();
452
453 let response = match agent
454 .transport()
455 .request_wire(
456 ownership.clone(),
457 wire::RequestPayload::ExecuteRequest(execute),
458 )
459 .await
460 {
461 Ok(response) => response,
462 Err(error) => {
463 tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed");
464 agent.inner().shells.remove(&exit_shell_id);
465 agent.inner().pending_shell_exits.remove(&exit_key);
466 let _ = exit_tx.send(Some(1));
467 return;
468 }
469 };
470
471 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
472 pid: Some(pid),
473 ..
474 }) = response
475 {
476 agent
477 .inner()
478 .shells
479 .update(&exit_shell_id, |_, existing| existing.pid = pid);
480 }
481 let _ = spawned_tx.send_replace(true);
487
488 let mut exit_code: i32 = 0;
489 loop {
490 let (_scope, payload) = match events.recv().await {
491 Ok(value) => value,
492 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
493 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
494 };
495 match payload {
496 EventPayload::ProcessOutputEvent(output) => {
497 if output.process_id != route_process_id {
498 continue;
499 }
500 let _ = data_tx.send(output.chunk.clone());
501 if output.channel == StreamChannel::Stderr {
502 let _ = stderr_tx.send(output.chunk.clone());
503 }
504 on_output(&output.chunk);
506 }
507 EventPayload::ProcessExitedEvent(exited) => {
508 if exited.process_id == route_process_id {
509 exit_code = exited.exit_code;
510 break;
511 }
512 }
513 EventPayload::VmLifecycleEvent(_)
514 | EventPayload::StructuredEvent(_)
515 | EventPayload::ExtEnvelope(_) => {}
516 }
517 }
518
519 agent.inner().pending_shell_exits.remove(&exit_key);
520 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
521 existing.process_id == route_process_id
522 });
523 let _ = exit_tx.send(Some(exit_code));
524 });
525
526 let _ = inner.pending_shell_exits.insert(counter, handle);
530 Ok(ShellHandle { shell_id })
531 }
532
533 pub(crate) fn acp_kill_terminal_shell(
537 &self,
538 shell_id: &str,
539 ) -> std::result::Result<(), ClientError> {
540 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
541 let agent = self.clone();
542 let ownership = self.vm_ownership();
543 tokio::spawn(async move {
544 wait_for_spawn(spawned_rx).await;
545 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
546 process_id,
547 signal: String::from("SIGTERM"),
548 });
549 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
550 tracing::warn!(?error, "acp_kill_terminal_shell failed");
551 }
552 });
553 Ok(())
554 }
555
556 pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
565 let ConnectTerminalOptions { base, on_data } = options;
566
567 let process_id = format!("terminal-{}", Uuid::new_v4());
568 let command = base
569 .command
570 .clone()
571 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
572 let (data_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
573 let (stderr_tx, _) =
574 tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
575
576 if let Some(cb) = on_data {
579 install_output_callback(data_tx.clone(), cb);
580 }
581 if let Some(cb) = base.on_stderr {
582 install_output_callback(stderr_tx.clone(), cb);
583 }
584
585 let execute = wire::ExecuteRequest {
586 process_id: process_id.clone(),
587 command: Some(command),
588 runtime: None,
589 entrypoint: None,
590 args: base.args.clone(),
591 env: base.env.clone().into_iter().collect(),
592 cwd: base.cwd.clone(),
593 wasm_permission_tier: None,
594 };
595
596 let events = self.transport().subscribe_wire_events();
598 let ownership = self.vm_ownership();
599 let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
600 let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
601 let agent = self.clone();
602 let route_process_id = process_id.clone();
603 let exit_task = tokio::spawn(async move {
604 if start_rx.await.is_err() {
605 return;
606 }
607 let terminal_pid = match agent
608 .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
609 .await
610 {
611 Some(pid) => pid,
612 None => return,
613 };
614 let mut events = events;
615 loop {
616 let (_scope, payload) = match events.recv().await {
617 Ok(value) => value,
618 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
619 if terminal_process_finished(&agent, terminal_pid).await {
620 break;
621 }
622 continue;
623 }
624 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
625 };
626 match payload {
627 EventPayload::ProcessOutputEvent(output) => {
628 if output.process_id != route_process_id {
629 continue;
630 }
631 let _ = data_tx.send(output.chunk.clone());
632 if output.channel == StreamChannel::Stderr {
633 let _ = stderr_tx.send(output.chunk);
634 }
635 }
636 EventPayload::ProcessExitedEvent(exited) => {
637 if exited.process_id == route_process_id {
638 break;
639 }
640 }
641 EventPayload::VmLifecycleEvent(_)
642 | EventPayload::StructuredEvent(_)
643 | EventPayload::ExtEnvelope(_) => {}
644 }
645 }
646 agent.finish_acp_terminal(&route_process_id);
647 });
648
649 {
650 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
651 if self.inner().disposed.load(Ordering::SeqCst) {
652 exit_task.abort();
653 return Err(ClientError::Sidecar(
654 "cannot connect terminal after VM shutdown has started".to_string(),
655 )
656 .into());
657 }
658 let mut terminal_reservation = AcpTerminalReservation::new(self)?;
659 match self
660 .inner()
661 .acp_terminals
662 .insert(process_id.clone(), AcpTerminalEntry { exit_task })
663 {
664 Ok(()) => {}
665 Err((_, entry)) => {
666 entry.exit_task.abort();
667 return Err(ClientError::Sidecar(format!(
668 "terminal process id collision while tracking ACP terminal: {process_id}"
669 ))
670 .into());
671 }
672 }
673 terminal_reservation.disarm();
674 if start_tx.send(()).is_err() {
675 self.finish_acp_terminal(&process_id);
676 return Err(ClientError::Sidecar(
677 "terminal startup task ended before registration completed".to_string(),
678 )
679 .into());
680 }
681 }
682
683 pid_rx
684 .await
685 .map_err(|_| {
686 ClientError::Sidecar(
687 "terminal startup task ended before returning a pid".to_string(),
688 )
689 })?
690 .map_err(Into::into)
691 }
692
693 pub fn write_shell(
695 &self,
696 shell_id: &str,
697 data: StdinInput,
698 ) -> std::result::Result<(), ClientError> {
699 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
700 let chunk = stdin_chunk(data);
701
702 let agent = self.clone();
707 let ownership = self.vm_ownership();
708 tokio::spawn(async move {
709 wait_for_spawn(spawned_rx).await;
710 let payload = wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
711 process_id,
712 chunk,
713 });
714 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
715 tracing::warn!(?error, "write_shell failed");
716 }
717 });
718
719 Ok(())
720 }
721
722 pub async fn write_shell_awaited(
726 &self,
727 shell_id: &str,
728 data: StdinInput,
729 ) -> std::result::Result<(), ClientError> {
730 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
731 let chunk = stdin_chunk(data);
732 tracing::debug!(shell_id, "write_shell_awaited: waiting for spawn gate");
733 wait_for_spawn(spawned_rx).await;
734 tracing::debug!(shell_id, "write_shell_awaited: issuing wire write");
735 let payload =
736 wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest { process_id, chunk });
737 let response = self
738 .transport()
739 .request_wire(self.vm_ownership(), payload)
740 .await?;
741 tracing::debug!(shell_id, "write_shell_awaited: wire write acked");
742 match response {
743 wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
744 _ => Ok(()),
745 }
746 }
747
748 pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
753 self.inner()
754 .shells
755 .read(shell_id, |_, entry| entry.data_tx.subscribe())
756 .map(ByteStream::new)
757 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
758 }
759
760 pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
765 self.inner()
766 .shells
767 .read(shell_id, |_, entry| entry.stderr_tx.subscribe())
768 .map(ByteStream::new)
769 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
770 }
771
772 pub fn resize_shell(
776 &self,
777 shell_id: &str,
778 cols: u16,
779 rows: u16,
780 ) -> std::result::Result<(), ClientError> {
781 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
783
784 let agent = self.clone();
785 let ownership = self.vm_ownership();
786 tokio::spawn(async move {
787 wait_for_spawn(spawned_rx).await;
788 let payload = wire::RequestPayload::ResizePtyRequest(wire::ResizePtyRequest {
789 process_id,
790 cols,
791 rows,
792 });
793 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
794 tracing::warn!(?error, "resize_shell failed");
795 }
796 });
797
798 Ok(())
799 }
800
801 pub async fn wait_shell(&self, shell_id: &str) -> std::result::Result<i32, ClientError> {
805 let exit_rx = self
806 .inner()
807 .shells
808 .read(shell_id, |_, entry| entry.exit_tx.subscribe());
809 let Some(mut exit_rx) = exit_rx else {
810 let retained = self.inner().closed_shell_exit_codes.lock();
812 return retained
813 .iter()
814 .rev()
815 .find(|(id, _)| id == shell_id)
816 .map(|(_, code)| *code)
817 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
818 };
819 loop {
820 if let Some(code) = *exit_rx.borrow_and_update() {
821 return Ok(code);
822 }
823 if exit_rx.changed().await.is_err() {
824 let retained = self.inner().closed_shell_exit_codes.lock();
827 return retained
828 .iter()
829 .rev()
830 .find(|(id, _)| id == shell_id)
831 .map(|(_, code)| *code)
832 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
833 }
834 }
835 }
836
837 pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> {
840 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
841
842 self.inner().shells.remove(shell_id);
845
846 let agent = self.clone();
848 let ownership = self.vm_ownership();
849 tokio::spawn(async move {
850 wait_for_spawn(spawned_rx).await;
851 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
852 process_id,
853 signal: String::from("SIGTERM"),
854 });
855 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
856 tracing::warn!(?error, "close_shell kill failed");
857 }
858 });
859
860 Ok(())
861 }
862
863 fn shell_wire_handle(
866 &self,
867 shell_id: &str,
868 ) -> std::result::Result<(String, tokio::sync::watch::Receiver<bool>), ClientError> {
869 self.inner()
870 .shells
871 .read(shell_id, |_, entry| {
872 (entry.process_id.clone(), entry.spawned_tx.subscribe())
873 })
874 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
875 }
876}
877
878async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver<bool>) {
881 if *spawned_rx.borrow() {
882 return;
883 }
884 while spawned_rx.changed().await.is_ok() {
885 if *spawned_rx.borrow() {
886 return;
887 }
888 }
889}
890
891async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
892 match agent.all_processes().await {
893 Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
894 Some(process) => process.status != ProcessStatus::Running,
895 None => true,
896 },
897 Err(error) => {
898 tracing::warn!(?error, pid, "terminal process snapshot failed");
899 false
900 }
901 }
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
909 fn reserve_counter_enforces_limit_and_release_reopens_slot() {
910 let counter = AtomicUsize::new(0);
911
912 assert!(try_reserve_counter(&counter, 2));
913 assert!(try_reserve_counter(&counter, 2));
914 assert!(!try_reserve_counter(&counter, 2));
915 release_counter(&counter);
916 assert!(try_reserve_counter(&counter, 2));
917 }
918}