Skip to content

bug: Chat transport crashes on Safari < 17.4: AbortSignal.any is not a function #5098

Open

Description

@madsobel

Provide environment information

System:
  OS: macOS 27.0.1
  CPU: (12) arm64 Apple M4 Pro
  Memory: 24.00 GB
  Shell: 5.9 - zsh
Binaries:
  Node: 24.12.0
  npm: 11.6.2
  pnpm: 10.33.0
  bun: 1.3.7

Affected client (end user's browser):
Safari 17.2.1 on macOS (Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15)

Packages:
@trigger.dev/sdk 4.8.3 (also reproduced on 4.7.3)
ai 7.0.162
next 16 (App Router), React 19

Describe the bug

The browser-side chat transport (useTriggerChatTransport from @trigger.dev/sdk/chat/react) calls AbortSignal.any without checking that it exists. Safari only added AbortSignal.any in 17.4, so on Safari 17.0–17.3 (and 16.x) every chat request throws:

TypeError: AbortSignal.any is not a function. (In 'AbortSignal.any([t,o.signal])', 'AbortSignal.any' is undefined)
at subscribeToSessionStream

The AI SDK always passes an abortSignal to sendMessages / reconnectToStream, so the AbortSignal.any branch is hit on every send and every resume. Chat is unusable for these users.

Next.js supports Safari 16.4+ by default, so these browsers are still in the supported range for most apps using the SDK.

Unguarded call sites in 4.8.3:

  • dist/esm/v3/chat.js:614 (reconnectToStream)
  • dist/esm/v3/chat.js:1210 (subscribeToSessionStream)
  • dist/esm/v3/chat-client.js:571
    (and the matching commonjs builds)

Reproduction repo

No standalone repo. Any app that uses useTriggerChatTransport with useChat reproduces it on Safari < 17.4, or on any browser after removing the method (see steps below).

To reproduce

  1. Set up a chat using useTriggerChatTransport from @trigger.dev/sdk/chat/react with useChat from @ai-sdk/react.
  2. Open the app in Safari 17.2 (or any Safari < 17.4).
    Alternatively, in any modern browser, run delete AbortSignal.any in the devtools console before the chat mounts.
  3. Send a message, or reload a page with an active chat so it resumes the stream.
  4. The transport throws TypeError: AbortSignal.any is not a function and the chat request fails.

Suggested fix

v3/sessions.js already guards this for Node 18 (if (typeof AbortSignal.any === "function") ... else { wire the caller signal manually }). Applying the same guard, or a small shared helper, to the chat transport would fix it. For example:

function anySignal(signals: AbortSignal[]): AbortSignal {
  if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);

  const controller = new AbortController();
  const aborted = signals.find((s) => s.aborted);
  if (aborted) {
    controller.abort(aborted.reason);
    return controller.signal;
  }

  for (const s of signals) {
    s.addEventListener("abort", () => controller.abort(s.reason), {
      once: true,
      signal: controller.signal,
    });
  }
  return controller.signal;
}

and use it at the three call sites in chat.js and chat-client.js.

Additional information

We've worked around it on our side by polyfilling AbortSignal.any in instrumentation-client.ts. It would still be good to fix in the SDK, since apps using the chat transport won't know they need the polyfill until older-Safari users report broken chat.

Activity

  1. priyanshumakes commented on Nov 10, 2026

    @priyanshumakes

    Thanks for the detailed writeup, I put together a verified fix. Sharing it here since I am not vouched to open PRs yet.

    Root cause: TriggerChatTransport (chat.ts) and AgentChat (chat-client.ts) call AbortSignal.any() unconditionally in three places (chat.ts:1358, chat.ts:2067, chat-client.ts:818). Safari only added AbortSignal.any in 17.4, so on 17.0-17.3 and older every chat request throws TypeError: AbortSignal.any is not a function.

    Suggested fix: a small anyAbortSignal() helper in ai-shared.ts that uses the native AbortSignal.any when available and falls back to listener forwarding on older runtimes, with matching semantics: an already-aborted input aborts the result immediately with the same reason, the first abort wins, and listeners detach once the combined signal fires. The three call sites each become a one-line swap.

    Verification: added anyAbortSignal.test.ts with 8 tests covering the native path and the fallback path (Safari < 17.4 simulated by removing AbortSignal.any). Ran the SDK's chat test suites locally: 212 tests across 11 files, all passing (204 existing + 8 new). No new typecheck errors from the changed files.

    Full diff (4 files, +142/-3)
    diff --git a/packages/trigger-sdk/src/v3/ai-shared.ts b/packages/trigger-sdk/src/v3/ai-shared.ts
    index d0434e2..08d655b 100644
    --- a/packages/trigger-sdk/src/v3/ai-shared.ts
    +++ b/packages/trigger-sdk/src/v3/ai-shared.ts
    @@ -278,3 +278,40 @@ export function waitBeforeEofResubscribe(attempt: number, signal: AbortSignal):
         if (signal.aborted) done();
       });
     }
    +
    +/**
    + * Combine several `AbortSignal`s into one that aborts when any of them does.
    + *
    + * Uses the native `AbortSignal.any` when available and falls back to manual
    + * listener forwarding on runtimes without it (Safari / iOS Safari < 17.4, and
    + * older browsers in general). Semantics match the native version: an
    + * already-aborted input produces an immediately-aborted result with the same
    + * `reason`, and the first abort wins.
    + */
    +export function anyAbortSignal(signals: AbortSignal[]): AbortSignal {
    +  if (typeof AbortSignal.any === "function") {
    +    return AbortSignal.any(signals);
    +  }
    +
    +  const controller = new AbortController();
    +
    +  // `AbortSignal.any` reflects inputs that are already aborted, so the
    +  // fallback must too (an already-aborted signal never fires `abort` again).
    +  for (const signal of signals) {
    +    if (signal.aborted) {
    +      controller.abort(signal.reason);
    +      return controller.signal;
    +    }
    +  }
    +
    +  for (const signal of signals) {
    +    signal.addEventListener("abort", () => controller.abort(signal.reason), {
    +      once: true,
    +      // Detach the listeners on the remaining inputs once the combined
    +      // signal aborts, so long-lived signals don't accumulate them.
    +      signal: controller.signal,
    +    });
    +  }
    +
    +  return controller.signal;
    +}
    diff --git a/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts b/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts
    new file mode 100644
    index 0000000..0bb3979
    --- /dev/null
    +++ b/packages/trigger-sdk/src/v3/anyAbortSignal.test.ts
    @@ -0,0 +1,100 @@
    +import { describe, it, expect, afterEach } from "vitest";
    +import { anyAbortSignal } from "./ai-shared.js";
    +
    +/**
    + * `AbortSignal.any` only exists on Safari >= 17.4 (and modern Chromium /
    + * Firefox / Node). The fallback path is exercised by temporarily removing
    + * the static method, which is exactly the situation older Safari hits.
    + */
    +const nativeAny = AbortSignal.any;
    +
    +function simulateSafariBelow174() {
    +  // @ts-expect-error - deliberately removing the static method to simulate older runtimes
    +  delete AbortSignal.any;
    +}
    +
    +afterEach(() => {
    +  if (typeof AbortSignal.any !== "function") {
    +    AbortSignal.any = nativeAny;
    +  }
    +});
    +
    +describe("anyAbortSignal", () => {
    +  it("uses native AbortSignal.any when available", () => {
    +    const a = new AbortController();
    +    const b = new AbortController();
    +    const combined = anyAbortSignal([a.signal, b.signal]);
    +
    +    expect(combined.aborted).toBe(false);
    +    a.abort("native-reason");
    +    expect(combined.aborted).toBe(true);
    +    expect(combined.reason).toBe("native-reason");
    +  });
    +
    +  describe("fallback (runtimes without AbortSignal.any, e.g. Safari < 17.4)", () => {
    +    it("starts un-aborted when no input is aborted", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      const b = new AbortController();
    +      expect(anyAbortSignal([a.signal, b.signal]).aborted).toBe(false);
    +    });
    +
    +    it("aborts when the first input aborts, preserving the reason", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      const b = new AbortController();
    +      const combined = anyAbortSignal([a.signal, b.signal]);
    +
    +      a.abort("first-reason");
    +      expect(combined.aborted).toBe(true);
    +      expect(combined.reason).toBe("first-reason");
    +    });
    +
    +    it("aborts when the second input aborts, preserving the reason", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      const b = new AbortController();
    +      const combined = anyAbortSignal([a.signal, b.signal]);
    +
    +      b.abort("second-reason");
    +      expect(combined.aborted).toBe(true);
    +      expect(combined.reason).toBe("second-reason");
    +    });
    +
    +    it("reflects an already-aborted input immediately", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      a.abort("already-aborted");
    +      const b = new AbortController();
    +
    +      const combined = anyAbortSignal([a.signal, b.signal]);
    +      expect(combined.aborted).toBe(true);
    +      expect(combined.reason).toBe("already-aborted");
    +    });
    +
    +    it("does not abort twice when more inputs abort after the first", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      const b = new AbortController();
    +      const combined = anyAbortSignal([a.signal, b.signal]);
    +
    +      a.abort("winner");
    +      b.abort("late");
    +      expect(combined.reason).toBe("winner");
    +    });
    +
    +    it("handles a single input", () => {
    +      simulateSafariBelow174();
    +      const a = new AbortController();
    +      const combined = anyAbortSignal([a.signal]);
    +
    +      a.abort();
    +      expect(combined.aborted).toBe(true);
    +    });
    +
    +    it("never aborts for an empty input list, matching native behavior", () => {
    +      simulateSafariBelow174();
    +      expect(anyAbortSignal([]).aborted).toBe(false);
    +    });
    +  });
    +});
    diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts
    index 1a421af..0f765f5 100644
    --- a/packages/trigger-sdk/src/v3/chat-client.ts
    +++ b/packages/trigger-sdk/src/v3/chat-client.ts
    @@ -31,6 +31,7 @@ import {
     } from "@trigger.dev/core/v3";
     import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
     import {
    +  anyAbortSignal,
       chatRunTags,
       MAX_EOF_RESUBSCRIBES,
       slimSubmitMessageForWire,
    @@ -815,7 +816,7 @@ export class AgentChat<TAgent = unknown> {
     
         const internalAbort = new AbortController();
         const combinedSignal = abortSignal
    -      ? AbortSignal.any([abortSignal, internalAbort.signal])
    +      ? anyAbortSignal([abortSignal, internalAbort.signal])
           : internalAbort.signal;
     
         // Detached once the stream ends (see the `finally` below): a caller reusing
    diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts
    index ba74aff..ec6ba02 100644
    --- a/packages/trigger-sdk/src/v3/chat.ts
    +++ b/packages/trigger-sdk/src/v3/chat.ts
    @@ -46,6 +46,7 @@ function byteLength(body: string): number {
     }
     import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
     import {
    +  anyAbortSignal,
       MAX_EOF_RESUBSCRIBES,
       slimSubmitMessageForWire,
       waitBeforeEofResubscribe,
    @@ -1355,7 +1356,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
         this.activeStreams.set(options.chatId, abortController);
     
         const abortSignal = options.abortSignal
    -      ? AbortSignal.any([options.abortSignal, abortController.signal])
    +      ? anyAbortSignal([options.abortSignal, abortController.signal])
           : abortController.signal;
     
         return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
    @@ -2064,7 +2065,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
         let didSettle = false;
         this.activeStreams.set(chatId, internalAbort);
         const combinedSignal = abortSignal
    -      ? AbortSignal.any([abortSignal, internalAbort.signal])
    +      ? anyAbortSignal([abortSignal, internalAbort.signal])
           : internalAbort.signal;
     
         if (abortSignal) {

    Feel free to apply it directly, or I can open a PR if I get vouched.

    Disclosure: I worked on this with an AI coding assistant.

  2. ghazy2001 commented on Nov 10, 2026

    @ghazy2001

    Opened PR #5107 to address this:

    • Introduced \�nySignal\ in \packages/trigger-sdk/src/v3/ai-shared.ts\ with native \AbortSignal.any\ delegation and an \AbortController\ fallback for Safari < 17.4 and Node < 20.3.
    • Includes listener cleanup upon first abort to prevent event listener leaks on long-lived caller signals.
    • Replaced direct \AbortSignal.any\ calls across \chat.ts, \chat-client.ts, \chat-server.ts, and \�i.ts.
    • Added unit test suites in \�i-shared.test.ts\ and \chat.test.ts\ verifying fallback and signal combinations (all passing).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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