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

> Learn about the ExecHandle lifecycle in Cloudflare's Computer framework. Discover why using disposal is essential to prevent memory leaks and ensure proper resource management.

- Repository: [Cloudflare/computer](https://github.com/cloudflare/computer)
- Tags: internals
- Published: 2026-08-14

---

**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`](https://github.com/cloudflare/computer/blob/main/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`](https://github.com/cloudflare/computer/blob/main/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.

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/runner.ts) lines 69-89 enables both live streaming and later re-attachment:

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/runner.ts) lines 254-275:

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/runner.ts) lines 191-210:

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/runner.ts) lines 11-22 through `disposeRecord()`:

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/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

```typescript
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`](https://github.com/cloudflare/computer/blob/main/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:

```typescript
// 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`](https://github.com/cloudflare/computer/blob/main/packages/computerd/src/exec/runner.ts) | Core `Runner` class with `exec()`, `get()`, and `disposeRecord()` |
| [`packages/computer/src/runtime/types.ts`](https://github.com/cloudflare/computer/blob/main/packages/computer/src/runtime/types.ts) | `WorkspaceRuntimeExecHandle<E>` public interface definition |
| [`packages/computer/src/stub.ts`](https://github.com/cloudflare/computer/blob/main/packages/computer/src/stub.ts) | `WorkspaceRuntimeExecHandleStub` async-disposable wrapper |
| [`packages/computer/src/stub.test.ts`](https://github.com/cloudflare/computer/blob/main/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`](https://github.com/cloudflare/computer/blob/main/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.