Repository navigation
fix(sdk): fallback for AbortSignal.any in chat transport on Safari < 17.4 (#5098) #5107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { anySignal } from "./ai-shared.js"; | ||
|
|
||
| describe("anySignal", () => { | ||
| it("returns a non-aborted signal when signals list is empty", () => { | ||
| const signal = anySignal([]); | ||
| expect(signal.aborted).toBe(false); | ||
| }); | ||
|
|
||
| it("filters out null and undefined values", () => { | ||
| const controller = new AbortController(); | ||
| const signal = anySignal([null, undefined, controller.signal, null]); | ||
| expect(signal).toBe(controller.signal); | ||
| }); | ||
|
|
||
| it("returns the single signal if only one valid signal is provided", () => { | ||
| const controller = new AbortController(); | ||
| const signal = anySignal([controller.signal]); | ||
| expect(signal).toBe(controller.signal); | ||
| }); | ||
|
|
||
| describe("when AbortSignal.any is available (native)", () => { | ||
| it("aborts when any of the input signals aborts", () => { | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| const combined = anySignal([c1.signal, c2.signal]); | ||
|
|
||
| expect(combined.aborted).toBe(false); | ||
| c2.abort("cancelled by c2"); | ||
| expect(combined.aborted).toBe(true); | ||
| expect(combined.reason).toBe("cancelled by c2"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("fallback when AbortSignal.any is undefined (Safari < 17.4, Node < 20.3)", () => { | ||
| const originalAny = AbortSignal.any; | ||
|
|
||
| beforeEach(() => { | ||
| // @ts-expect-error - simulating environment without AbortSignal.any | ||
| delete AbortSignal.any; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| AbortSignal.any = originalAny; | ||
| }); | ||
|
|
||
| it("returns an immediately aborted signal if an input signal is already aborted", () => { | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| c1.abort("already aborted reason"); | ||
|
|
||
| const combined = anySignal([c1.signal, c2.signal]); | ||
| expect(combined.aborted).toBe(true); | ||
| expect(combined.reason).toBe("already aborted reason"); | ||
| }); | ||
|
|
||
| it("aborts when the first signal is aborted", () => { | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| const combined = anySignal([c1.signal, c2.signal]); | ||
|
|
||
| expect(combined.aborted).toBe(false); | ||
| c1.abort("first reason"); | ||
| expect(combined.aborted).toBe(true); | ||
| expect(combined.reason).toBe("first reason"); | ||
| }); | ||
|
|
||
| it("aborts when the second signal is aborted", () => { | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
| const combined = anySignal([c1.signal, c2.signal]); | ||
|
|
||
| expect(combined.aborted).toBe(false); | ||
| c2.abort("second reason"); | ||
| expect(combined.aborted).toBe(true); | ||
| expect(combined.reason).toBe("second reason"); | ||
| }); | ||
|
|
||
| it("cleans up event listeners on remaining signals when one signal aborts", () => { | ||
| const c1 = new AbortController(); | ||
| const c2 = new AbortController(); | ||
|
|
||
| const c2RemoveSpy = vi.spyOn(c2.signal, "removeEventListener"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Fallback test uses a forbidden spy The listener-cleanup test calls Learn moreExample: A fallback implementation that manages listeners through another valid mechanism can fail the test solely because it never calls the spied-on method. Recommended fix: Replace the spy with an observable behavioral test that covers the listener-lifetime regression without intercepting Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| const combined = anySignal([c1.signal, c2.signal]); | ||
|
|
||
| expect(combined.aborted).toBe(false); | ||
| c1.abort("first aborted"); | ||
|
|
||
| expect(combined.aborted).toBe(true); | ||
| expect(c2RemoveSpy).toHaveBeenCalledWith("abort", expect.any(Function)); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -278,3 +278,50 @@ export function waitBeforeEofResubscribe(attempt: number, signal: AbortSignal): | |
| if (signal.aborted) done(); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Combines multiple AbortSignals into a single signal. | ||
| * | ||
| * Uses native `AbortSignal.any` when available, and falls back to an AbortController | ||
| * with listeners on environments where `AbortSignal.any` is not supported (such as | ||
| * Safari < 17.4 or Node < 20.3). | ||
| */ | ||
| export function anySignal(signals: (AbortSignal | undefined | null)[]): AbortSignal { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Safari fix lacks a release changeset The Learn moreThe SDK is a public package, and the new fallback lets customers use chat transports on older Safari versions that previously threw. Repository guidance requires a changeset when a public package fixes a bug users could hit. No changeset is added in this PR. Example: A customer deciding whether to upgrade for Safari support reads the SDK changelog and finds no entry for the fix. Recommended fix: Add a patch changeset for Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| const validSignals = signals.filter((s): s is AbortSignal => s != null); | ||
| const first = validSignals[0]; | ||
| if (!first) { | ||
| return new AbortController().signal; | ||
| } | ||
| if (validSignals.length === 1) { | ||
| return first; | ||
| } | ||
| if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") { | ||
| return AbortSignal.any(validSignals); | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| const alreadyAborted = validSignals.find((s) => s.aborted); | ||
| if (alreadyAborted) { | ||
| controller.abort(alreadyAborted.reason); | ||
| return controller.signal; | ||
| } | ||
|
|
||
| const onAbort = (e: Event) => { | ||
| const s = e.target as AbortSignal; | ||
| controller.abort(s?.reason); | ||
| cleanup(); | ||
| }; | ||
|
|
||
| const cleanup = () => { | ||
| for (const s of validSignals) { | ||
| s.removeEventListener("abort", onAbort); | ||
| } | ||
| }; | ||
|
|
||
| for (const signal of validSignals) { | ||
| signal.addEventListener("abort", onAbort, { once: true }); | ||
| } | ||
|
Comment on lines
+321
to
+323
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Completed turns retain abort listeners On runtimes without Learn moreThe fallback subscribes to each input signal and removes those listeners only when an input aborts. A managed chat turn creates a new stop controller and combines it with the run-wide signal in the turn setup. Normal completion does not abort either signal, so the run-wide signal keeps every prior turn's closure, controller, and stop signal alive. The custom session turn also replaces its stop controller without aborting the old one in reset. This differs from native Example: On a runtime without Recommended fix: Provide a way to dispose the fallback's source listeners when the composite's operation ends, and invoke it at each turn and subscription teardown. Preserve the existing abort propagation and reason while an operation remains active. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| return controller.signal; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2456,6 +2456,25 @@ describe("TriggerChatTransport", () => { | |
| vi.useRealTimers(); | ||
| } | ||
| }); | ||
|
|
||
| it("safely reconnects and aborts when AbortSignal.any is not supported (Safari < 17.4)", async () => { | ||
| const originalAny = AbortSignal.any; | ||
| try { | ||
| // @ts-expect-error - simulating older browser/runtime without AbortSignal.any | ||
| delete AbortSignal.any; | ||
|
|
||
| const { transport } = quietWatchTransport(); | ||
| const abort = new AbortController(); | ||
| const stream = await transport.reconnectToStream({ | ||
| chatId: "chat-own", | ||
| abortSignal: abort.signal, | ||
| }); | ||
| expect(stream).toBeDefined(); | ||
| abort.abort(); | ||
|
Comment on lines
+2472
to
+2473
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } finally { | ||
| AbortSignal.any = originalAny; | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("superseded stream teardown", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Identify each unit test’s regression
The new unit tests lack the required
// Catches: <bug>comments. Identify each distinct regression they protect and remove redundant cases.Was this helpful? React with 👍 or 👎 to provide feedback.