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 <noreply@anthropic.com>
This commit is contained in:
hcl 2026-02-02 07:40:27 +08:00 committed by GitHub
parent 19775abdda
commit 411d5fda58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -114,6 +114,7 @@ export class UrbitSSEClient {
Cookie: this.cookie, Cookie: this.cookie,
}, },
body: JSON.stringify([subscription]), body: JSON.stringify([subscription]),
signal: AbortSignal.timeout(30_000),
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
@ -130,6 +131,7 @@ export class UrbitSSEClient {
Cookie: this.cookie, Cookie: this.cookie,
}, },
body: JSON.stringify(this.subscriptions), body: JSON.stringify(this.subscriptions),
signal: AbortSignal.timeout(30_000),
}); });
if (!createResp.ok && createResp.status !== 204) { if (!createResp.ok && createResp.status !== 204) {
@ -152,6 +154,7 @@ export class UrbitSSEClient {
json: "Opening API channel", json: "Opening API channel",
}, },
]), ]),
signal: AbortSignal.timeout(30_000),
}); });
if (!pokeResp.ok && pokeResp.status !== 204) { if (!pokeResp.ok && pokeResp.status !== 204) {
@ -164,14 +167,23 @@ export class UrbitSSEClient {
} }
async openStream() { 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, { const response = await fetch(this.channelUrl, {
method: "GET", method: "GET",
headers: { headers: {
Accept: "text/event-stream", Accept: "text/event-stream",
Cookie: this.cookie, Cookie: this.cookie,
}, },
signal: controller.signal,
}); });
// Clear timeout once connection established (headers received)
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
throw new Error(`Stream connection failed: ${response.status}`); throw new Error(`Stream connection failed: ${response.status}`);
} }
@ -279,6 +291,7 @@ export class UrbitSSEClient {
Cookie: this.cookie, Cookie: this.cookie,
}, },
body: JSON.stringify([pokeData]), body: JSON.stringify([pokeData]),
signal: AbortSignal.timeout(30_000),
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
@ -296,6 +309,7 @@ export class UrbitSSEClient {
headers: { headers: {
Cookie: this.cookie, Cookie: this.cookie,
}, },
signal: AbortSignal.timeout(30_000),
}); });
if (!response.ok) { if (!response.ok) {
@ -364,6 +378,7 @@ export class UrbitSSEClient {
Cookie: this.cookie, Cookie: this.cookie,
}, },
body: JSON.stringify(unsubscribes), body: JSON.stringify(unsubscribes),
signal: AbortSignal.timeout(30_000),
}); });
await fetch(this.channelUrl, { await fetch(this.channelUrl, {
@ -371,6 +386,7 @@ export class UrbitSSEClient {
headers: { headers: {
Cookie: this.cookie, Cookie: this.cookie,
}, },
signal: AbortSignal.timeout(30_000),
}); });
} catch (error) { } catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`); this.logger.error?.(`Error closing channel: ${String(error)}`);