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
EventLogfor 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
ExecRecordentries inRunner.records(unbounded Map growth) - Live timers that continue firing (CPU overhead, delayed process termination)
- Open
ReadableStreamcontrollers (memory retention, backpressure issues) - SQLite rows in the
exectable (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:
- Stream closure – the controller closes and the host stops listening
- Bookkeeping cleanup –
Runner.disposeRecordclears all internal state - 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:
Runnerkeeps records untildisposeRecord()is explicitly called. usingguarantees cleanup: TypeScript 5.2's async disposable pattern ensuresdisposeRecord()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 instub.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →