Skip to content

[BUG] Clients Sharing a WebSocket Transport Mix Up Their Responses #594

Description

@phroi

Hello folks at CKB DevRel,

I noticed that two clients sharing one JsonRpcTransportWebSocket can get each other's responses. Sharing a transport is what ClientPublicTestnet.new is for: "Creates a Client that borrows an existing Transport."

The Cause

Each client sends its requests through its own requestor. Then:

So two requestors both send a request number 0. While the first one is still waiting, the second one replaces it in the table:

Minimal Reproduction

Requestors a and b stand in for two clients. A local server stands in for the node.

import { ccc } from "@ckb-ccc/core";
import { WebSocketServer } from "ws";

// A server that answers each request with its method name after 100 ms.
new WebSocketServer({ port: 18114 }).on("connection", (socket) =>
  socket.on("message", (raw) => {
    const { id, method } = JSON.parse(raw.toString());
    setTimeout(() => socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: method })), 100);
  }),
);

// Requestors sharing one transport with a 1 s timeout.
const transport = ccc.JsonRpcTransportWebSocket.open("ws://127.0.0.1:18114", 1000).value;
const request = (method: string) => ccc.RequestorJsonRpc.new({ transport }).request(method, []);

await request("connect"); // Open the socket first.
for (const method of ["a", "b"]) {
  request(method).then(
    (result) => console.log(method, "got", result),
    (err) => console.log(method, "failed:", err.message),
  );
  await new Promise((resolve) => setTimeout(resolve, 10)); // Send `a` before `b`.
}
setTimeout(() => (console.log("3 s passed"), process.exit()), 3000);

Behavior

b got a
3 s passed

b got the response meant for a. Meanwhile a never settles, not even after three times its 1 s timeout.

Environment

  • @ckb-ccc/core@1.23.0, ws@8.22.0 for the local server
  • Node.js 24.18.0, Linux

Keep up the Great Work,
Phroi %43

Activity

  1. Hanssen0 commented on Oct 8, 2026

    @Hanssen0
    Member

    Thank you for sharing your findings!

    For this issue, I assume that developers doing this should be able to keep this behaviour in mind. That said, if they reuse a websocket connection with two async requestors, it's obvious to predict that such a similar problem would happen. A possible solution is to randomise the request ID to lower the collision possibility to nearly none, but I think making this change for this uncertain scenario does not seem worth it at the moment.

    As for the "Creates a Client that borrows an existing Transport." claim, perhaps more detailed documentation could reduce the likelihood of misunderstandings. There is nothing inherently wrong with the description itself: only transferring ownership constitutes a true "move", while other scenarios merely involve "borrowing an existing instance". But it can probably confuse developers unfamiliar with ownership semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    wontfixThis will not be worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions