Skip to content

Commit efd837a

Browse files
xgtcodeshanchunhua
andauthored
fix(studio): timeout stalled run_sse streams (#986)
Co-authored-by: shanchunhua <shanchunhua@bytedance.com>
1 parent 13810b5 commit efd837a

65 files changed

Lines changed: 746 additions & 568 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/src/adk/client.ts

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,6 +1642,60 @@ export const RUN_SSE_EMPTY_RESPONSE_ERROR =
16421642
export const RUN_SSE_INCOMPLETE_RESPONSE_ERROR =
16431643
formatRunSseError("HTTP 200,SSE 响应中没有可展示的模型回复。");
16441644

1645+
const RUN_SSE_FIRST_EVENT_TIMEOUT_MS = 30_000;
1646+
export const RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR =
1647+
formatRunSseError("30 秒内未收到首个 SSE 事件。");
1648+
1649+
interface RunSseFirstEventDeadline {
1650+
signal?: AbortSignal;
1651+
clearDeadline: () => void;
1652+
cleanup: () => void;
1653+
timedOut: () => boolean;
1654+
}
1655+
1656+
function runSseFirstEventDeadline(
1657+
signal: AbortSignal | undefined,
1658+
): RunSseFirstEventDeadline {
1659+
if (signal?.aborted) {
1660+
return {
1661+
signal,
1662+
clearDeadline: () => {},
1663+
cleanup: () => {},
1664+
timedOut: () => false,
1665+
};
1666+
}
1667+
1668+
const controller = new AbortController();
1669+
let didTimeout = false;
1670+
let deadlineCleared = false;
1671+
const clearDeadline = () => {
1672+
if (deadlineCleared) return;
1673+
deadlineCleared = true;
1674+
clearTimeout(timer);
1675+
};
1676+
const onAbort = () => {
1677+
if (controller.signal.aborted) return;
1678+
controller.abort(signal?.reason ?? new DOMException("Aborted", "AbortError"));
1679+
};
1680+
const timer = setTimeout(() => {
1681+
if (deadlineCleared || controller.signal.aborted) return;
1682+
didTimeout = true;
1683+
deadlineCleared = true;
1684+
controller.abort(new Error(RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR));
1685+
}, RUN_SSE_FIRST_EVENT_TIMEOUT_MS);
1686+
signal?.addEventListener("abort", onAbort, { once: true });
1687+
1688+
return {
1689+
signal: controller.signal,
1690+
clearDeadline,
1691+
cleanup: () => {
1692+
clearDeadline();
1693+
signal?.removeEventListener("abort", onAbort);
1694+
},
1695+
timedOut: () => didTimeout,
1696+
};
1697+
}
1698+
16451699
/** Stream agent events for one user turn. */
16461700
export async function* runSSE({
16471701
appName,
@@ -1698,6 +1752,7 @@ export async function* runSSE({
16981752
};
16991753
}
17001754
let res: Response;
1755+
const firstEventDeadline = runSseFirstEventDeadline(signal);
17011756
try {
17021757
res = await apiFetch(
17031758
"/run_sse",
@@ -1717,16 +1772,19 @@ export async function* runSSE({
17171772
? { veadkInvocation: invocationMetadata }
17181773
: undefined,
17191774
}),
1720-
signal,
1775+
signal: firstEventDeadline.signal,
17211776
},
17221777
ep,
17231778
0,
17241779
);
17251780
} catch (error) {
1781+
firstEventDeadline.cleanup();
1782+
if (firstEventDeadline.timedOut()) throw new Error(RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR);
17261783
if (signal?.aborted || (error as Error)?.name === "AbortError") throw error;
17271784
throw new Error(formatRunSseError(error));
17281785
}
17291786
if (!res.ok) {
1787+
firstEventDeadline.cleanup();
17301788
const detail = await httpErrorMessage(res, "运行会话失败");
17311789
throw new Error(
17321790
formatRunSseError(`run_sse failed: ${res.status}${detail}`),
@@ -1736,6 +1794,7 @@ export async function* runSSE({
17361794
try {
17371795
for await (const evt of parseSSE(res)) {
17381796
receivedEvent = true;
1797+
firstEventDeadline.clearDeadline();
17391798
const event = evt as AdkEvent;
17401799
if (typeof event.error === "string") event.error = formatRunSseError(event.error);
17411800
if (typeof event.errorMessage === "string") {
@@ -1747,8 +1806,11 @@ export async function* runSSE({
17471806
yield event;
17481807
}
17491808
} catch (error) {
1809+
if (firstEventDeadline.timedOut()) throw new Error(RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR);
17501810
if (signal?.aborted || (error as Error)?.name === "AbortError") throw error;
17511811
throw new Error(formatRunSseError(error));
1812+
} finally {
1813+
firstEventDeadline.cleanup();
17521814
}
17531815
if (!receivedEvent) throw new Error(RUN_SSE_EMPTY_RESPONSE_ERROR);
17541816
}
@@ -3678,25 +3740,44 @@ export async function* runGeneratedAgentTestSSE({
36783740
signal?: AbortSignal;
36793741
}): AsyncGenerator<AdkEvent, void, unknown> {
36803742
const parts: Record<string, unknown>[] = text.trim() ? [{ text }] : [];
3681-
const res = await apiFetch(
3682-
`/web/generated-agent-test-runs/${runId}/run_sse`,
3683-
{
3684-
method: "POST",
3685-
headers: { "Content-Type": "application/json" },
3686-
body: JSON.stringify({
3687-
user_id: userId,
3688-
session_id: sessionId,
3689-
new_message: { role: "user", parts },
3690-
streaming: true,
3691-
}),
3692-
signal,
3693-
},
3694-
{},
3695-
0,
3696-
);
3697-
if (!res.ok) throw new Error(await httpErrorMessage(res, "调试运行失败"));
3698-
for await (const evt of parseSSE(res)) {
3699-
yield evt as AdkEvent;
3743+
const firstEventDeadline = runSseFirstEventDeadline(signal);
3744+
let res: Response;
3745+
try {
3746+
res = await apiFetch(
3747+
`/web/generated-agent-test-runs/${runId}/run_sse`,
3748+
{
3749+
method: "POST",
3750+
headers: { "Content-Type": "application/json" },
3751+
body: JSON.stringify({
3752+
user_id: userId,
3753+
session_id: sessionId,
3754+
new_message: { role: "user", parts },
3755+
streaming: true,
3756+
}),
3757+
signal: firstEventDeadline.signal,
3758+
},
3759+
{},
3760+
0,
3761+
);
3762+
} catch (error) {
3763+
firstEventDeadline.cleanup();
3764+
if (firstEventDeadline.timedOut()) throw new Error(RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR);
3765+
throw error;
3766+
}
3767+
if (!res.ok) {
3768+
firstEventDeadline.cleanup();
3769+
throw new Error(await httpErrorMessage(res, "调试运行失败"));
3770+
}
3771+
try {
3772+
for await (const evt of parseSSE(res)) {
3773+
firstEventDeadline.clearDeadline();
3774+
yield evt as AdkEvent;
3775+
}
3776+
} catch (error) {
3777+
if (firstEventDeadline.timedOut()) throw new Error(RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR);
3778+
throw error;
3779+
} finally {
3780+
firstEventDeadline.cleanup();
37003781
}
37013782
}
37023783

frontend/tests/runSseAbort.test.mjs

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const result = await build({
3939
const moduleUrl = `data:text/javascript;base64,${Buffer.from(
4040
result.outputFiles[0].contents,
4141
).toString("base64")}`;
42-
const { runSSE } = await import(moduleUrl);
42+
const { RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR, runSSE } = await import(moduleUrl);
4343

4444
test("runSSE forwards cancellation after yielding partial output", async (t) => {
4545
const previousFetch = globalThis.fetch;
@@ -87,7 +87,8 @@ test("runSSE forwards cancellation after yielding partial output", async (t) =>
8787
assert.equal(first.done, false);
8888
assert.equal(first.value.partial, true);
8989
assert.equal(first.value.content.parts[0].text, "part");
90-
assert.equal(requestSignal, abortController.signal);
90+
assert.ok(requestSignal instanceof AbortSignal);
91+
assert.equal(requestSignal.aborted, false);
9192

9293
const next = events.next();
9394
abortController.abort(new DOMException("Stopped by user", "AbortError"));
@@ -98,6 +99,102 @@ test("runSSE forwards cancellation after yielding partial output", async (t) =>
9899
});
99100
});
100101

102+
test("runSSE aborts when no first event arrives before the deadline", async (t) => {
103+
const previousFetch = globalThis.fetch;
104+
const previousSetTimeout = globalThis.setTimeout;
105+
const previousClearTimeout = globalThis.clearTimeout;
106+
t.after(() => {
107+
globalThis.fetch = previousFetch;
108+
globalThis.setTimeout = previousSetTimeout;
109+
globalThis.clearTimeout = previousClearTimeout;
110+
});
111+
112+
let timeoutCallback;
113+
let timeoutMs;
114+
globalThis.setTimeout = (callback, ms, ...args) => {
115+
timeoutMs = ms;
116+
timeoutCallback = () => callback(...args);
117+
return 1;
118+
};
119+
globalThis.clearTimeout = () => {};
120+
globalThis.fetch = async (_url, init) => {
121+
return new Promise((_resolve, reject) => {
122+
init.signal.addEventListener(
123+
"abort",
124+
() => reject(init.signal.reason ?? new DOMException("Aborted", "AbortError")),
125+
{ once: true },
126+
);
127+
});
128+
};
129+
130+
const events = runSSE({
131+
appName: "agent",
132+
userId: "user",
133+
sessionId: "session",
134+
text: "hello",
135+
});
136+
137+
const next = events.next();
138+
assert.equal(timeoutMs, 30_000);
139+
timeoutCallback();
140+
141+
await assert.rejects(next, (error) => {
142+
assert.equal(error.message, RUN_SSE_FIRST_EVENT_TIMEOUT_ERROR);
143+
assert.match(error.message, /30 SSE /);
144+
assert.match(error.message, //);
145+
return true;
146+
});
147+
});
148+
149+
test("runSSE clears the first-event deadline after yielding the first event", async (t) => {
150+
const previousFetch = globalThis.fetch;
151+
const previousSetTimeout = globalThis.setTimeout;
152+
const previousClearTimeout = globalThis.clearTimeout;
153+
t.after(() => {
154+
globalThis.fetch = previousFetch;
155+
globalThis.setTimeout = previousSetTimeout;
156+
globalThis.clearTimeout = previousClearTimeout;
157+
});
158+
159+
let requestSignal;
160+
let timeoutCallback;
161+
let clearedTimer;
162+
globalThis.setTimeout = (callback, ms, ...args) => {
163+
assert.equal(ms, 30_000);
164+
timeoutCallback = () => callback(...args);
165+
return 7;
166+
};
167+
globalThis.clearTimeout = (timer) => {
168+
clearedTimer = timer;
169+
};
170+
globalThis.fetch = async (_url, init) => {
171+
requestSignal = init.signal;
172+
return new Response(
173+
'data: {"partial":true,"content":{"parts":[{"text":"part"}]}}\n\n',
174+
{
175+
status: 200,
176+
headers: { "Content-Type": "text/event-stream" },
177+
},
178+
);
179+
};
180+
181+
const events = runSSE({
182+
appName: "agent",
183+
userId: "user",
184+
sessionId: "session",
185+
text: "hello",
186+
});
187+
188+
const first = await events.next();
189+
assert.equal(first.done, false);
190+
assert.equal(first.value.content.parts[0].text, "part");
191+
assert.equal(clearedTimer, 7);
192+
timeoutCallback();
193+
assert.equal(requestSignal.aborted, false);
194+
195+
await events.return();
196+
});
197+
101198
test("runSSE formats a fetch rejection before any response arrives", async (t) => {
102199
const previousFetch = globalThis.fetch;
103200
t.after(() => {

0 commit comments

Comments
 (0)