Skip to content

Commit 0993bbf

Browse files
authored
fix: prevent memory leak in stream abort race (#223)
Closes #222 - Create a fresh abort promise for each `stream.next()` call. - Remove the abort listener as soon as either side of the race settles. - Recheck the signal after registering the listener to handle an already-aborted signal. This prevents promise reactions and decoded message buffers from accumulating on a long-lived pending abort promise.
2 parents 50397c4 + 9190bcb commit 0993bbf

2 files changed

Lines changed: 30 additions & 13 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: refactor abort handling in stream processing",
4+
"packageName": "@apibara/indexer",
5+
"email": "jadejajaipal5@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

packages/indexer/src/indexer.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -351,17 +351,15 @@ export async function run<TFilter, TBlock>(
351351

352352
let onConnectCalled = false;
353353

354-
const abortPromise = abortSignal ? waitForAbort(abortSignal) : null;
355-
356354
while (true) {
357355
let result: IteratorResult<
358356
StreamDataResponse<TBlock>,
359357
StreamDataResponse<TBlock>
360358
>;
361359

362360
try {
363-
result = abortPromise
364-
? await Promise.race([stream.next(), abortPromise])
361+
result = abortSignal
362+
? await nextWithAbort(stream, abortSignal)
365363
: await stream.next();
366364
} catch (e) {
367365
if (abortSignal?.aborted) {
@@ -578,19 +576,31 @@ export async function run<TFilter, TBlock>(
578576
}
579577

580578
/**
581-
* Returns a promise that rejects as soon as the given AbortSignal fires.
582-
* Used to race against stream.next() so the loop can break on abort.
579+
* Races one stream read against an abort signal and removes its listener when
580+
* either settles. Using a fresh abort promise prevents reactions from building
581+
* up on one long-lived pending promise.
583582
*/
584-
function waitForAbort(signal: AbortSignal): Promise<never> {
585-
return new Promise<never>((_, reject) => {
586-
if (signal.aborted) {
587-
reject(signal.reason);
588-
return;
589-
}
590-
signal.addEventListener("abort", () => reject(signal.reason), {
583+
async function nextWithAbort<T>(
584+
stream: AsyncIterator<T, T>,
585+
signal: AbortSignal,
586+
): Promise<IteratorResult<T, T>> {
587+
let onAbort: () => void = () => {};
588+
const abortPromise = new Promise<never>((_, reject) => {
589+
onAbort = () => reject(signal.reason);
590+
signal.addEventListener("abort", onAbort, {
591591
once: true,
592592
});
593+
594+
if (signal.aborted) {
595+
onAbort();
596+
}
593597
});
598+
599+
try {
600+
return await Promise.race([stream.next(), abortPromise]);
601+
} finally {
602+
signal.removeEventListener("abort", onAbort);
603+
}
594604
}
595605

596606
async function registerMiddleware<TFilter, TBlock>(

0 commit comments

Comments
 (0)