alloy-rs / alloy-rs/alloy

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

オープン
#4,085 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る
bug
主要言語
Rust
スター
1.3k
フォーク
668
平均マージ
2日 1時間
マージ済み PR(30日)
29

説明

### 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.

コントリビューションガイド

コントリビューションガイドを開く

調査の方向性

Start with crates/rpc-client/src/batch.rs, crates/pubsub/src/frontend.rs, and crates/pubsub/src/service.rs, then run the supplied WebSocket reproduction to observe the separate frames. Compare this with crates/transport-http/src/reqwest_transport.rs and trace how RequestPacket::Batch reaches each transport. Done means the intended batch behavior is explicit and the WebSocket wire behavior matches the documented expectation, with reconnect handling accounted for.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
rust
領域
api, networking
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
静か
明瞭さ
おおむね明確
初心者へのやさしさ
56/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。