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 using declarations for automatic disposal of workspace stubs and exec handles.
  • Use finally blocks with manual Symbol.dispose calls when using is unavailable.
  • Enable CAPNWEB_TRACK_STUBS and 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →