From 411d5fda587c5ae44735550f9a23fe224de5da9f Mon Sep 17 00:00:00 2001 From: hcl Date: Mon, 2 Feb 2026 07:40:27 +0800 Subject: [PATCH] fix(tlon): add timeout to SSE client fetch calls (CWE-400) (#5926) 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 --- extensions/tlon/src/urbit/sse-client.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index ec131dd1b5..c985cf9f1d 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -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,14 +167,23 @@ 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, }); + // Clear timeout once connection established (headers received) + clearTimeout(timeoutId); + if (!response.ok) { throw new Error(`Stream connection failed: ${response.status}`); } @@ -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)}`);