Skip to content

rpc: stub leak tracker under-counts because capnweb disposes the shared server target once per session #58

Description

@rajpreetcodes

Describe the bug

The debug stub tracker in @cloudflare/computer-rpc (packages/rpc/src/debug.ts) pairs one trackStub per constructed server object with one untrackStub per [Symbol.dispose] call, floored at zero (debug.ts:39-51). But the documented serving pattern mounts the same RpcTarget on every WebSocket connection (packages/rpc/src/server.ts:345-350 acceptWebSocketSession; computerd and the package's own tests both do this), and capnweb disposes the session's main export on every session teardown (capnweb dist/index.js:2444-2446 session abort disposes export hooks; :1125-1133 disposeRpcTarget calls target[Symbol.dispose]()).

Result: a shared target constructed once but serving N sessions gets untracked N times. The zero floor then silently absorbs the decrements that belong to other live instances of the same class, so the snapshot reports zero while objects are provably alive. The advertised use of this surface is exactly leak detection (package README, GET /__computerd/stubs in packages/computerd/src/cli/computerd.ts:236, and the soak scripts): any reconnect during a soak run under-counts and can mask a real leak, the exact failure mode the tool exists to catch.

Secondary hazard: [Symbol.dispose] fires per-session on an object that keeps serving later sessions (verified: the target successfully served an RPC after its counter had already dropped). Any real cleanup wired into these dispose hooks later would fire prematurely.

Expected behavior

The per-class live count reflects constructed-and-not-yet-final-disposed server objects. Closing a session against a shared target does not decrement the count for other live instances; the snapshot for two live SyncRPCServer objects reports 2.

Steps to reproduce

Test inside packages/rpc (run npm test --workspace @cloudflare/computer-rpc -- <file>):

  1. Enable tracking; create two targets via createSyncServer (rpcA, rpcB).
  2. Mount rpcA on a WebSocket server (acceptWebSocketSession pattern); open and cleanly close two client sessions against it; make an RPC call on session 2 to prove rpcA still serves.
  3. Read the snapshot.

Actual output:

[repro] live SyncRPCServer objects: 2 (rpcA still serving, rpcB in scope)
[repro] counter after 1st session close: 1
[repro] counter after 2nd session close: 0
[repro] full snapshot: {}
AssertionError: expected +0 to be 2

Proposed fix

Make tracking identity-based instead of count-based: keep a module-level set of currently-tracked targets in debug.ts; trackStub increments the class counter only when the target is newly added, untrackStub decrements only when the delete actually removed it, so per-session double-dispose becomes a no-op. Contained to debug.ts, preserves the "per-class live count" semantics, no change to the shared-target mounting pattern. A comment on acceptWebSocketSession noting that capnweb disposes the main export per session would keep future dispose-hook cleanup from being wired to a shared object.

Environment

  • cloudflare/computer at 76d9e75 (current main), local checkout
  • npm test --workspace @cloudflare/computer-rpc baseline: 62/62 pass
  • Node v22.18.0, npm 11.8.0, Windows 11 (platform-independent)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions