Skip to content

Commit 411d5fd

Browse files
hclclaude
andauthored
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 <[email protected]>
1 parent 19775ab commit 411d5fd

1 file changed

Lines changed: 16 additions & 0 deletions

File tree

extensions/tlon/src/urbit/sse-client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class UrbitSSEClient {
114114
Cookie: this.cookie,
115115
},
116116
body: JSON.stringify([subscription]),
117+
signal: AbortSignal.timeout(30_000),
117118
});
118119

119120
if (!response.ok && response.status !== 204) {
@@ -130,6 +131,7 @@ export class UrbitSSEClient {
130131
Cookie: this.cookie,
131132
},
132133
body: JSON.stringify(this.subscriptions),
134+
signal: AbortSignal.timeout(30_000),
133135
});
134136

135137
if (!createResp.ok && createResp.status !== 204) {
@@ -152,6 +154,7 @@ export class UrbitSSEClient {
152154
json: "Opening API channel",
153155
},
154156
]),
157+
signal: AbortSignal.timeout(30_000),
155158
});
156159

157160
if (!pokeResp.ok && pokeResp.status !== 204) {
@@ -164,14 +167,23 @@ export class UrbitSSEClient {
164167
}
165168

166169
async openStream() {
170+
// Use AbortController with manual timeout so we only abort during initial connection,
171+
// not after the SSE stream is established and actively streaming.
172+
const controller = new AbortController();
173+
const timeoutId = setTimeout(() => controller.abort(), 60_000);
174+
167175
const response = await fetch(this.channelUrl, {
168176
method: "GET",
169177
headers: {
170178
Accept: "text/event-stream",
171179
Cookie: this.cookie,
172180
},
181+
signal: controller.signal,
173182
});
174183

184+
// Clear timeout once connection established (headers received)
185+
clearTimeout(timeoutId);
186+
175187
if (!response.ok) {
176188
throw new Error(`Stream connection failed: ${response.status}`);
177189
}
@@ -279,6 +291,7 @@ export class UrbitSSEClient {
279291
Cookie: this.cookie,
280292
},
281293
body: JSON.stringify([pokeData]),
294+
signal: AbortSignal.timeout(30_000),
282295
});
283296

284297
if (!response.ok && response.status !== 204) {
@@ -296,6 +309,7 @@ export class UrbitSSEClient {
296309
headers: {
297310
Cookie: this.cookie,
298311
},
312+
signal: AbortSignal.timeout(30_000),
299313
});
300314

301315
if (!response.ok) {
@@ -364,13 +378,15 @@ export class UrbitSSEClient {
364378
Cookie: this.cookie,
365379
},
366380
body: JSON.stringify(unsubscribes),
381+
signal: AbortSignal.timeout(30_000),
367382
});
368383

369384
await fetch(this.channelUrl, {
370385
method: "DELETE",
371386
headers: {
372387
Cookie: this.cookie,
373388
},
389+
signal: AbortSignal.timeout(30_000),
374390
});
375391
} catch (error) {
376392
this.logger.error?.(`Error closing channel: ${String(error)}`);

0 commit comments

Comments
 (0)