9.2 KiB
TECH.md — Remote Server: Error Handling & Abort
Linear: APP-3987
1. Problem
The remote server client/server protocol needs robust error handling for malformed messages and a mechanism for the client to cancel in-progress server requests. Specifically:
- Malformed messages — if the request ID is parseable, return an error for that request; otherwise drop silently
- Client-side timeout — abort requests that don't receive a response within 2 minutes
- Abort notification — a new message type allowing the client to cancel in-progress server requests
- Message too big — already enforced at the sending layer by
write_message - Unexpected responses — already handled (drop + log warning)
- Stream errors — no special handling needed
2. Relevant Code
crates/remote_server/proto/remote_server.proto—ClientMessage/ServerMessageenvelopes,ErrorCodeenum,ErrorResponsecrates/remote_server/src/protocol.rs—ProtocolErrorenum,read_message/write_messagewith length-delimited framing,MAX_MESSAGE_SIZE(64 MB),RequestIdnewtypecrates/remote_server/src/server_model.rs—ServerModelsingleton with stdin reader loop, stdout writer task,handle_messagedispatchcrates/remote_server/src/client.rs—RemoteServerClientwithpending_requests: DashMap<RequestId, oneshot::Sender<ServerMessage>>, background reader/writer tasks,ClientErrorenumcrates/remote_server/src/client_tests.rs— existing tests usingtokio::io::duplexfor in-memory streamscrates/remote_server/src/protocol_tests.rs— round-trip and edge-case protocol tests
3. Current State
The protocol layer handles basic I/O errors with a recoverable/fatal classification:
ProtocolError::Decodeis read-recoverable (payload bytes consumed, stream aligned), but the request ID is lost — no error response can be sent back.ProtocolError::MessageTooLargeis enforced on write viawrite_message. On read, it's fatal because the oversized payload isn't consumed.- The server's stdin reader loop skips recoverable errors and breaks on fatal ones.
The client's pending_requests map uses oneshot::Sender<ServerMessage>. There's no timeout — requests wait indefinitely. There's no way for the client to cancel server-side work.
The server has no concept of in-progress request tracking — handle_message is synchronous today (only Initialize exists). No abort mechanism exists.
4. Proposed Changes
4.1. Proto: Add Abort notification
Add to remote_server.proto:
message Abort {
string request_id_to_abort = 1;
}
Add Abort abort = 3 to ClientMessage.oneof message.
Abort is a notification (fire-and-forget) — no server response expected. The request_id on the ClientMessage envelope is still set (for logging/tracing) but the client does not register a pending request for it.
4.2. Protocol layer: Extract request ID from raw bytes on decode failure
Update protocol.rs:
- Change
ProtocolError::Decodeto carry the extracted request ID:Decode(prost::DecodeError, Option<RequestId>). The extraction is done insideread_messageitself using a privatetry_extract_request_idhelper, so callers get theOption<RequestId>directly without needing to handle raw bytes. - The private
try_extract_request_id(buf: &[u8]) -> Option<String>parses only protobuf field 1 (string) using manual wire-format parsing: checks for tag byte0x0a(field_number=1, wire_type=2), decodes the varint length, and extracts the UTF-8 string. Stops immediately after field 1, so corruption in later bytes does not affect extraction.
4.3. Server: Error response for malformed messages with parseable request ID
Update the stdin reader loop in ServerModel::new:
- On
ProtocolError::Decodewith raw buffer, calltry_extract_request_id. If a request ID is found, send anErrorResponse { code: INVALID_REQUEST }with that request ID throughresponse_tx. If no request ID can be extracted, drop the message (log warning, as already done).
4.4. Client: Change oneshot type to Result<ServerMessage, ClientError>
Change pending_requests from DashMap<RequestId, oneshot::Sender<ServerMessage>> to DashMap<RequestId, oneshot::Sender<Result<ServerMessage, ClientError>>>.
- Reader task sends
Ok(msg)for normal responses. - On
ProtocolError::Decodewith raw buffer: trytry_extract_request_id. If found and a pending request exists, sendErr(ClientError::Protocol(...))through the oneshot. If not found, drop (already logged). send_requestunwraps the double-Result:rx.await.map_err(|_| ClientError::ResponseChannelClosed)??.- Move
ErrorResponsehandling intosend_request: after receiving anOk(msg), check if the message is anErrorResponseand convert toErr(ClientError::ServerError). Callers likeinitialize()then only match on success variants +_ => UnexpectedResponse.
4.5. Client: 2-minute request timeout
Update send_request to wrap the oneshot rx.await with tokio::time::timeout(Duration::from_secs(120), rx). On timeout:
- Remove the request from
pending_requests - Send an
Abortmessage for the timed-out request ID (viasend_notification) - Return
ClientError::Timeout
Add ClientError::Timeout to the ClientError enum.
4.6. Client: send_notification helper
Add a private fn send_notification(&self, msg: ClientMessage) that sends through outbound_tx without registering a pending request. Used by timeout-triggered abort.
4.7. Server: In-progress request tracking & abort handling
Add in_progress: HashMap<RequestId, tokio::sync::oneshot::Sender<()>> to ServerModel. Each entry holds a cancellation signal sender.
- When
handle_messagestarts processing a request that may be long-running, it inserts a cancellation receiver. The background work checks the receiver for cancellation. - For synchronous/fast requests like
Initialize, no tracking needed. - On receiving
Abort { request_id_to_abort }: look up the request ID inin_progress, if found send the cancel signal and remove the entry. If not found, no-op. - When a request completes (response sent), remove it from
in_progress.
Abort has pure notification semantics — the server does not send any response for abort messages.
Note: today all handlers are synchronous, so abort is mostly a no-op. This is future-proofing for long-running requests (file tree, code review, etc.). The infrastructure should be wired up now.
5. End-to-End Flow
Malformed message with parseable request ID (server-side)
- Client sends a corrupted protobuf where field 1 (request_id) is intact but other fields are malformed
- Server stdin reader calls
read_client_message→ProtocolError::Decode(err, raw_buf) - Reader calls
try_extract_request_id(&raw_buf)→Some("abc-123") - Reader sends
ErrorResponse { code: INVALID_REQUEST, request_id: "abc-123" }throughresponse_tx - Server stdout writer sends the error response to the client
- Client reader task resolves the pending request for "abc-123" with the error
Client request timeout → abort
- Client calls
send_requestwhich registers a oneshot inpending_requestsand sends theClientMessage - Server receives the request and begins (potentially long-running) work
- After 2 minutes with no response,
tokio::time::timeoutfires insend_request - Client removes the request from
pending_requests - Client sends
ClientMessage { request_id: new_uuid, message: Abort { request_id_to_abort: original_id } }viasend_notification - Client returns
ClientError::Timeoutto the caller - Server receives the
Abort, looks uporiginal_idinin_progress, sends the cancel signal if found
6. Risks and Mitigations
ProtocolError::Decodevariant change is a breaking API change — all existing match arms onProtocolError::Decode(_)must be updated toProtocolError::Decode(_, Some(...))/ProtocolError::Decode(_, None). This is internal-only and caught at compile time.try_extract_request_idfalse negatives — if corruption hits the request_id field bytes, extraction fails and we fall back to dropping the message. This is the correct behavior since we genuinely can't correlate the error.- Oneshot type change ripples — changing from
oneshot::Sender<ServerMessage>tooneshot::Sender<Result<ServerMessage, ClientError>>affects the reader task andsend_request. The existing test mock servers need updating since they constructServerMessagedirectly.
7. Testing and Validation
- Protocol test: Verify
try_extract_request_idextracts the ID from corrupted payloads where field 1 is intact, and returnsNonewhen field 1 is also corrupt. - Server malformed message test: Send a corrupted protobuf with a valid request_id prefix → assert server responds with
ErrorResponse { code: INVALID_REQUEST }.
8. Follow-ups
- Client timeout and abort tests (mock server that never responds,
tokio::time::pause()) - Abort cancellation tests (verify server cancels in-progress work)
- Domain-specific error codes (e.g.
FILE_NOT_FOUND) as the protocol grows - Structured log delivery over the protocol as an alternative to stderr streaming