Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
13c8b2ada5 fix: clear tlon SSE connect timeout (#5926) (thanks @hclsys) 2026-02-01 15:39:37 -08:00
chenglun.hu
261a05bfc7 fix(tlon): add timeout to SSE client fetch calls (CWE-400)
Add timeout protection to prevent indefinite hangs when Urbit server
becomes unresponsive or network partition occurs.

Changes:
- Add AbortSignal.timeout(30_000) to 7 one-shot fetch calls
- Add AbortController with 60s connection timeout to SSE stream fetch
  (clears timeout after headers received to avoid aborting active stream)

Affected methods: sendSubscription, connect, openStream, poke, scry, close

Fixes #5266

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:34:02 -08:00
3 changed files with 36 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.
- TUI: prevent crash when searching with digits in the model selector.
- Tlon: clear SSE connection timeout on failed connect. (#5926) Thanks @hclsys.
- Agents: wire before_tool_call plugin hook into tool execution. (#6570) Thanks @ryancnelson.
- Browser: secure Chrome extension relay CDP sessions.
- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.

View File

@@ -37,4 +37,23 @@ describe("UrbitSSEClient", () => {
path: "/dm/~zod",
});
});
it("clears the connection timeout when fetch fails", async () => {
const timeoutId = 123 as unknown as NodeJS.Timeout;
const setTimeoutSpy = vi.spyOn(global, "setTimeout").mockReturnValue(timeoutId);
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
mockFetch.mockRejectedValue(new Error("network down"));
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
try {
await expect(client.openStream()).rejects.toThrow("network down");
expect(setTimeoutSpy).toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
} finally {
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
}
});
});

View File

@@ -114,6 +114,7 @@ export class UrbitSSEClient {
Cookie: this.cookie,
},
body: JSON.stringify([subscription]),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok && response.status !== 204) {
@@ -130,6 +131,7 @@ export class UrbitSSEClient {
Cookie: this.cookie,
},
body: JSON.stringify(this.subscriptions),
signal: AbortSignal.timeout(30_000),
});
if (!createResp.ok && createResp.status !== 204) {
@@ -152,6 +154,7 @@ export class UrbitSSEClient {
json: "Opening API channel",
},
]),
signal: AbortSignal.timeout(30_000),
});
if (!pokeResp.ok && pokeResp.status !== 204) {
@@ -164,12 +167,21 @@ export class UrbitSSEClient {
}
async openStream() {
// Use AbortController with manual timeout so we only abort during initial connection,
// not after the SSE stream is established and actively streaming.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60_000);
const response = await fetch(this.channelUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
signal: controller.signal,
}).finally(() => {
// Clear timeout once connection established (headers received) or on failure.
clearTimeout(timeoutId);
});
if (!response.ok) {
@@ -279,6 +291,7 @@ export class UrbitSSEClient {
Cookie: this.cookie,
},
body: JSON.stringify([pokeData]),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok && response.status !== 204) {
@@ -296,6 +309,7 @@ export class UrbitSSEClient {
headers: {
Cookie: this.cookie,
},
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
@@ -364,6 +378,7 @@ export class UrbitSSEClient {
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
signal: AbortSignal.timeout(30_000),
});
await fetch(this.channelUrl, {
@@ -371,6 +386,7 @@ export class UrbitSSEClient {
headers: {
Cookie: this.cookie,
},
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);