Understanding the Exec Handle Lifecycle and Why `using` Disposal Is Required

An ExecHandle represents a running command in the Computer framework that requires explicit disposal via using to prevent memory leaks, timer accumulation, and stale database records.

The Computer project by Cloudflare provides a distributed execution environment where processes run remotely and clients stream their output through handles. Managing these ExecHandle objects correctly is critical for production stability. This article examines the complete lifecycle of an exec handle based on the source code in packages/computerd/src/exec/runner.ts and explains why TypeScript 5.2's using statement is the recommended pattern for resource cleanup.

The Five Phases of an Exec Handle Lifecycle

Phase 1: Creation via Runner.exec()

When you spawn a command, the Runner class in packages/computerd/src/exec/runner.ts builds a new ExecHandle containing an id and a ReadableStream<ExecEvent>. Simultaneously, it registers an ExecRecord in Runner.records and starts the child process.

// runner.ts lines 104-119 (simplified)
exec(command: string, options?: ExecOptions): ExecHandle {
  const id = generateId();
  const record: ExecRecord = {
    id,
    process: spawn(command),
    log: new EventLog(),
    subscriber: new StreamController<ExecEvent>(),
    // ... timers and metadata
  };
  this.records.set(id, record);
  return {
    id,
    events: record.subscriber.readable,
  };
}

The handle itself is lightweight. The heavy state lives in the ExecRecord managed by the Runner.

Phase 2: Streaming Events

While the child process runs, stdout/stderr data and heartbeat events flow through two paths simultaneously:

  • Written to an in-memory EventLog for replay capability
  • Enqueued onto the handle's stream via record.subscriber.enqueue

This dual-write architecture in runner.ts lines 69-89 enables both live streaming and later re-attachment:

// runner.ts lines 69-89 (simplified)
onData(chunk: Buffer) {
  const event: ExecEvent = { name: "stdout", value: chunk };
  record.log.append(event);           // Persist for replay
  record.subscriber.enqueue(event);   // Push to live stream
}

Phase 3: Replay and Re-attachment

Consumers can call Runner.get(id, { after }) to obtain a fresh ExecHandle that replays events from the EventLog starting at any offset. This is implemented in runner.ts lines 254-275:

// runner.ts lines 254-275
get(id: string, options: { after?: number } = {}): ExecHandle | undefined {
  const record = this.records.get(id);
  if (!record) return undefined;
  
  const stream = record.log.replay(options.after);
  return { id, events: stream };
}

The EventLog decouples the child process lifetime from client connection lifetime—critical for resilient distributed systems.

Phase 4: Process Completion

When the child exits, the Runner finalizes the ExecRecord in runner.ts lines 191-210:

// runner.ts lines 191-210
onExit(code: number) {
  record.live = false;
  record.subscriber.close();
  clearTimeout(record.heartbeatTimer);
  clearTimeout(record.timeoutTimer);
  clearTimeout(record.killGraceTimer);
  // Note: record is NOT deleted from this.records yet
}

Critical observation: The ExecRecord remains in Runner.records after process exit. The handle's stream closes, but the bookkeeping structures persist until explicit disposal.

Phase 5: Disposal via Runner.disposeRecord()

The final cleanup occurs in runner.ts lines 11-22 through disposeRecord():

// runner.ts lines 11-22
disposeRecord(record: ExecRecord): void {
  this.records.delete(record.id);
  record.subscriber.close();
  clearTimeout(record.heartbeatTimer);
  clearTimeout(record.timeoutTimer);
  clearTimeout(record.killGraceTimer);
  this.db.exec("DELETE FROM exec WHERE id = ?", record.id);
}

This method:

  • Removes the record from the in-memory Map
  • Closes any live subscriber
  • Clears all pending timers
  • Deletes SQLite rows for persistent state

Why using Is Necessary for Proper Disposal

The Resource Leak Problem

Without explicit disposal, terminated commands leave behind:

  • Stale ExecRecord entries in Runner.records (unbounded Map growth)
  • Live timers that continue firing (CPU overhead, delayed process termination)
  • Open ReadableStream controllers (memory retention, backpressure issues)
  • SQLite rows in the exec table (database bloat)

In long-running services, this accumulation causes gradual resource exhaustion.

TypeScript 5.2 using Statement

The using statement (TC39 Stage 3, implemented in TypeScript 5.2) invokes [Symbol.asyncDispose] when a block exits, guaranteeing cleanup even during exceptions.

WorkspaceRuntimeExecHandleStub in packages/computer/src/stub.ts implements AsyncDisposable. The pattern works as follows:

  1. Stream closure – the controller closes and the host stops listening
  2. Bookkeeping cleanup – Runner.disposeRecord clears all internal state
  3. Exception safety – disposal runs regardless of control flow

Correct Usage Pattern

import { WorkspaceRuntimeExecHandle } from "./runtime/types.js";

async function runCommand(runner: Runner) {
  // `using` ensures disposal at block end, even on throw
  await using handle = runner.exec("ls -l /tmp");
  
  for await (const ev of handle.events) {
    if (ev.name === "stdout") process.stdout.write(ev.value);
    if (ev.name === "stderr") process.stderr.write(ev.value);
  }
  // Automatic disposal: stream closed, timers cleared, DB row deleted
}

The repository's own test suite in packages/computer/src/stub.test.ts lines 490-531 validates this behavior by wrapping stub handles in using blocks and asserting complete resource cleanup.

What Happens Without using

Consider the anti-pattern:

// DANGER: Leaks resources
async function brokenRun(runner: Runner) {
  const handle = runner.exec("long-running-task");
  // If this throws early, handle never disposed
  for await (const ev of handle.events) {
    if (ev.name === "error") throw new Error("abort");
  }
  // If we forget this call in early-return scenarios:
  // await handle[Symbol.asyncDispose]();
}

Missing disposal leaves the ExecRecord in Runner.records, timers active, and database rows intact. The EventLog also retains all output history indefinitely.

Key Implementation Files

File Purpose
packages/computerd/src/exec/runner.ts Core Runner class with exec(), get(), and disposeRecord()
packages/computer/src/runtime/types.ts WorkspaceRuntimeExecHandle<E> public interface definition
packages/computer/src/stub.ts WorkspaceRuntimeExecHandleStub async-disposable wrapper
packages/computer/src/stub.test.ts Test suite demonstrating using patterns

Summary

  • Exec handles have five lifecycle phases: creation, streaming, replay, completion, and disposal—only the final phase frees all resources.
  • Process exit does not equal disposal: Runner keeps records until disposeRecord() is explicitly called.
  • using guarantees cleanup: TypeScript 5.2's async disposable pattern ensures disposeRecord() runs reliably, preventing memory leaks and timer accumulation.
  • Always wrap handles in await using: This is the pattern used throughout the Computer codebase and validated in stub.test.ts.

Frequently Asked Questions

What triggers the disposeRecord method in practice?

The disposeRecord method is triggered automatically when a handle wrapped in using goes out of scope. The WorkspaceRuntimeExecHandleStub class implements [Symbol.asyncDispose] to call back into the Runner and invoke disposeRecord on the underlying record.

Can I manually dispose a handle without using?

Yes, you can call await handle[Symbol.asyncDispose]() directly, but using is safer—it handles exceptions, early returns, and nested scopes correctly. Manual disposal is error-prone and discouraged in production code.

What data persists after process completion but before disposal?

The EventLog retains all stdout, stderr, and heartbeat events. The ExecRecord stays in Runner.records and the SQLite exec table remains populated. These structures enable re-attachment via Runner.get() but consume resources until disposal.

Does using work with multiple concurrent handles?

Yes. Each using declaration creates an independent disposal scope. For concurrent execution, use await Promise.all with multiple using blocks or nest them appropriately. Each handle's disposal runs when its specific block exits.

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 →