Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+602
View File
@@ -0,0 +1,602 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use futures::channel::oneshot;
use futures::io::{AsyncRead, AsyncWrite};
use warpui::r#async::{executor, FutureExt as _};
use crate::proto::{
client_message, server_message, Abort, ClientMessage, DeleteFile, ErrorCode, Initialize,
InitializeResponse, LoadRepoMetadataDirectoryResponse, NavigatedToDirectoryResponse,
ReadFileContextRequest, ReadFileContextResponse, RunCommandRequest, RunCommandResponse,
ServerMessage, SessionBootstrapped, WriteFile,
};
use crate::protocol::{self, ProtocolError, RequestId};
use warp_core::SessionId;
use warpui::r#async::TransportStream;
/// Default request timeout (2 minutes).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Errors from the `RemoteServerClient`.
#[derive(thiserror::Error, Debug)]
pub enum ClientError {
#[error("Connection was dropped")]
Disconnected,
#[error("Protocol error: {0}")]
Protocol(#[from] ProtocolError),
#[error("Response channel closed before receiving a reply")]
ResponseChannelClosed,
#[error("Unexpected response from server")]
UnexpectedResponse,
#[error("Server error ({code:?}): {message}")]
ServerError { code: ErrorCode, message: String },
#[error("Request timed out after {0:?}")]
Timeout(Duration),
#[error("File operation failed: {0}")]
FileOperationFailed(String),
}
/// Events received from the remote server, delivered through the event
/// channel returned by [`RemoteServerClient::new`].
///
/// The consumer (typically `RemoteServerManager`) drains this channel to
/// react to connection lifecycle changes and server-pushed data.
#[derive(Clone, Debug)]
pub enum ClientEvent {
/// The reader task detected EOF or a fatal error. The connection is gone.
/// This is always the last event sent on the channel.
Disconnected,
/// A full or lazy-loaded repo metadata snapshot was pushed by the server.
RepoMetadataSnapshotReceived {
update: repo_metadata::RepoMetadataUpdate,
},
/// An incremental repo metadata update was pushed by the server.
RepoMetadataUpdated {
update: repo_metadata::RepoMetadataUpdate,
},
/// A server message could not be decoded and had no parseable request_id.
MessageDecodingError,
}
/// Client for communicating with a `remote_server` process over the remote server protocol.
///
/// Exposes async request/response APIs over generic I/O streams (child-process pipes,
/// SSH channels, or in-memory streams for testing).
///
/// Designed to be wrapped in `Arc` for sharing across threads. Construction
/// returns an event receiver that delivers push events and a final
/// `Disconnected` event when the connection drops.
///
/// This type does **not** own the child subprocess whose stdio backs it.
/// For transports that spawn a subprocess (e.g. SSH), the caller is
/// responsible for holding the `Child` for the lifetime of the session
/// so that `kill_on_drop` fires when teardown occurs. In Warp this is
/// the `RemoteServerManager`, which stores the child in
/// `RemoteSessionState` alongside the `Arc<RemoteServerClient>`. That
/// way the child's lifetime is gated by the manager's session map
/// rather than by `Arc` refcount -- cloning `Arc<RemoteServerClient>`
/// into other owners (e.g. the command executor) no longer keeps the
/// child alive.
pub struct RemoteServerClient {
/// Channel for queuing ClientMessages to send to the remote server.
outbound_tx: async_channel::Sender<ClientMessage>,
/// Maps `request_id` → oneshot sender for the correlated response from the remote server.
pending_requests: Arc<DashMap<RequestId, oneshot::Sender<Result<ServerMessage, ClientError>>>>,
/// Set to `true` by the reader task when the connection is lost. Checked by
/// `send_request` after inserting into `pending_requests` to avoid hanging
/// on a dead connection.
disconnected: Arc<AtomicBool>,
}
impl fmt::Debug for RemoteServerClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteServerClient").finish_non_exhaustive()
}
}
#[cfg(not(target_family = "wasm"))]
impl RemoteServerClient {
/// Creates a client from a child process's stdin, stdout, and stderr.
///
/// The caller retains ownership of the `Child` itself. Typically the
/// caller spawns the `Command` with `kill_on_drop(true)` and stashes
/// the returned `Child` somewhere whose lifetime matches the
/// session's (in Warp, on the `RemoteServerManager`'s
/// `RemoteSessionState`). Dropping the `Child` there triggers
/// SIGKILL on the subprocess, regardless of how many
/// `Arc<RemoteServerClient>` clones are still alive.
///
/// Internally forwards stderr lines to local logging via
/// [`spawn_stderr_forwarder`], then delegates to [`Self::new`] for the
/// protocol reader/writer setup.
///
/// Returns the client and an event receiver that delivers push events
/// and a final `Disconnected` event when the connection drops.
pub fn from_child_streams(
stdin: async_process::ChildStdin,
stdout: async_process::ChildStdout,
stderr: async_process::ChildStderr,
executor: &executor::Background,
) -> (Self, async_channel::Receiver<ClientEvent>) {
spawn_stderr_forwarder(stderr, executor);
Self::new(stdout, stdin, executor)
}
}
impl RemoteServerClient {
/// Creates a new client, spawning background reader and writer tasks on the
/// provided executor.
///
/// Returns the client and an event receiver that delivers push events
/// and a final `Disconnected` event when the connection drops.
pub fn new(
reader: impl AsyncRead + TransportStream,
writer: impl AsyncWrite + TransportStream,
executor: &executor::Background,
) -> (Self, async_channel::Receiver<ClientEvent>) {
let pending_requests: Arc<
DashMap<RequestId, oneshot::Sender<Result<ServerMessage, ClientError>>>,
> = Arc::new(DashMap::new());
let (outbound_tx, outbound_rx) = async_channel::unbounded::<ClientMessage>();
let (event_tx, event_rx) = async_channel::unbounded::<ClientEvent>();
let disconnected = Arc::new(AtomicBool::new(false));
executor
.spawn(Self::writer_task(
writer,
outbound_rx,
Arc::clone(&pending_requests),
))
.detach();
executor
.spawn(Self::reader_task(
reader,
Arc::clone(&pending_requests),
event_tx,
Arc::clone(&disconnected),
))
.detach();
(
Self {
outbound_tx,
pending_requests,
disconnected,
},
event_rx,
)
}
/// Sends an `Initialize` request and awaits the `InitializeResponse`.
pub async fn initialize(&self) -> Result<InitializeResponse, ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::Initialize(Initialize {})),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::InitializeResponse(resp)) => Ok(resp),
other => {
log::error!("Unexpected response variant for Initialize: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Sends a `SessionBootstrapped` notification (fire-and-forget) so the
/// server can create a `LocalCommandExecutor` for the session.
pub fn notify_session_bootstrapped(
&self,
session_id: SessionId,
shell_type: &str,
shell_path: Option<&str>,
) {
let msg = ClientMessage {
request_id: String::new(),
message: Some(client_message::Message::SessionBootstrapped(
SessionBootstrapped {
session_id: session_id.as_u64(),
shell_type: shell_type.to_owned(),
shell_path: shell_path.map(ToOwned::to_owned),
},
)),
};
self.send_notification(msg);
}
/// Sends a `NavigatedToDirectory` request and awaits the response.
pub async fn navigate_to_directory(
&self,
path: String,
) -> Result<NavigatedToDirectoryResponse, ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::NavigatedToDirectory(
crate::proto::NavigatedToDirectory { path },
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::NavigatedToDirectoryResponse(resp)) => Ok(resp),
other => {
log::error!("Unexpected response variant for NavigatedToDirectory: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Sends a `LoadRepoMetadataDirectory` request and awaits the response.
pub async fn load_repo_metadata_directory(
&self,
repo_path: String,
dir_path: String,
) -> Result<LoadRepoMetadataDirectoryResponse, ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::LoadRepoMetadataDirectory(
crate::proto::LoadRepoMetadataDirectory {
repo_path,
dir_path,
},
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::LoadRepoMetadataDirectoryResponse(resp)) => Ok(resp),
other => {
log::error!("Unexpected response variant for LoadRepoMetadataDirectory: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Writes content to a file on the remote host.
/// Creates parent directories if they don't exist.
pub async fn write_file(&self, path: String, content: String) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::WriteFile(WriteFile {
path,
content,
})),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::WriteFileResponse(resp)) => match resp.result {
Some(crate::proto::write_file_response::Result::Success(_)) | None => Ok(()),
Some(crate::proto::write_file_response::Result::Error(e)) => {
Err(ClientError::FileOperationFailed(e.message))
}
},
other => {
log::error!("Unexpected response variant for WriteFile: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Batch-reads one or more files from the remote host with full context
/// (line ranges, binary/image support, metadata, size limits).
///
/// Per-file failures are reported in `ReadFileContextResponse::failed_files`
/// rather than as a top-level error. The method only returns `Err` for
/// transport-level failures (disconnect, timeout, etc.).
pub async fn read_file_context(
&self,
request: ReadFileContextRequest,
) -> Result<ReadFileContextResponse, ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::ReadFileContext(request)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::ReadFileContextResponse(resp)) => Ok(resp),
other => {
log::error!("Unexpected response variant for ReadFileContext: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Deletes a file on the remote host.
pub async fn delete_file(&self, path: String) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::DeleteFile(DeleteFile { path })),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::DeleteFileResponse(resp)) => match resp.result {
Some(crate::proto::delete_file_response::Result::Success(_)) | None => Ok(()),
Some(crate::proto::delete_file_response::Result::Error(e)) => {
Err(ClientError::FileOperationFailed(e.message))
}
},
other => {
log::error!("Unexpected response variant for DeleteFile: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Converts a server push message (empty request_id) into a domain event.
fn push_message_to_event(msg: ServerMessage) -> Option<ClientEvent> {
match msg.message? {
server_message::Message::RepoMetadataSnapshot(snapshot) => {
let update = crate::repo_metadata_proto::proto_snapshot_to_update(&snapshot)?;
Some(ClientEvent::RepoMetadataSnapshotReceived { update })
}
server_message::Message::RepoMetadataUpdate(push) => {
let update = crate::repo_metadata_proto::proto_to_repo_metadata_update(&push)?;
Some(ClientEvent::RepoMetadataUpdated { update })
}
other => {
log::warn!("Unhandled push message variant: {other:?}");
None
}
}
}
/// Sends a `RunCommand` request
pub async fn run_command(
&self,
session_id: SessionId,
command: String,
working_directory: Option<String>,
environment_variables: HashMap<String, String>,
) -> Result<RunCommandResponse, ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::RunCommand(RunCommandRequest {
command,
working_directory,
environment_variables,
session_id: session_id.as_u64(),
})),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::RunCommandResponse(resp)) => Ok(resp),
other => {
log::error!("Unexpected response variant for RunCommand: {other:?}");
Err(ClientError::UnexpectedResponse)
}
}
}
/// Generic request/response correlation.
///
/// Registers a oneshot channel keyed by `request_id`, sends the message
/// through the outbound channel, and awaits the correlated response.
/// Times out after `REQUEST_TIMEOUT` and sends an `Abort` to the server.
async fn send_request(
&self,
request_id: RequestId,
msg: ClientMessage,
) -> Result<ServerMessage, ClientError> {
let (tx, rx) = oneshot::channel();
self.pending_requests.insert(request_id.clone(), tx);
// Check if the reader task has already marked the connection as dead.
// The DashMap lock from `insert` above synchronizes with the lock from
// `clear` in `reader_task`, so if `clear` ran before our insert the
// flag is guaranteed to be visible here.
if self.disconnected.load(Ordering::Acquire) {
self.pending_requests.clear();
return Err(ClientError::Disconnected);
}
if self.outbound_tx.send(msg).await.is_err() {
self.pending_requests.remove(&request_id);
return Err(ClientError::Disconnected);
}
let result = match rx.with_timeout(REQUEST_TIMEOUT).await {
Ok(Ok(inner)) => inner,
Ok(Err(_)) => return Err(ClientError::ResponseChannelClosed),
Err(_) => {
// Timed out — clean up and send abort.
self.pending_requests.remove(&request_id);
self.send_abort(&request_id);
return Err(ClientError::Timeout(REQUEST_TIMEOUT));
}
};
// Unwrap the inner Result (reader task may send Err for decode failures).
let response = result?;
// Convert server-reported ErrorResponse into ClientError so callers
// only need to match on success variants.
if let Some(server_message::Message::Error(ref e)) = response.message {
return Err(ClientError::ServerError {
code: e.code(),
message: e.message.clone(),
});
}
Ok(response)
}
/// Sends an `Abort` notification for the given request ID.
fn send_abort(&self, request_id_to_abort: &RequestId) {
let msg = ClientMessage {
request_id: RequestId::new().to_string(),
message: Some(client_message::Message::Abort(Abort {
request_id_to_abort: request_id_to_abort.to_string(),
})),
};
self.send_notification(msg);
}
/// Sends a message without registering a pending request (fire-and-forget).
fn send_notification(&self, msg: ClientMessage) {
// Use try_send to avoid blocking; if the channel is full or closed,
// the notification is best-effort.
if let Err(e) = self.outbound_tx.try_send(msg) {
log::debug!("Failed to send notification (best-effort): {e}");
}
}
/// Background task that writes `ClientMessage`s to the underlying stream.
async fn writer_task(
writer: impl AsyncWrite + TransportStream,
outbound_rx: async_channel::Receiver<ClientMessage>,
pending_requests: Arc<
DashMap<RequestId, oneshot::Sender<Result<ServerMessage, ClientError>>>,
>,
) {
let mut writer = futures::io::BufWriter::new(writer);
while let Ok(msg) = outbound_rx.recv().await {
if let Err(e) = protocol::write_client_message(&mut writer, &msg).await {
let request_id = RequestId::from(msg.request_id);
if !e.is_write_recoverable() {
log::error!("Writer task fatal error: request_id={request_id}: {e}");
pending_requests.clear();
break;
}
log::error!("Writer task: request_id={request_id}: {e}");
// Drop the sender so the caller receives ResponseChannelClosed.
pending_requests.remove(&request_id);
}
}
}
/// Background task that reads `ServerMessage`s and resolves pending
/// requests by `request_id`, or converts push messages to events.
///
/// Sends `ClientEvent::Disconnected` as the final event when the
/// connection is lost.
async fn reader_task(
reader: impl AsyncRead + TransportStream,
pending_requests: Arc<
DashMap<RequestId, oneshot::Sender<Result<ServerMessage, ClientError>>>,
>,
event_tx: async_channel::Sender<ClientEvent>,
disconnected: Arc<AtomicBool>,
) {
let mut reader = futures::io::BufReader::new(reader);
loop {
match protocol::read_server_message(&mut reader).await {
Ok(msg) => {
let request_id = RequestId::from(msg.request_id.clone());
if request_id.is_empty() {
// Push message — convert to a domain event and forward.
if let Some(event) = Self::push_message_to_event(msg) {
if event_tx.send(event).await.is_err() {
log::warn!("Event channel closed, dropping push message");
}
}
} else if let Some((_, tx)) = pending_requests.remove(&request_id) {
// Ignore send failure — the caller may have dropped the receiver.
let _ = tx.send(Ok(msg));
} else {
log::warn!("Received unexpected response with request_id={request_id}");
}
}
Err(ProtocolError::Decode(ref err, Some(ref request_id))) => {
if let Some((_, tx)) = pending_requests.remove(request_id) {
log::warn!(
"Reader task: malformed response \
(request_id={request_id}): {err}"
);
let _ = tx.send(Err(ClientError::Protocol(ProtocolError::Decode(
err.clone(),
Some(request_id.clone()),
))));
} else {
log::warn!(
"Reader task: malformed response for \
unknown request (request_id={request_id}): {err}"
);
}
}
Err(ProtocolError::Decode(ref err, None)) => {
log::warn!(
"Reader task: skipping malformed response \
(no parseable request_id): {err}"
);
let _ = event_tx.send(ClientEvent::MessageDecodingError).await;
}
Err(e) if e.is_read_recoverable() => {
log::warn!("Reader task: skipping message: {e}");
}
Err(e) => {
match e {
ProtocolError::UnexpectedEof => {
log::info!("Reader task: server disconnected (EOF)");
}
_ => log::error!("Reader task fatal error: {e}"),
}
break;
}
}
}
// Mark the connection as dead so that any new `send_request` calls
// fail immediately rather than hanging forever. This prevents a race
// where `pending_requests.clear()` runs before `send_request` has
// inserted its oneshot entry.
disconnected.store(true, Ordering::Release);
// Notify all pending requests that the connection is gone.
pending_requests.clear();
// Signal disconnection as the final event.
let _ = event_tx.send(ClientEvent::Disconnected).await;
}
}
/// Spawns a background task that reads lines from the server's stderr and
/// forwards them to the client's logging.
#[cfg(not(target_family = "wasm"))]
pub fn spawn_stderr_forwarder(
stderr: impl AsyncRead + TransportStream,
executor: &executor::Background,
) {
use futures::io::AsyncBufReadExt;
use futures::StreamExt;
executor
.spawn(async move {
let reader = futures::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Some(Ok(line)) = lines.next().await {
log::info!("[remote_server] {line}");
}
})
.detach();
}
#[cfg(test)]
#[path = "../client_tests.rs"]
mod tests;