[Bug] WebSocket transport splits JSON-RPC batches into separate messages
- 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
Assessment
This issue has not been assessed yet.