Files
codex/codex-rs/app-server-client/src/cloud.rs
T
Benjamin Carlsson 45179176e5 Add a channel-backed cloud app-server client (#56037)
## What changed

- Add `CloudClient` and integrate it with `AppServerClient` and `AppServerRequestHandle` for requests, notifications, server-request replies, events, and shutdown.
- Keep retained request and reply handles bound to their cloud backend, with event delivery independent of request completion and a timeout for shutdown.
- Return no platform or Codex home metadata for cloud clients, and reject TUI filesystem operations for this backend.

## Testing

Add tests for backend ownership of retained handles, independent event delivery, errors after the command receiver closes, and shutdown timeouts when the worker does not respond.

GitOrigin-RevId: 4cf308dc0ea6fa6e7b14dd4542d93e8030b0162d
2026-10-22 01:14:34 +00:00

164 lines
4.9 KiB
Rust

//! Channel-backed cloud client. Requests and retained replies stay on one owner;
//! events are drained independently so a foreground request cannot block its response.
use crate::AppServerEvent;
use crate::RequestResult;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result as JsonRpcResult;
use std::io;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
/// Operations the cloud worker must handle without forwarding to another backend.
/// Unsupported operations must return an explicit error; they must not claim success.
pub enum CloudOperation {
Request(Box<ClientRequest>),
Notify(ClientNotification),
Resolve {
request_id: RequestId,
result: JsonRpcResult,
},
Reject {
request_id: RequestId,
error: JSONRPCErrorError,
},
Shutdown,
}
/// An operation and its single response. Dropping a caller does not undo admission;
/// cloud workers must not retry mutations when the receiver has gone away.
pub struct CloudCommand {
pub operation: CloudOperation,
pub response: oneshot::Sender<io::Result<RequestResult>>,
}
#[derive(Clone)]
pub struct CloudRequestHandle {
commands: mpsc::Sender<CloudCommand>,
}
pub struct CloudClient {
handle: CloudRequestHandle,
events: mpsc::UnboundedReceiver<AppServerEvent>,
}
impl CloudClient {
/// Creates a cloud transport. The owner must drain commands and stop when
/// its receiver closes. Event delivery is independent of request completion,
/// matching conventional transports; cloud workers must bound remote payloads.
pub fn channel(
capacity: usize,
) -> (
Self,
mpsc::Receiver<CloudCommand>,
mpsc::UnboundedSender<AppServerEvent>,
) {
let (commands, receiver) = mpsc::channel(capacity.max(1));
let (events, event_receiver) = mpsc::unbounded_channel();
(
Self {
handle: CloudRequestHandle { commands },
events: event_receiver,
},
receiver,
events,
)
}
pub(crate) fn request_handle(&self) -> CloudRequestHandle {
self.handle.clone()
}
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
self.events.recv().await
}
pub(crate) async fn request(&self, request: ClientRequest) -> io::Result<RequestResult> {
self.handle.request(request).await
}
pub(crate) async fn notify(&self, notification: ClientNotification) -> io::Result<()> {
self.handle.unit(CloudOperation::Notify(notification)).await
}
pub(crate) async fn resolve_server_request(
&self,
request_id: RequestId,
result: JsonRpcResult,
) -> io::Result<()> {
self.handle.resolve_server_request(request_id, result).await
}
pub(crate) async fn reject_server_request(
&self,
request_id: RequestId,
error: JSONRPCErrorError,
) -> io::Result<()> {
self.handle.reject_server_request(request_id, error).await
}
pub(crate) async fn shutdown(self) -> io::Result<()> {
tokio::time::timeout(
crate::SHUTDOWN_TIMEOUT,
self.handle.unit(CloudOperation::Shutdown),
)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "cloud backend shutdown timed out"))?
}
}
impl CloudRequestHandle {
async fn send(&self, operation: CloudOperation) -> io::Result<RequestResult> {
let (response, result) = oneshot::channel();
self.commands
.send(CloudCommand {
operation,
response,
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "cloud backend is closed"))?;
result.await.map_err(|_| {
io::Error::new(
io::ErrorKind::BrokenPipe,
"cloud backend dropped its response",
)
})?
}
async fn unit(&self, operation: CloudOperation) -> io::Result<()> {
self.send(operation)
.await?
.map(|_| ())
.map_err(|error| io::Error::other(error.message))
}
pub(crate) async fn request(&self, request: ClientRequest) -> io::Result<RequestResult> {
self.send(CloudOperation::Request(Box::new(request))).await
}
pub(crate) async fn resolve_server_request(
&self,
request_id: RequestId,
result: JsonRpcResult,
) -> io::Result<()> {
self.unit(CloudOperation::Resolve { request_id, result })
.await
}
pub(crate) async fn reject_server_request(
&self,
request_id: RequestId,
error: JSONRPCErrorError,
) -> io::Result<()> {
self.unit(CloudOperation::Reject { request_id, error })
.await
}
}
#[cfg(test)]
#[path = "cloud_tests.rs"]
mod tests;