Skip to content

Aborting a tool-enabled stream leaves executionPromise unhandled and can terminate Node.js #113

Description

@xavier-villelegier

Hey team !

Summary

Aborting a tool-enabled callModel() request while consuming getFullResponsesStream() can produce two rejections:

  1. The broadcaster consumer rejects with AbortError.
  2. The background executionPromise rejects independently.

The caller can catch the stream error, but the async generator exits while iterating the consumer and never reaches its later await executionPromise. The second rejection therefore becomes unhandled.

With Node.js's default unhandled-rejection behavior, this can terminate the entire server process.

Affected versions

  • @openrouter/agent@0.7.0
  • @openrouter/agent@0.9.0
  • Current main at 787cbf8b22bf2b8071e81e2dbf84ecd871a5e824
  • Observed in production on Node.js 22
  • Reproduced deterministically on Node.js 24.11.1

Reproduction

import { OpenRouter, tool } from "@openrouter/agent";
import { z } from "zod";

const client = new OpenRouter({
  apiKey: process.env.OPENROUTER_API_KEY,
});

const controller = new AbortController();

const noopTool = tool({
  name: "noop",
  description: "A tool that is not expected to be called.",
  inputSchema: z.object({}),
  execute: async () => ({ ok: true }),
});

// This handler demonstrates the second rejection.
// Without it, Node.js may terminate the process.
process.on("unhandledRejection", (error) => {
  console.error("UNHANDLED REJECTION:", error);
});

const result = client.callModel(
  {
    model: process.env.OPENROUTER_MODEL ?? "openai/gpt-5-nano",
    input: "Write a sufficiently long response.",
    tools: [noopTool],
  },
  {
    signal: controller.signal,
  },
);

let aborted = false;

try {
  for await (const event of result.getFullResponsesStream()) {
    if (!aborted) {
      aborted = true;
      controller.abort();
    }

    console.log(event.type);
  }
} catch (error) {
  console.log("Stream error caught by caller:", error);
}

// Allow Node.js to report any unhandled promise rejection.
await new Promise((resolve) => setTimeout(resolve, 100));

Typical result:

Stream error caught by caller: DOMException [AbortError]
UNHANDLED REJECTION: DOMException [AbortError]

Without an unhandledRejection listener, Node.js 22 terminates with output similar to:

node:internal/process/promises:394
    triggerUncaughtException(err, true /* fromPromise */);
    ^

DOMException [AbortError]: This operation was aborted

The tool does not need to be called. Merely configuring a non-empty tools array selects the broadcaster/tool-execution streaming path.

Expected behavior

The abort should be exposed once through the stream getter, allowing the caller to handle it with try/catch.

No additional process-level unhandledRejection should occur.

Root cause

startTurnBroadcasterExecution() starts tool execution in the background:

const executionPromise = this.executeToolsIfNeeded().finally(async () => {
  if (this.initialPipePromise) {
    await this.initialPipePromise;
  }

  broadcaster.complete();
});

The stream getters then consume the broadcaster before awaiting that promise:

const { consumer, executionPromise } =
  this.startTurnBroadcasterExecution();

for await (const event of consumer) {
  yield event;
}

await executionPromise;

If consumer throws during an abort, control exits the async generator before await executionPromise is reached. The independently rejected promise has no handler.

Because several stream getters use startTurnBroadcasterExecution(), the same failure mode may affect more than getFullResponsesStream().

Suggested fix

Observe executionPromise immediately after creating it:

const executionPromise = this.executeToolsIfNeeded().finally(async () => {
  if (this.initialPipePromise) {
    await this.initialPipePromise;
  }

  broadcaster.complete();
});

void executionPromise.catch(() => {});

This does not replace or resolve the original promise. A later await executionPromise still throws the original error to the caller; the immediate handler only prevents the background rejection from becoming process-level unhandled.

A centralized stream helper that always settles executionPromise when consumer iteration fails would also solve the problem.

Suggested regression test

The test should verify that:

  1. Aborting a tool-enabled stream rejects the consumer with AbortError.
  2. No unhandledRejection is emitted after the next event-loop turn.
  3. The original executionPromise remains rejected and observable.

Impact

HTTP servers commonly forward the incoming request's AbortSignal to the model request. A mobile app closing, browser navigation, or network disconnection can therefore terminate the complete Node.js server rather than only canceling one generation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions