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>):
- Enable tracking; create two targets via
createSyncServer (rpcA, rpcB).
- 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.
- 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)
Describe the bug
The debug stub tracker in
@cloudflare/computer-rpc(packages/rpc/src/debug.ts) pairs onetrackStubper constructed server object with oneuntrackStubper[Symbol.dispose]call, floored at zero (debug.ts:39-51). But the documented serving pattern mounts the sameRpcTargeton every WebSocket connection (packages/rpc/src/server.ts:345-350acceptWebSocketSession; computerd and the package's own tests both do this), and capnweb disposes the session's main export on every session teardown (capnwebdist/index.js:2444-2446session abort disposes export hooks;:1125-1133disposeRpcTargetcallstarget[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/stubsinpackages/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
SyncRPCServerobjects reports 2.Steps to reproduce
Test inside
packages/rpc(runnpm test --workspace @cloudflare/computer-rpc -- <file>):createSyncServer(rpcA,rpcB).rpcAon a WebSocket server (acceptWebSocketSessionpattern); open and cleanly close two client sessions against it; make an RPC call on session 2 to proverpcAstill serves.Actual output:
Proposed fix
Make tracking identity-based instead of count-based: keep a module-level set of currently-tracked targets in
debug.ts;trackStubincrements the class counter only when the target is newly added,untrackStubdecrements only when the delete actually removed it, so per-session double-dispose becomes a no-op. Contained todebug.ts, preserves the "per-class live count" semantics, no change to the shared-target mounting pattern. A comment onacceptWebSocketSessionnoting that capnweb disposes the main export per session would keep future dispose-hook cleanup from being wired to a shared object.Environment
cloudflare/computerat76d9e75(currentmain), local checkoutnpm test --workspace @cloudflare/computer-rpcbaseline: 62/62 pass