vercel/ai · error · Error

SandboxChannel: cannot send ${message.type} — channel is clo

Error message

SandboxChannel: cannot send ${message.type} — channel is closed.

What it means

Thrown by SandboxChannel.send in packages/harness/src/utils/sandbox-channel.ts when a message is sent on a channel that has reached terminal (closed) state. Once a channel is terminal no further messages can be serialized or transmitted; every send attempt fails immediately rather than silently dropping the message. A new channel must be established to continue communicating.

Source

Thrown at packages/harness/src/utils/sandbox-channel.ts:260

    return () => {
      set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
    };
  }

  onClose(handler: (code: number, reason: string) => void): void {
    this.onCloseHandlers.add(handler);
  }

  onReconnect(handler: () => void): () => void {
    this.onReconnectHandlers.add(handler);
    return () => {
      this.onReconnectHandlers.delete(handler);
    };
  }

  send(message: TIn): void {
    if (this.terminal) {
      throw new Error(
        `SandboxChannel: cannot send ${message.type} — channel is closed.`,
      );
    }
    this.rawSend(JSON.stringify(message));
  }

  /**
   * Mark that the host is tearing the session down. The next socket close is
   * then treated as terminal rather than triggering a reconnect. Call before
   * sending a `stop` / `destroy` message whose completion closes the bridge
   * socket.
   */
  beginClose(): void {
    this.closing = true;
  }

  close(): void {
    if (this.terminal) return;

View on GitHub (pinned to 69428b1f8b)

Solutions

  1. Check the channel's connection/terminal state before calling send, and recreate the channel when it is terminal.
  2. Wrap sends in error handling that triggers channel recreation and message replay.
  3. Stop producers (queues/callbacks) when the channel's terminal/close callback fires.
  4. Use the channel's reconnect handlers to detect permanent closure versus transient disconnect.

Example fix

// before
await channel.close();
channel.send({ type: 'run' }); // throws
// after
await channel.close();
channel = createChannel();
await channel.open();
channel.send({ type: 'run' });
Defensive patterns

Strategy: try-catch

Validate before calling

if (channel.isTerminal?.()) {
  throw new Error('Channel closed — recreate before sending ' + message.type);
}

Type guard

function canSend(channel: { isTerminal?: () => boolean }): boolean {
  return !(channel.isTerminal?.() ?? true);
}

Try / catch

try {
  channel.send(message);
} catch (error) {
  if (/channel is closed/.test((error as Error).message)) {
    await recreateAndReplay([message]); // new channel, replay unsent messages
  } else throw error;
}

Prevention

When it happens

Trigger: Calling `channel.send(message)` after the channel entered its terminal state — e.g. the sandbox process exited, the WebSocket closed fatally, or `close()` was invoked — and application code still holds the stale channel reference.

Common situations: Fire-and-forget command queues continuing to push messages after sandbox teardown; long-running workers holding a channel across sandbox restarts; race where the sandbox dies mid-task and the next `send` throws.

Related errors


AI-assisted analysis of vercel/ai@69428b1f8b (2026-08-30). Data as JSON: /api/errors/8c5fe9ebb061758f. Report an issue: GitHub.