Stub Disposal Contract in Cloudflare Computer: Preventing Memory Leaks in Long-Lived Sessions
The stub disposal contract is an explicit agreement between Cap'n Web (capnweb) RPC peers that requires manual disposal of remote stub objects via Symbol.dispose to prevent unbounded memory growth.
Capnweb does not garbage collect remote stubs automatically. A stub referenced on one side keeps the peer's resources alive until the WebSocket closes or the owning side explicitly releases it. In long-lived sessions—such as agent loops or frequently invoked Workers handlers—neglecting this contract causes memory leaks that eventually trigger OOM failures.
Understanding the Two Stub Boundaries
The Cloudflare Computer repository defines two distinct boundaries where stubs cross, each with specific disposal rules.
Worker/DO ↔ computerd (Capnweb over WebSocket)
The sync driver in packages/rpc/src/sync-driver.ts manages RPC calls like fetchChanges, pushObjects, and exec streams. Callers must bind result envelopes to using variables or call result[Symbol.dispose]() after draining streams. The driver wraps disposal in finally blocks using maybeDispose(fetchResult) to guarantee cleanup on errors.
Worker ↔ DO (Workers-RPC)
Workers obtain a WorkspaceStub via env.COMPUTERD.get(id).getWorkspace(). This parent stub includes child objects (fs, runtime, runtime.exec handles). Disposing the parent cascades to children on the DO side. Individual child stubs cannot be disposed independently—they live only as long as the parent workspace stub.
How to Prevent Memory Leaks
Use using Declarations for Automatic Cleanup
The using keyword ensures Symbol.dispose executes when the block exits. This is the recommended pattern for all stub acquisitions.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const id = env.COMPUTERD.idFromName("user-123");
using ws = await env.COMPUTERD.get(id).getWorkspace(); // ← disposes ws
using execHandle = await ws.runtime.exec("npm test"); // ← disposes handle
const result = await execHandle.result(); // plain data
return Response.json({ exitCode: result.exitCode });
},
} satisfies ExportedHandler<Env>;
This pattern appears in docs/11_lifecycle.md (lines 202–227) as the canonical implementation.
Manual Disposal for Streaming Results
When using is unavailable—such as direct client.sync.fetchChanges calls—dispose manually in a finally block.
const { stream } = await client.sync.fetchChanges({ after });
try {
for await (const entry of stream) {
// process entry …
}
} finally {
(stream as any)[Symbol.dispose]?.();
}
The maybeDispose helper in packages/rpc/src/sync-driver.ts (lines 39–45) implements this fallback pattern.
Enable Stub Tracking in Development
Set CAPNWEB_TRACK_STUBS=1 to expose live stub counts. Import stubSnapshot from @cloudflare/computer-rpc/debug or query the computerd endpoint.
import { stubSnapshot } from "@cloudflare/computer-rpc/debug";
if (process.env.CAPNWEB_TRACK_STUBS) {
console.log("Live stubs:", stubSnapshot());
}
The computerd CLI exposes this at GET /__computerd/stubs (see packages/computerd/src/cli/computerd.ts, lines 236–247).
Validate with the Stub Soak Test
packages/computer/tests/stub-soak.test.ts (lines 31–71) creates and destroys stubs repeatedly while asserting that stubSnapshot() returns to baseline. Run this test to verify your disposal patterns under sustained load.
// Simplified soak test structure from the source
import { stubSnapshot } from "@cloudflare/computer-rpc/debug";
test("no stub leaks after repeated workspace acquisitions", async () => {
const baseline = stubSnapshot();
for (let i = 0; i < 1000; i++) {
using ws = await getWorkspace(); // proper disposal
await ws.runtime.exec("echo test");
}
expect(stubSnapshot()).toBe(baseline);
});
Key Implementation Files
| File | Purpose |
|---|---|
docs/11_lifecycle.md |
Documents the stub disposal contract and its two boundaries |
packages/rpc/src/sync-driver.ts |
Implements maybeDispose, drives sync ticks, guarantees envelope cleanup |
packages/computerd/src/cli/computerd.ts |
Exposes /__computerd/stubs debugging endpoint |
packages/computer/tests/stub-soak.test.ts |
Validates leak-free operation under load |
packages/computer/src/shell.ts |
Example driver with using bindings for streaming results |
packages/computer/src/debug.ts |
Provides stubSnapshot() for leak detection |
Summary
- The stub disposal contract requires explicit cleanup—capnweb has no automatic garbage collection for remote stubs.
- Two boundaries exist: Worker/DO↔computerd (capnweb) and Worker↔DO (Workers-RPC), with cascading disposal on the parent stub.
- Prefer
usingdeclarations for automatic disposal of workspace stubs and exec handles. - Use
finallyblocks with manualSymbol.disposecalls whenusingis unavailable. - Enable
CAPNWEB_TRACK_STUBSand run soak tests to detect leaks during development.
Following these patterns ensures that every remote stub is released promptly, keeping long-lived sessions stable and memory-bounded.
Frequently Asked Questions
What happens if I forget to dispose a stub?
The remote resource remains allocated on the peer until the WebSocket connection closes. In long-lived sessions, repeated unclosed stubs accumulate, causing memory growth that eventually exhausts available heap and triggers OOM failures.
Can I dispose child stubs like ws.runtime.exec independently?
No. Child stubs such as fs, runtime, and runtime.exec handles are not independently disposable. Only the parent WorkspaceStub supports disposal, which cascades to all its children on the DO side according to the implementation in docs/11_lifecycle.md.
Does using work with streaming responses?
Yes. The using declaration works with any object implementing Symbol.dispose, including streaming result envelopes from fetchChanges or exec calls. The stream itself must be fully drained or explicitly closed before the using block exits.
How do I verify my code has no stub leaks?
Enable CAPNWEB_TRACK_STUBS=1, import stubSnapshot from @cloudflare/computer-rpc/debug, and assert that stub counts return to baseline after operations. For comprehensive validation, run the official soak test in packages/computer/tests/stub-soak.test.ts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →