Skip to content

WebSocket Support

The WebSocket module (src/platform/network/websocket.rs) provides an async client with automatic reconnection, exponential backoff, and a pending message buffer. It is feature-gated so the binary compiles cleanly without it. When you need real-time data (collaboration, live feeds, device telemetry), flip the feature on and connect.

For the broader network layer, see Architecture. For async patterns in GPUI, see the state management blog post.

The client depends on tokio-tungstenite with native TLS. Both are optional, gated behind the websocket feature flag. Add to Cargo.toml:

[dependencies]
tokio-tungstenite = { version = "0.24", optional = true, features = ["native-tls"] }
tokio-stream = { version = "0.1", optional = true }
futures-util = "0.3"
[features]
websocket = ["dep:tokio-tungstenite", "dep:tokio-stream"]

Then build with the feature enabled:

Terminal window
cargo run --features websocket

When the feature is off, a stub WebSocketClient compiles instead. It has the same method signatures but returns WebSocketError::FeatureDisabled on every call, so code that references the client compiles regardless of the feature flag.

The client tracks its status through a state machine:

pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Reconnecting { attempt: u8 },
Closed,
}

The transitions are linear: Disconnected to Connecting on the first attempt, then Connected when the handshake completes. If the stream ends, back to Disconnected, then Reconnecting { attempt } for each retry. When max_retries is exceeded, the state moves to Closed. Read client.state at any time to drive connection status indicators in your UI.

use crate::websocket::{WebSocketClient, ReconnectPolicy, MessageHandler};
let client = WebSocketClient::new("wss://example.com/ws".to_string());

The builder pattern lets you set a custom reconnection policy and message handler:

let policy = ReconnectPolicy {
max_retries: 10,
base_delay_ms: 1000,
max_delay_ms: Some(60_000),
};
let handler: MessageHandler = Box::new(|text: &str| {
tracing::info!(target: "app", "received: {text}");
});
let client = WebSocketClient::new("wss://example.com/ws".to_string())
.with_reconnect_policy(policy)
.on_message(handler);

MessageHandler is a type alias for Box<dyn Fn(&str) + Send + Sync>. The callback receives text messages only. Binary, ping, and pong frames are ignored by the read loop.

Controls how the client behaves when the connection drops:

pub struct ReconnectPolicy {
pub max_retries: u8,
pub base_delay_ms: u64,
pub max_delay_ms: Option<u64>,
}
FieldDefaultPurpose
max_retries5Total reconnection attempts before the client gives up and enters Closed state
base_delay_ms500Starting delay in milliseconds for exponential backoff
max_delay_msSome(30_000)Optional upper bound so backoff does not grow indefinitely

The delay formula is base_delay_ms * 2^attempt, capped at max_delay_ms. For the default policy the sequence is roughly 500ms, 1s, 2s, 4s, 8s before the client stops trying.

Call connect_loop() to start the main loop. This method connects, reads incoming messages until the stream closes, then retries according to the reconnection policy:

if let Err(e) = client.connect_loop().await {
tracing::error!("websocket terminated: {e}");
}

Inside the loop: state is set to Connecting (or Reconnecting { attempt } for retries), connect_async is called, and on success the stream is split into write and read halves. The write half is stored in an Arc<Mutex<Option<WriteHalf>>> so send() can access it. Any pending messages accumulated during disconnection are flushed before the read loop begins. The read loop calls on_message for each text frame. When the stream ends or errors, state returns to Disconnected and the next attempt starts. If max_retries is exceeded, state becomes Closed and an error is returned.

client.send("hello").await?;

Three things can happen:

  1. When connected, the message is written to the WebSocket write half immediately.
  2. When disconnected or reconnecting, the message is pushed into a pending buffer (up to 64 messages). When the next connection is established, flush_pending drains the buffer into the fresh write half.
  3. If the buffer is full (64 messages already pending), send returns WebSocketError::NotConnected and the message is dropped.

The pending buffer prevents message loss during brief disconnections. It is not a durable queue. If the client enters Closed state or you call close(), the buffer is cleared.

client.close().await?;

This sends a close frame, sets state to Closed, and clears the pending buffer. The client will not reconnect after close().

The module provides spawn_connect for wiring into GPUI’s async system:

use crate::websocket;
// During app init or in response to a user action:
let url = "wss://example.com/ws".to_string();
websocket::spawn_connect(url, cx);

spawn_connect creates a WebSocketClient, wraps connect_loop in cx.spawn, and detaches the task. Logging is routed through the gpui_starter::websocket target.

For more control (custom message handler, reconnect policy), spawn manually:

use crate::websocket::{WebSocketClient, ReconnectPolicy};
let url = "wss://example.com/ws".to_string();
cx.spawn(async move |cx| {
let mut client = WebSocketClient::new(url)
.with_reconnect_policy(ReconnectPolicy {
max_retries: 10,
base_delay_ms: 1000,
max_delay_ms: Some(30_000),
})
.on_message(Box::new(|text| {
tracing::info!("ws message: {text}");
}));
cx.background_executor()
.spawn(async move {
if let Err(e) = client.connect_loop().await {
tracing::error!("websocket error: {e}");
}
})
.await
}).detach();

This follows the same pattern used by connectivity::check_now and tasks::start_demo_task in gpui-starter: cx.spawn creates a GPUI-managed async context, and the I/O work runs on the background executor.

pub enum WebSocketError {
Connection(String),
Send(tokio_tungstenite::tungstenite::Error), // feature-gated
Close(tokio_tungstenite::tungstenite::Error), // feature-gated
NotConnected,
FeatureDisabled,
}
VariantWhen it occurs
Connectionmax_retries exceeded, connection URL invalid, TLS handshake failed
SendWriting to the WebSocket stream failed
CloseSending the close frame failed
NotConnectedsend() called while disconnected and the pending buffer is full
FeatureDisabledAny method called when the websocket feature is off

All configuration is done through Rust code at client construction time. There are no config file entries or environment variables.

SettingDefaultWhere to set
WebSocket URL(required)WebSocketClient::new(url)
Max retries5ReconnectPolicy::max_retries
Base delay500msReconnectPolicy::base_delay_ms
Max delay30sReconnectPolicy::max_delay_ms
Message handlerNone.on_message(handler)
Pending buffer size64Hardcoded (MAX_PENDING_MESSAGES)

All log statements use the gpui_starter::websocket target. Enable trace-level logging to see connection attempts, reconnection delays, and pending buffer flushes:

RUST_LOG=gpui_starter::websocket=trace cargo run --features websocket

For general debugging techniques including log filtering, see the debugging techniques blog post.

The reconnect policy and connection state machine have unit tests in src/platform/network/websocket.test.rs. These tests run without the websocket feature enabled because they only exercise the types and the stub client.

Test cases:

  • reconnect_delay_increases_exponentially: verifies backoff growth across attempts.
  • reconnect_delay_respects_cap: verifies max_delay_ms clamps the delay.
  • stub_client_returns_error_without_feature: verifies the stub surfaces FeatureDisabled.

For integration testing with a real server, see Testing for the GPUI test harness setup. You can point the client at a local test server and assert on the message handler callback.