use std::{
collections::HashMap,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use agent_client_protocol::{
schema::v1::{
CreateTerminalRequest, CreateTerminalResponse, EnvVariable, KillTerminalRequest,
ReleaseTerminalRequest, SessionId, TerminalId, TerminalOutputRequest,
TerminalOutputResponse, WaitForTerminalExitRequest, WaitForTerminalExitResponse,
},
Client, ConnectionTo, Error as AcpError,
};
use rho_tools::{
HostCommand, HostError, HostExitStatus, HostFuture, HostTerminal, HostTerminalOutput,
HostTerminals,
};
use super::{AcpClientPort, AcpStartup};
pub(in crate::app) enum TerminalRequest {
Create(CreateTerminalRequest),
Output(TerminalOutputRequest),
WaitForExit(WaitForTerminalExitRequest),
Kill(KillTerminalRequest),
Release(ReleaseTerminalRequest),
}
pub(in crate::app) enum TerminalResponse {
Created(CreateTerminalResponse),
Output(TerminalOutputResponse),
Exited(WaitForTerminalExitResponse),
Killed,
Released,
}
pub(super) async fn send(
cx: &ConnectionTo<Client>,
request: TerminalRequest,
) -> Result<TerminalResponse, AcpError> {
Ok(match request {
TerminalRequest::Create(request) => {
TerminalResponse::Created(cx.send_request(request).block_task().await?)
}
TerminalRequest::Output(request) => {
TerminalResponse::Output(cx.send_request(request).block_task().await?)
}
TerminalRequest::WaitForExit(request) => {
TerminalResponse::Exited(cx.send_request(request).block_task().await?)
}
TerminalRequest::Kill(request) => {
cx.send_request(request).block_task().await?;
TerminalResponse::Killed
}
TerminalRequest::Release(request) => {
cx.send_request(request).block_task().await?;
TerminalResponse::Released
}
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Lease {
Running,
Finished,
}
#[derive(Default)]
struct Leases(std::sync::Mutex<HashMap<TerminalId, Lease>>);
impl Leases {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<TerminalId, Lease>> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
pub(super) struct ClientTerminals {
port: Arc<dyn AcpClientPort>,
session_id: SessionId,
leases: Arc<Leases>,
}
impl ClientTerminals {
pub(super) fn for_session(startup: &AcpStartup, session_id: &SessionId) -> Option<Arc<Self>> {
let port = startup
.client
.as_ref()
.filter(|_| startup.host_features.terminals)?;
Some(Arc::new(Self {
port: Arc::clone(port),
session_id: session_id.clone(),
leases: Arc::default(),
}))
}
pub(super) async fn release_finished(&self) {
self.release( false).await;
}
pub(super) async fn release_all(&self) {
self.release( true).await;
}
async fn release(&self, including_running: bool) {
let released: Vec<TerminalId> = {
let mut leases = self.leases.lock();
let ids = leases
.iter()
.filter(|(_, lease)| including_running || **lease == Lease::Finished)
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
for id in &ids {
leases.remove(id);
}
ids
};
for id in released {
let request = ReleaseTerminalRequest::new(self.session_id.clone(), id.clone());
if let Err(error) = self.port.terminal(TerminalRequest::Release(request)).await {
tracing::warn!(terminal = %id.0, %error, "could not release a host terminal");
}
}
}
}
impl HostTerminals for ClientTerminals {
fn create(&self, command: HostCommand) -> HostFuture<'_, Box<dyn HostTerminal>> {
Box::pin(async move {
let request = CreateTerminalRequest::new(self.session_id.clone(), command.program)
.args(command.args)
.env(
command
.env
.into_iter()
.map(|(name, value)| EnvVariable::new(name, value))
.collect(),
)
.cwd(command.cwd)
.output_byte_limit(command.output_byte_limit);
let (port, session_id, leases) = (
Arc::clone(&self.port),
self.session_id.clone(),
Arc::clone(&self.leases),
);
let (claim, claimed) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let created = match port.terminal(TerminalRequest::Create(request)).await {
Ok(TerminalResponse::Created(created)) => Ok(created),
Ok(_) => Err(mismatched("terminal/create")),
Err(error) => Err(host_error(error)),
};
let _ = claim.send(created.map(|created| {
Unclaimed(Some(ClientTerminal {
port,
session_id,
id: created.terminal_id,
leases,
running: AtomicBool::new(true),
}))
}));
});
let terminal = claimed
.await
.map_err(|_| HostError::new("terminal/create task ended without a reply"))??
.claim();
self.leases
.lock()
.insert(terminal.id.clone(), Lease::Running);
Ok(Box::new(terminal) as Box<dyn HostTerminal>)
})
}
}
struct Unclaimed(Option<ClientTerminal>);
impl Unclaimed {
fn claim(mut self) -> ClientTerminal {
self.0.take().expect("a terminal is claimed once")
}
}
impl Drop for Unclaimed {
fn drop(&mut self) {
let Some(terminal) = self.0.take() else {
return;
};
terminal.running.store(false, Ordering::Relaxed);
let request = ReleaseTerminalRequest::new(terminal.session_id.clone(), terminal.id.clone());
terminal.send_detached(TerminalRequest::Release(request), "release");
}
}
struct ClientTerminal {
port: Arc<dyn AcpClientPort>,
session_id: SessionId,
id: TerminalId,
leases: Arc<Leases>,
running: AtomicBool,
}
impl ClientTerminal {
async fn send(&self, request: TerminalRequest) -> Result<TerminalResponse, HostError> {
self.port.terminal(request).await.map_err(host_error)
}
fn send_detached(&self, request: TerminalRequest, method: &'static str) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
tracing::warn!(terminal = %self.id.0, method, "no runtime to send a host terminal request");
return;
};
let (port, id) = (Arc::clone(&self.port), self.id.clone());
runtime.spawn(async move {
if let Err(error) = port.terminal(request).await {
tracing::warn!(terminal = %id.0, method, %error, "host terminal request failed");
}
});
}
}
impl HostTerminal for ClientTerminal {
fn id(&self) -> &str {
&self.id.0
}
fn wait_for_exit(&self) -> HostFuture<'_, HostExitStatus> {
Box::pin(async move {
let request = WaitForTerminalExitRequest::new(self.session_id.clone(), self.id.clone());
match self.send(TerminalRequest::WaitForExit(request)).await? {
TerminalResponse::Exited(exited) => {
self.running.store(false, Ordering::Relaxed);
Ok(exit_status(exited.exit_status))
}
_ => Err(mismatched("terminal/wait_for_exit")),
}
})
}
fn output(&self) -> HostFuture<'_, HostTerminalOutput> {
Box::pin(async move {
let request = TerminalOutputRequest::new(self.session_id.clone(), self.id.clone());
match self.send(TerminalRequest::Output(request)).await? {
TerminalResponse::Output(output) => {
Ok(HostTerminalOutput::new(output.output, output.truncated))
}
_ => Err(mismatched("terminal/output")),
}
})
}
fn kill(&self) -> HostFuture<'_, ()> {
Box::pin(async move {
let request = KillTerminalRequest::new(self.session_id.clone(), self.id.clone());
self.send(TerminalRequest::Kill(request)).await?;
self.running.store(false, Ordering::Relaxed);
Ok(())
})
}
}
impl Drop for ClientTerminal {
fn drop(&mut self) {
if let Some(lease) = self.leases.lock().get_mut(&self.id) {
*lease = Lease::Finished;
}
if self.running.load(Ordering::Relaxed) {
let request = KillTerminalRequest::new(self.session_id.clone(), self.id.clone());
self.send_detached(TerminalRequest::Kill(request), "kill");
}
}
}
fn exit_status(status: agent_client_protocol::schema::v1::TerminalExitStatus) -> HostExitStatus {
HostExitStatus::new(
status.exit_code.and_then(|code| i32::try_from(code).ok()),
status.signal,
)
}
fn host_error(error: AcpError) -> HostError {
HostError::new(error.to_string())
}
fn mismatched(method: &str) -> HostError {
HostError::new(format!("{method} got a reply of another kind"))
}
#[cfg(test)]
#[path = "client_terminals_tests.rs"]
mod tests;