Skip to content

Commit b1814dc

Browse files
committed
fix(sdk): keep the successor stream's abort controller when an aborted stream tears down
1 parent 5810c80 commit b1814dc

3 files changed

Lines changed: 147 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fixed a race where quickly restarting a chat stream could break stop and reconnect for the new stream.

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,35 @@ function defaultSseResponse(
132132
});
133133
}
134134

135+
/**
136+
* An SSE response whose body stays open until the request signal aborts.
137+
* Models a live subscription sitting on a quiet server.
138+
*/
139+
function openSseResponse(signal?: AbortSignal | null): Response {
140+
const body = new ReadableStream<Uint8Array>({
141+
start(controller) {
142+
const onAbort = () => {
143+
const err = new Error("aborted");
144+
err.name = "AbortError";
145+
try {
146+
controller.error(err);
147+
} catch {
148+
/* already errored */
149+
}
150+
};
151+
if (signal?.aborted) onAbort();
152+
else signal?.addEventListener("abort", onAbort, { once: true });
153+
},
154+
});
155+
return new Response(body, {
156+
status: 200,
157+
headers: {
158+
"content-type": "text/event-stream",
159+
"X-Stream-Version": "v2",
160+
},
161+
});
162+
}
163+
135164
function authError(status = 401): Response {
136165
return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), {
137166
status,
@@ -1524,6 +1553,112 @@ describe("TriggerChatTransport", () => {
15241553
});
15251554
});
15261555

1556+
describe("superseded stream teardown", () => {
1557+
it("keeps the successor's controller registered when the aborted stream tears down", async () => {
1558+
vi.useFakeTimers();
1559+
try {
1560+
let appendCount = 0;
1561+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1562+
const urlStr = typeof url === "string" ? url : url.toString();
1563+
if (isSessionStreamAppendUrl(urlStr)) {
1564+
appendCount++;
1565+
return defaultAppendResponse();
1566+
}
1567+
// Quiet stream: EOF, no records, never settled — watch keeps it open.
1568+
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse([]);
1569+
throw new Error(`Unexpected URL: ${urlStr}`);
1570+
});
1571+
1572+
const transport = new TriggerChatTransport({
1573+
task: "my-chat-task",
1574+
accessToken: () => "pat",
1575+
watch: true,
1576+
sessions: { "chat-race": { publicAccessToken: "p", isStreaming: true } },
1577+
});
1578+
1579+
const send = () =>
1580+
transport.sendMessages({
1581+
trigger: "submit-message" as const,
1582+
chatId: "chat-race",
1583+
messageId: undefined,
1584+
messages: [createUserMessage("hi")],
1585+
abortSignal: undefined,
1586+
});
1587+
1588+
const first = drainChunks(await send());
1589+
await vi.advanceTimersByTimeAsync(1_000);
1590+
1591+
// Supersede: the new stream registers its controller synchronously,
1592+
// the aborted one tears down a microtask later.
1593+
const second = await send();
1594+
let secondClosed = false;
1595+
const secondDrain = drainChunks(second).then(() => {
1596+
secondClosed = true;
1597+
});
1598+
await first;
1599+
await vi.advanceTimersByTimeAsync(1_000);
1600+
1601+
// stopGeneration posts the stop chunk either way — only the
1602+
// closing assertion proves it found the successor to abort.
1603+
appendCount = 0;
1604+
expect(await transport.stopGeneration("chat-race")).toBe(true);
1605+
await vi.advanceTimersByTimeAsync(1_000);
1606+
expect(appendCount).toBe(1);
1607+
expect(secondClosed).toBe(true);
1608+
1609+
transport.dispose();
1610+
await secondDrain;
1611+
} finally {
1612+
vi.useRealTimers();
1613+
}
1614+
});
1615+
1616+
it("keeps the tab claim the successor took (multi-tab)", async () => {
1617+
vi.useFakeTimers();
1618+
try {
1619+
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
1620+
const urlStr = typeof url === "string" ? url : url.toString();
1621+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1622+
// Open SSE that only ends when the subscription is aborted, so
1623+
// the superseded stream tears down while the successor is live.
1624+
if (isSessionOutSubscribeUrl(urlStr)) return openSseResponse(init?.signal);
1625+
throw new Error(`Unexpected URL: ${urlStr}`);
1626+
});
1627+
1628+
const transport = new TriggerChatTransport({
1629+
task: "my-chat-task",
1630+
accessToken: () => "pat",
1631+
multiTab: true,
1632+
sessions: { "chat-race-tab": { publicAccessToken: "p", isStreaming: true } },
1633+
});
1634+
1635+
const send = () =>
1636+
transport.sendMessages({
1637+
trigger: "submit-message" as const,
1638+
chatId: "chat-race-tab",
1639+
messageId: undefined,
1640+
messages: [createUserMessage("hi")],
1641+
abortSignal: undefined,
1642+
});
1643+
1644+
const first = drainChunks(await send());
1645+
await vi.advanceTimersByTimeAsync(1_000);
1646+
const secondDrain = drainChunks(await send());
1647+
await first;
1648+
await vi.advanceTimersByTimeAsync(1_000);
1649+
1650+
// The superseded stream must not release the claim its successor
1651+
// holds — otherwise this tab flips to read-only mid-turn.
1652+
expect(transport.hasClaim("chat-race-tab")).toBe(true);
1653+
1654+
transport.dispose();
1655+
await secondDrain;
1656+
} finally {
1657+
vi.useRealTimers();
1658+
}
1659+
});
1660+
});
1661+
15271662
describe("multi-tab coordination", () => {
15281663
it("isReadOnly defaults to false when multiTab is disabled", () => {
15291664
const transport = new TriggerChatTransport({

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2054,8 +2054,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
20542054
controller.error(error);
20552055
} finally {
20562056
teardownWakeListeners();
2057-
this.activeStreams.delete(chatId);
2058-
this.coordinator?.release(chatId);
2057+
// Only clear the entry (and drop the tab claim) if it is still
2058+
// ours — a superseding send registers its controller before this
2059+
// teardown runs, and owns the claim from then on.
2060+
if (this.activeStreams.get(chatId) === internalAbort) {
2061+
this.activeStreams.delete(chatId);
2062+
this.coordinator?.release(chatId);
2063+
}
20592064
}
20602065
},
20612066
// A consumer that stops reading without aborting (drops the reader)

0 commit comments

Comments
 (0)