diff --git a/src/aw-client.ts b/src/aw-client.ts index 1f2deb2..ba65c9e 100644 --- a/src/aw-client.ts +++ b/src/aw-client.ts @@ -83,24 +83,64 @@ interface GetEventsOptions { limit?: number; } -function makeTimeoutAbortSignal( +type TimeoutSignalResult = { + signal?: AbortSignal; + cleanup: () => void; +}; + +function makeTimeoutSignal( timeout?: number, existingSignal?: AbortSignal, -) { - if (timeout === undefined) - return { signal: existingSignal, timeoutId: undefined }; - const abortController = new AbortController(); - const timeoutId = setTimeout( - () => abortController.abort(), - timeout || 10000, - ); - // Sync with existing abort signal if it exists - if (existingSignal?.aborted) abortController.abort(); - else - existingSignal?.addEventListener("abort", () => - abortController.abort(), - ); - return { signal: abortController.signal, timeoutId }; +): TimeoutSignalResult { + // Create a per-request AbortController and explicitly clean up both the timeout + // and the propagated abort listener when the request finishes. + // + // The old code leaked because it added an "abort" listener to the long-lived + // AWClient.controller.signal on every request but never removed it. In consumers + // like aw-watcher-web (heartbeat every ~5 s), that accumulated hundreds of + // thousands of AbortController instances over days of browsing. + // See: https://github.com/ActivityWatch/aw-watcher-web/issues/222 + if (timeout === undefined && existingSignal === undefined) { + return { signal: undefined, cleanup: () => undefined }; + } + + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + let abortListener: (() => void) | undefined; + + const cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (existingSignal !== undefined && abortListener !== undefined) { + existingSignal.removeEventListener("abort", abortListener); + abortListener = undefined; + } + }; + + if (timeout !== undefined) { + timeoutId = setTimeout(() => { + controller.abort(); + cleanup(); + }, timeout); + } + + if (existingSignal !== undefined) { + if (existingSignal.aborted) { + controller.abort(); + cleanup(); + return { signal: controller.signal, cleanup }; + } + + abortListener = () => { + controller.abort(); + cleanup(); + }; + existingSignal.addEventListener("abort", abortListener, { once: true }); + } + + return { signal: controller.signal, cleanup }; } async function fetchWithFailure( @@ -108,7 +148,7 @@ async function fetchWithFailure( init: RequestInit, timeout?: number, ): Promise { - const { signal, timeoutId } = makeTimeoutAbortSignal( + const { signal, cleanup } = makeTimeoutSignal( timeout, init.signal || undefined, ); @@ -117,7 +157,7 @@ async function fetchWithFailure( if (res.status >= 300) throw new FetchError(res); return res; }) - .finally(() => clearTimeout(timeoutId)); + .finally(cleanup); } export class AWClient { diff --git a/src/test/test.ts b/src/test/test.ts index cc4982a..c7a11e0 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -232,4 +232,65 @@ describe("API config behavior", () => { awc.abort(); return caught; }); + + it("cleans up propagated abort listeners after a successful request", async () => { + const awc = new AWClient(clientName, { + testing: true, + timeout: 30_000, + }); + + const signal = awc.controller.signal; + const originalAddEventListener = signal.addEventListener.bind(signal); + const originalRemoveEventListener = + signal.removeEventListener.bind(signal); + const originalFetch = global.fetch; + + let activeAbortListeners = 0; + let addCalls = 0; + let removeCalls = 0; + + signal.addEventListener = (( + type: string, + listener: any, + options?: any, + ) => { + if (type === "abort" && listener !== null) { + activeAbortListeners += 1; + addCalls += 1; + } + originalAddEventListener(type, listener, options); + }) as typeof signal.addEventListener; + + signal.removeEventListener = (( + type: string, + listener: any, + options?: any, + ) => { + if (type === "abort" && listener !== null) { + activeAbortListeners -= 1; + removeCalls += 1; + } + originalRemoveEventListener(type, listener, options); + }) as typeof signal.removeEventListener; + + global.fetch = (() => + Promise.resolve( + new Response(JSON.stringify({ testing: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )) as typeof fetch; + + try { + const resp = await awc.getInfo(); + assert.equal(resp.testing, true); + assert.equal(addCalls, 1); + assert.equal(removeCalls, 1); + assert.equal(activeAbortListeners, 0); + } finally { + signal.addEventListener = originalAddEventListener; + signal.removeEventListener = originalRemoveEventListener; + global.fetch = originalFetch; + } + }); });