Skip to content

Commit b9daf9a

Browse files
committed
fix: connection abort
1 parent 5533eb1 commit b9daf9a

3 files changed

Lines changed: 42 additions & 11 deletions

File tree

src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -685,15 +685,18 @@ async function fetchWithTimeout(
685685
if (!timeoutMs) return fetchFn(input, init);
686686
const timeoutController = new AbortController();
687687
const t = setTimeout(() => timeoutController.abort(), timeoutMs);
688-
const { signal, cleanUpSignals } = combineAbortSignals([init.signal, timeoutController.signal]);
688+
// Note: We intentionally don't call cleanUpSignals() here because for streaming
689+
// responses (like SSE), the connection remains open after the response headers
690+
// are received. The abort signal needs to remain connected so that close() can
691+
// propagate the abort through the signal chain.
692+
const { signal } = combineAbortSignals([init.signal, timeoutController.signal]);
689693
try {
690694
return await fetchFn(input, {
691695
...init,
692696
signal,
693697
});
694698
} finally {
695699
clearTimeout(t);
696-
cleanUpSignals();
697700
}
698701
}
699702

@@ -703,7 +706,7 @@ async function* fetchSse(params: {
703706
fetchFn: typeof fetch;
704707
url: string;
705708
timeoutMs: number;
706-
body?: unknown;
709+
body?: string;
707710
headers?: Record<string, string>;
708711
method?: string;
709712
signal?: AbortSignal;
@@ -719,7 +722,7 @@ async function* fetchSse(params: {
719722
{
720723
method: params.method ?? "GET",
721724
headers: { Accept: "text/event-stream", ...(params.headers ?? {}) },
722-
body: params.body ? JSON.stringify(params.body) : undefined,
725+
body: params.body,
723726
signal,
724727
},
725728
params.timeoutMs,

tests/index.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,11 @@ describe("createReplaneClient", () => {
15081508
it("should stop receiving updates after close", async () => {
15091509
clientPromise = createClient();
15101510
const connection = await mockServer.acceptConnection();
1511+
1512+
// Verify signal is passed to the mock
1513+
expect(connection.hasSignal).toBe(true);
1514+
expect(connection.aborted).toBe(false);
1515+
15111516
await connection.push({
15121517
type: "init",
15131518
configs: [{ name: "config1", overrides: [], version: 1, value: "initial" }],
@@ -1520,6 +1525,9 @@ describe("createReplaneClient", () => {
15201525
client.close();
15211526
await sync();
15221527

1528+
// Verify signal was aborted after close
1529+
expect(connection.aborted).toBe(true);
1530+
15231531
// This shouldn't update the config
15241532
await connection.push({
15251533
type: "config_change",

tests/utils.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export function createReplaneServerMock(handler: ReplaneServerMockHandler) {
7272
});
7373
}
7474

75-
const body = JSON.parse(await req.json()) as StartReplicationStreamBody;
75+
const body = (await req.json()) as StartReplicationStreamBody;
7676

7777
if (!Array.isArray(body.currentConfigs)) {
7878
return new Response("Invalid request", { status: 400 });
@@ -198,48 +198,68 @@ export class MockReplaneServerController {
198198
export class MockReplaneServerConnection {
199199
private readonly _events = new Channel<ReplicationStreamRecord>();
200200
private readonly _signal?: AbortSignal;
201+
private _closed = false;
201202

202203
constructor(
203-
private readonly body: StartReplicationStreamBody,
204+
private readonly _body: StartReplicationStreamBody,
204205
signal?: AbortSignal
205206
) {
206207
this._signal = signal;
207208

208209
// Close the channel when the signal is aborted
209210
if (signal) {
210-
signal.addEventListener("abort", () => {
211+
if (signal.aborted) {
212+
this._closed = true;
211213
this._events.close();
212-
});
214+
} else {
215+
signal.addEventListener("abort", () => {
216+
this._closed = true;
217+
this._events.close();
218+
});
219+
}
213220
}
214221
}
215222

216223
get signal(): AbortSignal | undefined {
217224
return this._signal;
218225
}
219226

227+
get hasSignal(): boolean {
228+
return this._signal !== undefined;
229+
}
230+
220231
get aborted(): boolean {
221-
return this._signal?.aborted ?? false;
232+
return this._closed || (this._signal?.aborted ?? false);
233+
}
234+
235+
get closed(): boolean {
236+
return this._closed;
222237
}
223238

224239
get events(): AsyncIterable<ReplicationStreamRecord> {
225240
return this._events;
226241
}
227242

243+
get requestBody(): StartReplicationStreamBody {
244+
return this._body;
245+
}
246+
228247
async push(event: ReplicationStreamRecord) {
229-
if (this._signal?.aborted) {
248+
if (this._closed || this._signal?.aborted) {
230249
return;
231250
}
232251
await this._events.push(event);
233252
}
234253

235254
async throw(error: Error) {
236-
if (this._signal?.aborted) {
255+
if (this._closed || this._signal?.aborted) {
237256
return;
238257
}
239258
await this._events.throw(error);
240259
}
241260

242261
close() {
262+
this._closed = true;
243263
this._events.close();
244264
}
245265
}

0 commit comments

Comments
 (0)