19 KiB
Remote Command Execution for SSH Completions via RemoteServerManager
Linear: APP-3791
1. Problem
When a user SSHes into a remote host, Warp's completions pipeline (autosuggestions, syntax highlighting, tab completions) needs to run generator commands on the remote machine. Today this is done by opening a new SSH session per command, which is constrained by the host's MaxSessions limit and cannot run commands in parallel.
This spec covers building the completions path through the persistent remote_server process:
- Proto messages —
RunCommandRequest/RunCommandResponsefor executing shell commands on the remote host. - Server-side handling —
ServerModeldispatchesRunCommandto$SHELL -c, returns output. - Client-side API —
RemoteServerClient.run_command()sends the request and correlates the response. RemoteServerCommandExecutor— aCommandExecutorthat uses aRemoteServerClientto execute commands.- Wiring — how the executor gets its client from the manager via event subscription.
Scope
Triggering RemoteServerManager.connect_session() is handled in a separate flow. The manager's connection flow sends an Initialize handshake (for version/host negotiation) followed by a SessionBootstrapped notification that carries session_id, shell_type, and shell_path so the server creates a per-session LocalCommandExecutor matching the bootstrapped shell. This spec assumes the server is already running and the manager is in Connected state. Error conditions are logged but not surfaced to the user.
2. Relevant Code
Protocol
app/proto/remote_server.proto—ClientMessage/ServerMessageenvelopes,RunCommandRequest(field 7 onClientMessage),RunCommandResponse(field 8 onServerMessage)app/src/remote_server/protocol.rs— length-delimited protobuf read/write helpers,RequestIdnewtype
Server-side
app/src/remote_server/server_model.rs—ServerModeldispatcheshandle_messageon the main thread;RunCommandarm delegates toLocalCommandExecutorviactx.spawn_abortable, sendsRunCommandResponseback throughresponse_tx
Client-side
app/src/remote_server/client.rs—RemoteServerClientwithrun_command(),initialize(), background reader/writer tasks,ClientErrorenum,ClientEvent::Disconnected
Remote server manager
app/src/remote_server/manager.rs—RemoteServerManagersingleton with per-session state (RemoteSessionStateenum:Connecting→Initializing→Connected→Disconnected),connect_session,client_for_session,deregister_session, session-scoped events (SessionConnected,SessionDisconnected) and host-scoped events (HostConnected,HostDisconnected)app/src/lib.rs:1202— singleton registration at app startup
Command executor framework
app/src/terminal/model/session/command_executor.rs—CommandExecutortrait (execute_command,supports_parallel_command_execution),new_command_executor_for_sessiondispatchapp/src/terminal/model/session.rs (199-309)—Sessions::initialize_bootstrapped_session()creates the command executor for each session
Existing SSH executor (being replaced)
app/src/terminal/model/session/command_executor/remote_command_executor.rs—RemoteCommandExecutoropens a one-off SSH session per command viaControlMaster/ControlPath. Limited byMaxSessions, does not support parallel execution.
3. Current State
SSH completions currently use RemoteCommandExecutor, which forks a new ssh process for every generator command (e.g. compgen -c, ls). Each invocation opens a new channel on the ControlMaster SSH connection. This has two problems:
- MaxSessions limit: Many SSH servers default
MaxSessionsto 10. When multiple generators fire in parallel, they can exceed this limit and getchannel: open failederrors. To avoid this,RemoteCommandExecutorreturnsfalsefromsupports_parallel_command_execution(), serializing all generator commands and making completions slow. - Per-command overhead: Each command requires SSH channel setup/teardown. Even with multiplexing, the per-command latency adds up across the dozens of generators that fire during a typical completion cycle.
The remote server architecture (proto, ServerModel, RemoteServerClient, RemoteServerManager) already exists. The remote_server binary runs on the remote host as a long-lived process, communicating with the client over a single SSH connection via length-delimited protobuf. The manager tracks per-session state (Connecting → Initializing → Connected → Disconnected) and emits lifecycle events. What's missing is the RunCommand flow (proto messages, server dispatch, client API) and the CommandExecutor implementation that plugs into the completions pipeline.
4. Proposed Changes
4.1. Proto changes
RunCommandRequest (field 7 on ClientMessage.oneof):
string command— the shell command to execute.optional string working_directory— cwd for the command. If absent, uses the server's default.map<string, string> environment_variables— env vars applied natively viacmd.envs(...), not baked into the command string.uint64 session_id— routes the command to the correct per-session executor.
RunCommandResponse (field 8 on ServerMessage.oneof):
bytes stdout/bytes stderr— raw output.bytesto avoid UTF-8 validity assumptions.optional int32 exit_code— absent when the process is killed by a signal (Unix).
4.2. Server-side: per-session executors in ServerModel
ServerModel maintains a HashMap<SessionId, Arc<LocalCommandExecutor>> (executors) - every session must be registered via SessionBootstrapped before it can run commands.
SessionBootstrapped handler: When the client sends SessionBootstrapped with session_id, shell_type, and optionally shell_path, the server parses the shell type and creates a LocalCommandExecutor with the provided shell_path (or falls back to the bare shell name if absent). The executor is inserted into executors keyed by the session ID. If the shell type is unknown, the handler logs an error and returns early (this is a notification — no response is sent).
Repeated SessionBootstrapped: If SessionBootstrapped is sent again for the same session_id, the new executor overwrites the old one (last-writer-wins). In-flight commands on the old executor complete or fail naturally since they hold their own Arc<LocalCommandExecutor>. A warning is logged when this happens.
RunCommand handler: Looks up the executor by session_id from the request. If the session is unregistered, returns ErrorResponse with INVALID_REQUEST — this is a bug (every session goes through SessionBootstrapped before sending commands).
- Future: delegates to
LocalCommandExecutor::execute_local_command(). on_resolve(main thread): removes the entry fromin_progress. IfOk(output), wraps inRunCommandResponseand sends viaresponse_tx. IfErr(e), sendsErrorResponse { code: INTERNAL, message }.on_abort(main thread): removes the entry fromin_progressand logs the cancellation. No response is sent —Abortis fire-and-forget.- Abort handling: When the client sends
Abort,handle_messageremoves theSpawnedFutureHandlefromin_progressand callshandle.abort(). The framework aborts the background future (dropping it kills the child process viakill_on_drop) and invokes theon_abortcallback.
This same spawn_abortable + in_progress pattern is used for all async request handlers (e.g. NavigatedToDirectory), so the Abort handler works generically for any in-progress request.
Why LocalCommandExecutor: Delegating to it gives us shell config flags (--norc for bash, -f for zsh, --no-config for fish) that suppress sourcing .bashrc/.zshrc, which is correct for generator commands. It also gives us process-group tracking and kill_on_drop via the command crate's Command wrapper.
4.3. Client-side: RemoteServerClient.run_command()
pub async fn run_command(
&self,
session_id: SessionId,
command: String,
working_directory: Option<String>,
environment_variables: HashMap<String, String>,
) -> Result<RunCommandResponse, ClientError>
run_command() accepts SessionId to route to the correct per-session executor. It uses the same request/response correlation pattern as initialize(): generate a RequestId, construct a ClientMessage, call send_request, match the response variant. On ErrorResponse, returns ClientError::ServerError. On timeout, sends Abort and returns ClientError::Timeout. Session registration is handled separately by notify_session_bootstrapped(), which sends a fire-and-forget SessionBootstrapped notification (no response expected).
4.4. RemoteServerCommandExecutor
A CommandExecutor implementation in app/src/terminal/model/session/command_executor/remote_server_executor.rs.
Structure:
#[derive(Debug)]
pub struct RemoteServerCommandExecutor {
session_id: SessionId,
client: RwLock<Option<Arc<RemoteServerClient>>>,
}
The executor holds its SessionId and a parking_lot::RwLock<Option<Arc<RemoteServerClient>>> that starts as None and is updated from the main thread when the connection state changes.
Why RwLock<Option<>>: The manager stores each session's client as Arc<RemoteServerClient> inside RemoteSessionState. client_for_session() returns Option<&Arc<RemoteServerClient>>, so the main thread can clone the Arc out. Cloning gives a second handle to the same underlying channels (outbound_tx, pending_requests), fully functional from any thread. RwLock<Option<>> lets the main thread write the Arc on connect and clear it on disconnect, while background threads read it without needing AppContext.
Unlike OnceLock, RwLock<Option<>> supports clearing the client on disconnect and replacing it on reconnect. The read lock is uncontended in practice — the writer (main thread, on connect/disconnect) and readers (background threads, on execute_command) almost never overlap, and the read-side operation is just an Arc::clone.
set_client(client: Arc<RemoteServerClient>) — writes Some(client) into the RwLock. Called from the main thread on connect.
clear_client() — writes None into the RwLock. Called from the main thread on disconnect.
CommandExecutor impl:
execute_command(command, shell, cwd, env_vars, options): readsself.client.read().clone(). IfNone(server not connected yet or disconnected), returns emptyCommandOutputwithFailurestatus and logs a warning. Otherwise, callsclient.run_command(self.session_id, command, cwd, env_vars). Timeout and abort are handled bysend_requestinternally using the sharedREQUEST_TIMEOUT. TranslatesRunCommandResponse→CommandOutput.supports_parallel_command_execution()→true. The remote server multiplexes commands over a single SSH connection (unlikeRemoteCommandExecutorwhich opens a new SSH session per command and is limited byMaxSessions).
4.5. Wiring the executor to RemoteServerManager
The executor does not spawn or manage the server. It gets its RemoteServerClient from the RemoteServerManager via two mechanisms set up during executor creation in new_command_executor_for_local_tty_session.
The manager is per-session: each SSH session gets its own RemoteServerClient and SSH connection. Events are session-scoped (SessionConnected { session_id, host_id }, SessionDisconnected { session_id, host_id }). The host-level tracking (host_to_sessions) exists only to deduplicate host-scoped models (e.g. RepoMetadataModel), not connections.
A. Eager check at creation time: If this session's server is already connected (the executor is created after the manager has already completed the handshake), set the client immediately:
let executor = Arc::new(RemoteServerCommandExecutor::new(session_id));
let remote_server_manager = RemoteServerManager::handle(ctx);
let executor_clone = executor.clone();
remote_server_manager.read(ctx, |manager, _ctx| {
if let Some(client) = manager.client_for_session(session_id) {
executor_clone.set_client(Arc::clone(client));
}
});
B. Event subscription for connection changes: Subscribe to RemoteServerManagerEvent so the executor tracks the connection lifecycle. The subscription receives events for all sessions, so it filters on session_id:
let executor_clone = executor.clone();
let remote_server_manager_clone = remote_server_manager.clone();
ctx.subscribe_to_model(&remote_server_manager, move |_sessions, event, ctx| {
match event {
RemoteServerManagerEvent::SessionConnected { session_id: sid, .. } if *sid == session_id => {
remote_server_manager_clone.read(ctx, |manager, _ctx| {
if let Some(client) = manager.client_for_session(session_id) {
executor_clone.set_client(Arc::clone(client));
}
});
}
RemoteServerManagerEvent::SessionDisconnected { session_id: sid, .. } if *sid == session_id => {
executor_clone.clear_client();
}
_ => {}
}
});
Both paths are needed: (A) handles the case where the server is already connected for this session, (B) handles connection and disconnection events after creation. On disconnect, the client is cleared so execute_command returns clean "not connected" results. On reconnect, the subscription fires SessionConnected again and sets the new client.
4.6. Dispatch ordering in new_command_executor_for_local_tty_session
The SshRemoteServer branch is added as the first check in new_command_executor_for_local_tty_session, before all other SSH executor paths:
1. SshRemoteServer + IsLegacySSHSession::Yes → RemoteServerCommandExecutor [NEW]
2. SSHTmuxWrapper + tmux_control_mode → TmuxCommandExecutor
3. SessionType::Local (various) → LocalCommandExecutor / MSYS2 / WSL
4. WarpifiedRemote + legacy SSH + !InBandForSSH → RemoteCommandExecutor
5. default → InBandCommandExecutor / NoOp
Why first: When the remote server is available, it is strictly better than every other SSH command execution method:
- vs
RemoteCommandExecutor(branch 4): opens a new SSH session per command, limited by the host'sMaxSessionssshd setting. The remote server multiplexes all commands over a single persistent connection. - vs
InBandCommandExecutor(branch 5): injects commands into the user's visible terminal session. Slow, fragile, and pollutes terminal output. - vs
TmuxCommandExecutor(branch 2): wraps the session in tmux for generator access. The remote server provides the same capability without the tmux dependency.
By checking SshRemoteServer first, we ensure that when the flag is on, the persistent multiplexed connection is always preferred. When the flag is off, the existing executor dispatch is unchanged.
The full executor creation (including the eager check and subscription from §4.5) happens in this branch.
5. End-to-End Flow
Precondition
RemoteServerManager.connect_session() was called by a separate flow (out of scope for this spec). The manager has completed: server startup → Arc<RemoteServerClient> creation (state: Initializing) → initialize handshake → mark_session_connected (state: Connected). The session's RemoteSessionState is Connected { client: Arc<RemoteServerClient>, host_id }.
Executor creation
- SSH session bootstraps.
Sessions::initialize_bootstrapped_session()creates aRemoteServerCommandExecutorwithsession_id. - Eager check: reads
manager.client_for_session(session_id)— if this session isConnected, clones theArc<RemoteServerClient>and callsexecutor.set_client(client). - Subscribes to
RemoteServerManagerEventfor futureSessionConnected/SessionDisconnectedevents, filtering on matchingsession_id.
RunCommand flow
Detailed steps:
- Generator calls
executor.execute_command("compgen -c", shell, cwd, env_vars, opts). RemoteServerCommandExecutorreadsself.client.read().clone(). IfSome, uses theRemoteServerClient.- Calls
client.run_command(self.session_id, command, cwd, env_vars). run_commandcallssend_request(request_id, msg), which registers a oneshot inpending_requests, sends theClientMessageviaoutbound_tx, and awaits the response with the standardREQUEST_TIMEOUT. If the timeout fires,send_requestremoves thepending_requestsentry and sendsAbortto the server.- Client writer task pulls message from channel, calls
write_client_message→[4-byte LE length][protobuf bytes]over SSH stdin. - Server stdin reader task decodes
ClientMessage, dispatches toServerModel::handle_messageviaModelSpawner. handle_messagematchesRunCommand: delegates toLocalCommandExecutor::execute_local_command()viactx.spawn_abortable. The returnedSpawnedFutureHandleis stored inin_progressso the client can cancel it viaAbort.on_resolvecallback receivesOutput, removes the entry fromin_progress, constructsRunCommandResponse { stdout, stderr, exit_code }, sends viaresponse_tx.try_send(response).- Server stdout writer task encodes
ServerMessage→[4-byte LE length][protobuf bytes]over SSH stdout. - Client reader task decodes
ServerMessage, looks uprequest_idinpending_requests, resolves the oneshot. run_command()receivesRunCommandResponse, returns to executor.- Executor translates:
exit_code == Some(0)→CommandExitStatus::Success, elseFailure. Wraps inCommandOutput { stdout, stderr, status, exit_code }.
Server connects after executor creation
- Executor is created, but
self.client.read()returnsNonebecause the manager hasn't connected this session yet. - Completions calls to
execute_commandreturn emptyFailureresults (logged). - Manager completes connection for this session, emits
SessionConnected { session_id, host_id }. - Subscription handler fires on main thread (matches on
session_id) → clonesArc<RemoteServerClient>from manager →executor.set_client(client). - Subsequent
execute_commandcalls read the client from theRwLock.
Disconnection
- SSH dies or server crashes for this session →
RemoteServerClientreader task hits EOF → clearspending_requests(in-flight calls getResponseChannelClosed). - Manager's subscription on the client fires
ClientEvent::Disconnected→mark_session_disconnected(session_id)→ emitsSessionDisconnected { session_id, host_id }. - Subscription handler fires on the executor (matches on
session_id) →executor.clear_client()→ writesNoneinto theRwLock. - Subsequent
execute_commandcalls readNone→ return clean emptyFailureresult (logged).
Reconnection
- Manager reconnects this session (trigger out of scope for this spec) — creates a new
RemoteServerClientfor this session. - Manager emits
SessionConnected { session_id, .. }→ subscription handler fires → clones newArc<RemoteServerClient>from manager →executor.set_client(new_client). - Subsequent
execute_commandcalls read the new client from theRwLock. Completions resume.