alloy-rs / alloy-rs/alloy

[Bug] WebSocket transport splits JSON-RPC batches into separate messages

Open
#4,085 1 comment 0 reactions 0 assignees View on GitHub
bug
Dominant language
Rust
Stars
1.3k
Forks
668
Avg merge
2d 2h
Merged PRs (30d)
29

Description

### Component

provider, pubsub, rpc, transports

### What version of Alloy are you on?

alloy 2.1.1 (alloy-rpc-client, alloy-pubsub, and alloy-transport-ws all v2.1.1)

### Operating System

macOS (Apple Silicon)

### Describe the bug

`RpcClient::new_batch()` builds a `RequestPacket::Batch`, but the pub-sub frontend used by the
WebSocket transport splits that packet into independent requests. A WebSocket server therefore
receives one text message per request object instead of one text message containing the JSON-RPC
batch array.

The HTTP transport preserves the packet and serializes it into one POST body. The transport chosen
for the same `new_batch()` API therefore changes whether a batch exists on the wire.

This report is not asking for transactional or ordered execution of a JSON-RPC batch. The missing
property is a single wire message on one connection.

### Expected behavior

The WebSocket transport should serialize a `RequestPacket::Batch` as one WebSocket text message
containing a JSON array, matching the packet passed by `BatchFuture` and the HTTP transport's wire
behavior.

If splitting is intentional or required by the pub-sub architecture, the transport-specific
behavior should be explicit in the `new_batch()` / `BatchRequest` documentation and an API should
exist for callers that require a real JSON-RPC batch on the wire.

### Actual behavior

Each batch element is converted to a separate in-flight request and sent independently. The batch
future joins the individual responses locally, making the caller-visible result look batched even
though the wire traffic is not.

This also means one logical batch can straddle a reconnect: completed elements may have run on the
old WebSocket connection while pending elements are reissued on the new connection. With a
load-balanced endpoint, those requests can reach different backend nodes.

### Reproduction

`Cargo.toml`:

```toml
[package]
name = "alloy-ws-batch-repro"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"

[dependencies]
alloy-rpc-client = { version = "=2.1.1", features = ["ws"] }
alloy-transport-ws = "=2.1.1"
futures-util = "0.3"
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
tokio-tungstenite = "0.28"
```

`src/main.rs`:

```rust
use alloy_rpc_client::ClientBuilder;
use alloy_transport_ws::WsConnect;
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::time::Duration;
use tokio::{net::TcpListener, time::timeout};
use tokio_tungstenite::{accept_async, tungstenite::Message};

#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();

let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = accept_async(stream).await.unwrap();
let mut frames = Vec::new();

for result in [11_u64, 22_u64] {
let message = timeout(Duration::from_secs(3), ws.next())
.await
.expect("timed out waiting for a WebSocket frame")
.expect("WebSocket closed before both requests arrived")
.expect("failed to read WebSocket frame");
let request: Value = serde_json::from_str(message.to_text().unwrap()).unwrap();
assert!(request.is_object(), "expected one request object, got {request}");

let response = json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": result,
});
ws.send(Message::Text(response.to_string().into())).await.unwrap();
frames.push(request);
}

frames
});

let client = ClientBuilder::default()
.pubsub(WsConnect::new(format!("ws://{addr}")))
.await
.unwrap();
let mut batch = client.new_batch();
let first = batch.add_call::<_, u64>("first", &()).unwrap();
let second = batch.add_call::<_, u64>("second", &()).unwrap();

batch.send().await.unwrap();
assert_eq!(first.await.unwrap(), 11);
assert_eq!(second.await.unwrap(), 22);

let frames = server.await.unwrap();
println!("frames received: {}", frames.len());
for (index, frame) in frames.iter().enumerate() {
println!("frame {} is_array={}: {frame}", index + 1, frame.is_array());
}
}
```

Observed output:

```text
frames received: 2
frame 1 is_array=false: {"id":0,"jsonrpc":"2.0","method":"first","params":null}
frame 2 is_array=false: {"id":1,"jsonrpc":"2.0","method":"second","params":null}
```

### Relevant source

- [`BatchFuture` passes a `RequestPacket::Batch` to the transport](https://github.com/alloy-rs/alloy/blob/v2.1.1/crates/rpc-client/src/batch.rs#L193-L199).
- [`PubSubFrontend::send_packet` splits the batch into individual `send` calls](https://github.com/alloy-rs/alloy/blob/v2.1.1/crates/pubsub/src/frontend.rs#L84-L92).
- [The HTTP transport serializes the complete `RequestPacket` in one POST](https://github.com/alloy-rs/alloy/blob/v2.1.1/crates/transport-http/src/reqwest_transport.rs#L38-L47).
- [Reconnect logic reissues each pending in-flight request separately](https://github.com/alloy-rs/alloy/blob/v2.1.1/crates/pubsub/src/service.rs#L69-L90).

### Duplicate search

I searched open and closed issues for `WebSocket batch`, `JSON-RPC batch websocket`, `new_batch ws`,
`pubsub batch requests`, and `RequestPacket::Batch` and did not find an existing report of this
transport behavior.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.