20 KiB
TECH.md — Remote Server: Headless App + Message Transport Foundation
Linear: APP-3721
1. Problem
The remote_server crate needs to become a standalone binary that communicates with the Warp client over remote connections with length-delimited protobuf messages. In order to support future coding features like the file tree and code review pane, the remote server needs the warpui App to store and handle Entity/SingletonEntity models like RepositoryMetadataModel.
This spec covers the foundation: a shared protocol layer, a minimal request/response client, the headless warpui server runtime, and Initialize end-to-end validation.
2. Relevant Code
remote_server crate (current state)
remote_server/Cargo.toml— current deps:prost,tokio,prost-buildremote_server/src/lib.rs— library target re-exporting generated prost typesremote_server/proto/remote_server.proto—ClientMessage/ServerMessageenvelopes withInitialize/InitializeResponseremote_server/build.rs— prost codegen for the proto
Headless warpui App infrastructure
crates/warpui/src/platform/app.rs:68-80—AppBuilder::new_headless(callbacks, assets, test_driver)constructorcrates/warpui/src/platform/app.rs:107-155—AppBuilder::run(init_fn)wraps init_fn and enters the event loopcrates/warpui/src/platform/headless/app.rs—App::run()creates mpsc channel, marks main thread, entersevent_loop::run()crates/warpui/src/platform/headless/event_loop.rs— blockingfor event in receiver.iter()loop processingRunTask,RunCallback,Terminate; includes Ctrl-C handler viactrlc::set_handler
Entity/Model system
ui/src/core/entity.rs:39-54—Entitytrait (hastype Event) andSingletonEntitytrait (provideshandle()andas_ref())ui/src/core/app.rs:2060-2077—AppContext::add_singleton_model(build_model)registers a singletonui/src/core/app.rs:845-847—AppContext::background_executor()returns&Arc<Background>
ModelSpawner
ui/src/core/model/context.rs:442-466—ModelContext::spawner()creates aModelSpawner<T>(Send + Clone)ui/src/core/model/context.rs:592-624—ModelSpawner<T>definition;spawn(work).awaitdispatchesworkto main thread and returns the resultapp/src/ai/agent_sdk/driver.rs:890-1027—AgentDriver::run_internal: long async workflow usingModelSpawnerto step into the model at specific pointsapp/src/workspace/view/global_search/model.rs:77-178—GlobalSearch: background ripgrep task pushing result batches viaModelSpawner
No-op asset provider
ui/src/assets/mod.rs:5-11—impl AssetProvider for ()returns errors for all lookups
App termination
ui/src/core/app.rs:3998-4012—AppContext::terminate_app(mode, result)delegates to platformui/src/platform/mod.rs:282-292—TerminationModeenum:Cancellable,ForceTerminate,ContentTransferred
3. Current State
The current remote_server crate has:
- Proto definition for
ClientMessage/ServerMessagewithInitialize/InitializeResponse lib.rsre-exporting generated prost types viainclude!(concat!(env!("OUT_DIR"), "/remote_server.rs"))- No
main.rs, no binary entry point, no I/O code, no warpui dependency
4. Proposed Changes
4.1. Shared protocol.rs in the remote_server library
Create remote_server/src/protocol.rs and re-export from lib.rs.
Contents:
ProtocolErrorenum — covers I/O errors, decode failures, unexpected EOF, and message-too-largeread_message<M: prost::Message + Default>(reader) -> Result<M, ProtocolError>— reads[4-byte LE length][protobuf bytes], decodes intoMwrite_message<M: prost::Message>(writer, msg) -> Result<(), ProtocolError>— encodesM, writes[4-byte LE length][protobuf bytes]- Convenience wrappers:
read_client_message,write_client_message,read_server_message,write_server_messagethat specialize the generic helpers forClientMessageandServerMessage
Message size limit: read_message rejects payloads exceeding MAX_MESSAGE_SIZE (64 MB) with ProtocolError::MessageTooLarge after decoding the u32 length prefix but before allocating the payload buffer. This prevents OOM from a corrupted or adversarial length prefix. Since both read_client_message and read_server_message delegate to the generic read_message, the size check applies in both directions — protecting the server from oversized client requests and the client from oversized server responses.
The generic read_message/write_message take tokio::io::AsyncRead + Unpin / tokio::io::AsyncWrite + Unpin so both the server (stdin/stdout) and client (child process or SSH streams) can use them.
4.2. Minimal RemoteServerClient in the library
Create remote_server/src/client.rs and export from lib.rs.
Structure:
RemoteServerClientstruct owns:outbound_tx: async_channel::Sender<ClientMessage>— feeds the background writer taskpending_requests: Arc<DashMap<RequestId, oneshot::Sender<ServerMessage>>>— mapsrequest_idto response sender (shared with the reader task).
RequestId newtype: Introduce a RequestId(String) newtype in the remote_server library (e.g. in protocol.rs) wrapping the proto string request_id field. This provides type safety over raw strings and centralizes ID generation (RequestId::new() → Uuid::new_v4().to_string()). The proto field stays string; conversion happens at the serialization boundary. Use RequestId consistently in RemoteServerClient, ServerModel, and pending_requests.
- Constructor takes generic
reader: impl AsyncRead + Unpin + Send + 'staticandwriter: impl AsyncWrite + Unpin + Send + 'static, plus a handle to the background executor (or accepts atokio::runtime::Handle) - Spawns two background tasks:
-
Writer task: a dedicated background task spawned at construction time. The write half of the connection (
impl AsyncWrite) is moved into this task — no other code retains a reference. It pullsClientMessages fromoutbound_rxand writes each one viaprotocol::write_client_message.Callers never write to the stream directly. They only hold clones of
outbound_tx, which enqueue messages into the channel. The channel acts as a FIFO queue: concurrentsend_requestcalls are serialized into arrival order, and the writer task drains them one at a time. -
Reader task: reads
ServerMessages viaprotocol::read_server_messagein a loop, looks uprequest_idinpending_requests, sends response through the correspondingoneshot::Sender
-
Public API:
async fn initialize(&self) -> Result<InitializeResponse, ClientError>— generates arequest_id, sendsClientMessage { initialize }, awaits the correlated response- Private
async fn send_request(&self, msg: ClientMessage) -> Result<ServerMessage, ClientError>— generic request/response correlation ClientErrorenum covering disconnection, protocol errors, server-reported errors (ClientError::ServerError), and response timeout.
4.3. main.rs — headless App entry point
Create remote_server/src/main.rs. Add [[bin]] target in Cargo.toml and add warpui as a dependency.
fn main() -> anyhow::Result<()> {
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None)
.run(|ctx| { /* init_fn */ })?;
Ok(())
}
Key details:
AppCallbacks::default()— all fieldsNone, no custom callbacks neededBox::new(())— usesimpl AssetProvider for ()(no-op, returns errors for all lookups)- The headless
App::run()creates the mpsc event channel, marks the current thread as main, and enters the blocking event loop. TheBackgroundexecutor inside the App IS the tokio runtime — there is exactly one runtime in the process. - The headless warpui
Appinfrastructure is proven in production (the Oz CLI uses it viaAppBuilder::new_headless+add_singleton_model+ModelSpawner). It provides the full entity/model runtime with zero rendering overhead.
Logging:
Stdout is the wire transport — any stray output to stdout will corrupt the protocol and cause decode failures on the client. Logging must be configured to write exclusively to stderr before the App starts.
At the top of main(), before AppBuilder::new_headless:
env_logger::Builder::from_default_env()
.target(env_logger::Target::Stderr)
.init();
This ensures all log::info!, log::error!, etc. macros route to stderr.
Client-side stderr streaming: The client reads the server's stderr in a background task to surface server logs locally. It spawns a task that calls read_line on the child's stderr in a loop, forwarding each line to the client's own logging. Stderr streaming is the always-on fallback — it requires no protocol changes and continues to flow even when the protocol itself is broken, which is critical for debugging transport-level issues.
Inside init_fn:
- Create a typed response channel:
async_channel::unbounded::<ServerMessage>() - Register
ServerModelas a singleton and obtain aModelSpawnerin the same step. - Spawn a background stdin reader task on
ctx.background_executor():- Wraps
tokio::io::stdin()in aBufReader - Loops:
read_client_message(&mut reader).await→spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await - Handles errors:
Err(ModelDropped)fromspawner.spawn()by breaking out of the loop (this means theServerModelwas dropped during shutdown — no further messages should be processed)- Recoverable errors (log a warning and continue to the next message): errors where the stream is still correctly positioned at the next message boundary. Example:
ProtocolError::Decode— the payload bytes were already consumed, so the next read starts at a valid length prefix. - Fatal errors (break and begin shutdown): errors where the stream is dead or misaligned. Examples:
ProtocolError::UnexpectedEof(client disconnected),ProtocolError::Io(broken pipe, connection reset),ProtocolError::MessageTooLarge(payload not consumed, stream position is invalid). - On fatal error: dispatches
spawner.spawn(|_, ctx| ctx.terminate_app(TerminationMode::ForceTerminate, None)). This is best-effort (let _ =) since the model may already be gone.
- Wraps
- Spawn a background stdout writer task on
ctx.background_executor():- Wraps
tokio::io::stdout()in aBufWriter - Receives
ServerMessages from theasync_channel::Receiver - Calls
protocol::write_server_message(&mut writer, msg).awaitfor each - Exits naturally when the response channel closes (all senders dropped)
- Wraps
app/src/lib.rs stays thin: boot the headless app and register ServerModel.
It is called from the WorkerCommand::RemoteServer dispatch in app/src/lib.rs, which
returns early before the full app initialization path — identical to how TerminalServer
and other worker commands are structured.
4.4. ServerModel — remote-side main-thread orchestrator
Create remote_server/src/server_model.rs.
pub struct ServerModel {
response_tx: async_channel::Sender<ServerMessage>,
}
impl Entity for ServerModel {
type Event = ();
}
impl SingletonEntity for ServerModel {}
Responsibilities:
- Holds the typed response sender
- Exposes
handle_message(&mut self, msg: ClientMessage, ctx: &mut ModelContext<Self>)— called by the background stdin reader viaModelSpawner - Dispatches on
msg.message(theoneofvariant):Initialize→ constructsInitializeResponse { server_version }fromChannelState::app_version()(falling back toenv!("CARGO_PKG_VERSION")in dev builds whereGIT_RELEASE_TAGis unset), wraps inServerMessage { request_id: msg.request_id, message: Some(...) }, sends viaself.response_txNone(missing variant) → sendsErrorResponse { code: INVALID_REQUEST, message }back to the client- Future message types will be added as new
oneofvariants in the proto and new match arms here
Error responses: The proto defines a shared ErrorResponse message (with an ErrorCode enum and a human-readable message string) as a variant in ServerMessage.oneof. This follows the JSON-RPC pattern: one error shape shared across all request types, with a machine-readable code for programmatic handling. The initial codes are INVALID_REQUEST and INTERNAL; domain-specific codes (e.g. FILE_NOT_FOUND) can be added as the protocol grows. On the client side, ErrorResponse maps to ClientError::ServerError { code, message }.
- Dispatches to future child models via
ctx.update_model(...), subscriptions, and emitted events — never ad-hoc cross-thread calls
Design boundary: Transport loops and protobuf byte encoding stay outside this model. ServerModel receives and sends typed Rust structs, not raw bytes.
4.5. Design Decision: ModelSpawner vs spawn_stream_local
Two warpui primitives could bridge background I/O to main-thread model context:
ModelSpawner (chosen): The background stdin reader task holds a ModelSpawner<ServerModel> and calls spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await for each decoded message. The transport loop is explicit code we own — it controls pacing, handles EOF, and manages shutdown. The model is a passive handler that doesn't know where messages come from.
- Precedent:
AgentDriver::run_internal(app/src/ai/agent_sdk/driver.rs:890) usesModelSpawnerfor a long async workflow.GlobalSearch(app/src/workspace/view/global_search/model.rs:77) uses it for a background producer pushing results. - Advantage: transport-level concerns (reconnect, backpressure, batching, error recovery) stay in the transport loop, not in model callbacks. EOF handling is a simple
break+ terminate dispatch.
spawn_stream_local (considered, not chosen): The model would call ctx.spawn_stream_local(request_rx, on_item, on_done) during construction. Each item is delivered to an on_item callback; on_done fires on channel close.
- Precedent:
BulkFilesystemWatcher(watcher/src/lib.rs:163) uses this for OS file events. - Tradeoff: simpler setup for pure event-consumption, but the model owns the ingestion lifecycle. Transport-level logic (reconnect, rate-limiting) would need to live inside model callbacks.
We chose ModelSpawner because the remote server's transport layer will likely grow (protocol versioning, multiplexed streams) and keeping that logic in an explicit background loop is easier to extend.
4.6. Cargo.toml changes
Add to remote_server/Cargo.toml:
warpuidependency (workspace) — for headless App, Entity, ModelContext, ModelSpawneranyhow(workspace) — error handling in maintokiofeatures: addio-stdfor stdin/stdout accessasync-channel(workspace) — for all async channels (outbound client channel, server response channel). Avoidtokio::sync::mpscfor warpui-layer code.log(workspace) — structured loggingenv_logger(workspace) — stderr-only log outputdashmap(workspace) — for lock-free concurrent request tracking inRemoteServerClientthiserror(workspace) — forProtocolErrorandClientErrorderive
5. End-to-End Flow
Initialize handshake (client → server → client)
- Client calls
RemoteServerClient::initialize():- Generates a UUID
request_id - Constructs
ClientMessage { request_id, message: Initialize {} } - Registers a
oneshot::Senderinpending_requestskeyed byrequest_id - Sends the message through
outbound_txto the writer task
- Generates a UUID
- Client writer task receives the
ClientMessage, callsprotocol::write_client_message(stdout, msg):- Encodes via
prost::Message::encode - Writes
[4-byte LE length][protobuf bytes]to the stream
- Encodes via
- Server stdin reader task calls
protocol::read_client_message(stdin):- Reads 4 bytes → interprets as LE u32 length
- Reads
lengthbytes → decodes viaprost::Message::decodeintoClientMessage - Dispatches to main thread:
spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await
- ServerModel::handle_message (main thread):
- Matches
Initializevariant - Constructs
ServerMessage { request_id, message: InitializeResponse { server_version } } - Sends via
self.response_tx.send(response)
- Matches
- Server stdout writer task receives the
ServerMessage, callsprotocol::write_server_message(stdout, msg):- Encodes and writes
[4-byte LE length][protobuf bytes]to stdout
- Encodes and writes
- Client reader task calls
protocol::read_server_message(stdin):- Decodes
ServerMessage, looks uprequest_idinpending_requests - Sends the response through the
oneshot::Sender
- Decodes
- Client
initialize()awaits the oneshot, receivesInitializeResponse { server_version }
Shutdown (stdin EOF)
- Server stdin reader task's
read_client_messagereturns an error (EOF or broken pipe) - Reader loop breaks
- Reader dispatches
spawner.spawn(|_, ctx| ctx.terminate_app(TerminationMode::ForceTerminate, None)) - The headless event loop receives
AppEvent::Terminate(ForceTerminate)and breaks - Dropping all response senders closes the response channel; stdout writer task exits
- On the client side, the reader task sees EOF on its stream, notifies pending requests of disconnection, and the client tears down
6. Risks and Mitigations
- Client request/response matching: Responses can arrive out of order once the server handles multiple message types concurrently. Mitigation: track in-flight requests by
request_idwith aDashMap<RequestId, oneshot::Sender> - warpui compile footprint: Pulling in
warpuibrings transitive deps (fonts, rendering stubs). These are dead code in the headless binary — same tradeoff as the Oz CLI. No runtime cost, only compile time. - Main thread serialization: All typed request handling runs on the main thread via the event loop. Handlers should be fast (in-memory dispatch and model coordination). Heavy work (filesystem I/O, tree building) must be offloaded to background tasks via
ctx.spawn()orModelSpawner.
7. Testing and Validation
- Unit tests for
protocol.rs: Round-trip encode/decode forClientMessageandServerMessage. Test edge cases: zero-length messages, maximum length, malformed length prefix, truncated payload. - Unit tests for
RemoteServerClient: Use in-memorytokio::io::duplexstreams to simulate a server. Verifyinitialize()returns the expectedInitializeResponse. Verify correctrequest_idcorrelation. VerifyClientError::Disconnectedon stream close. - Integration test for
Initializeround trip: Spawnwarp remote-serveras a child process, create aRemoteServerClientover the child's stdin/stdout, callinitialize(), assertserver_versionis non-empty. The test lives inapp/tests/remote_server_tests.rs— because thewarpbinary is a[[bin]]target in theappcrate, cargo automatically builds it before running these tests. - Shutdown test: Send an
Initialize, then close the client's write end. Assert the server process exits cleanly (exit code 0). - Build validation:
cargo build -p warpproduces a binary with theremote-serversubcommand.cargo clippyandcargo fmtpass.
8. Follow-ups
- Feature-specific message types (file tree listing, filesystem watch events, code review context) as new
oneofvariants in the proto and newServerModelmatch arms. - SSH integration: the app wrapper on the local side that spawns the remote server binary over SSH and wraps a
RemoteServerClientaround the SSH channel. - Server lifecycle management: V0 terminates the server immediately on fatal stream errors. Future versions should better handle transient errors, client disconnects, and retries.
- Protocol-based log streaming: In addition to stderr, add structured log delivery over the protocol. The server would install a custom
loglayer that, alongside stderr, sends each log event through the response channel as aServerMessage.