alloy-rs / alloy-rs/alloy

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

Đang mở
#4,085 1 bình luận 0 reaction 0 người được giao Xem trên GitHub
bug
Ngôn ngữ chính
Rust
Star
1.3k
Fork
668
Merge trung bình
2 ngày 1 giờ
Pull request đã merge (30 ngày)
29

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
rust
Lĩnh vực
api, networking
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Ít trao đổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
56/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.