From 0ac117f5a59e941e9e8a9b9e710ae053bbe6e848 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 15 Apr 2026 08:18:19 +0000 Subject: [PATCH 1/3] fix: replace persistent abort listener with AbortSignal.any() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old makeTimeoutAbortSignal() added an addEventListener("abort", ...) listener to existingSignal (AWClient.controller.signal) on every request but never removed it. In long-running use (e.g. aw-watcher-web sends a heartbeat every ~5s) this caused hundreds of thousands of AbortController instances to accumulate: ~250k listeners reported in Firefox after 5 days, totalling 5 GB RSS and 100% CPU in the extension process. Replace with AbortSignal.any() + AbortSignal.timeout(): - AbortSignal.timeout() is a built-in that self-cleans up - AbortSignal.any() uses weak-reference semantics (per WHATWG spec) so the combined signal — and any closure holding it — is GC-eligible once no fetch holds a reference to it, preventing persistent accumulation Supported in: Chrome 116+, Firefox 115+, Safari 17.4+, Node.js 20.3+ Fixes: https://github.com/ActivityWatch/aw-watcher-web/issues/222 --- src/aw-client.ts | 50 +++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/aw-client.ts b/src/aw-client.ts index 1f2deb2..71dc609 100644 --- a/src/aw-client.ts +++ b/src/aw-client.ts @@ -83,24 +83,27 @@ interface GetEventsOptions { limit?: number; } -function makeTimeoutAbortSignal( +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 }; +): AbortSignal | undefined { + // Use AbortSignal.any() + AbortSignal.timeout() instead of manually wiring an + // "abort" event listener onto existingSignal. + // + // The previous approach created a new AbortController per request and attached a + // persistent listener to existingSignal (AWClient.controller.signal). That listener + // was never removed, so after days of use — aw-watcher-web fires a heartbeat every + // ~5 s — hundreds of thousands of AbortController instances accumulated, causing + // 5 GB+ RSS and 100% CPU in the Firefox extensions process. + // See: https://github.com/ActivityWatch/aw-watcher-web/issues/222 + // + // AbortSignal.timeout() and AbortSignal.any() are supported in: + // Chrome 116+, Firefox 115+, Safari 17.4+, Node.js 20.3+ + if (timeout === undefined && existingSignal === undefined) return undefined; + const signals: AbortSignal[] = []; + if (timeout !== undefined) signals.push(AbortSignal.timeout(timeout)); + if (existingSignal !== undefined) signals.push(existingSignal); + return signals.length === 1 ? signals[0] : AbortSignal.any(signals); } async function fetchWithFailure( @@ -108,16 +111,11 @@ async function fetchWithFailure( init: RequestInit, timeout?: number, ): Promise { - const { signal, timeoutId } = makeTimeoutAbortSignal( - timeout, - init.signal || undefined, - ); - return fetch(input, { ...init, signal }) - .then((res) => { - if (res.status >= 300) throw new FetchError(res); - return res; - }) - .finally(() => clearTimeout(timeoutId)); + const signal = makeTimeoutSignal(timeout, init.signal || undefined); + return fetch(input, { ...init, signal }).then((res) => { + if (res.status >= 300) throw new FetchError(res); + return res; + }); } export class AWClient { From 8bffa29b5f2b1b335183c180e65ec63906b44b30 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 09:16:16 +0000 Subject: [PATCH 2/3] fix(abort): restore compatibility while cleaning up listeners --- src/aw-client.ts | 84 ++++++++++++++++++++++++++++++++++++------------ src/test/test.ts | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/aw-client.ts b/src/aw-client.ts index 71dc609..ba65c9e 100644 --- a/src/aw-client.ts +++ b/src/aw-client.ts @@ -83,27 +83,64 @@ interface GetEventsOptions { limit?: number; } +type TimeoutSignalResult = { + signal?: AbortSignal; + cleanup: () => void; +}; + function makeTimeoutSignal( timeout?: number, existingSignal?: AbortSignal, -): AbortSignal | undefined { - // Use AbortSignal.any() + AbortSignal.timeout() instead of manually wiring an - // "abort" event listener onto existingSignal. +): TimeoutSignalResult { + // Create a per-request AbortController and explicitly clean up both the timeout + // and the propagated abort listener when the request finishes. // - // The previous approach created a new AbortController per request and attached a - // persistent listener to existingSignal (AWClient.controller.signal). That listener - // was never removed, so after days of use — aw-watcher-web fires a heartbeat every - // ~5 s — hundreds of thousands of AbortController instances accumulated, causing - // 5 GB+ RSS and 100% CPU in the Firefox extensions process. + // 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 - // - // AbortSignal.timeout() and AbortSignal.any() are supported in: - // Chrome 116+, Firefox 115+, Safari 17.4+, Node.js 20.3+ - if (timeout === undefined && existingSignal === undefined) return undefined; - const signals: AbortSignal[] = []; - if (timeout !== undefined) signals.push(AbortSignal.timeout(timeout)); - if (existingSignal !== undefined) signals.push(existingSignal); - return signals.length === 1 ? signals[0] : AbortSignal.any(signals); + 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( @@ -111,11 +148,16 @@ async function fetchWithFailure( init: RequestInit, timeout?: number, ): Promise { - const signal = makeTimeoutSignal(timeout, init.signal || undefined); - return fetch(input, { ...init, signal }).then((res) => { - if (res.status >= 300) throw new FetchError(res); - return res; - }); + const { signal, cleanup } = makeTimeoutSignal( + timeout, + init.signal || undefined, + ); + return fetch(input, { ...init, signal }) + .then((res) => { + if (res.status >= 300) throw new FetchError(res); + return res; + }) + .finally(cleanup); } export class AWClient { diff --git a/src/test/test.ts b/src/test/test.ts index cc4982a..db15628 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -232,4 +232,64 @@ 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; + } + }); }); From aa2f775f0c6f87fc5fb776f3f9d75bd4cf84a9f0 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 10:04:41 +0000 Subject: [PATCH 3/3] style: run prettier on test.ts to fix lint CI --- src/test/test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/test.ts b/src/test/test.ts index db15628..c7a11e0 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -241,7 +241,8 @@ describe("API config behavior", () => { const signal = awc.controller.signal; const originalAddEventListener = signal.addEventListener.bind(signal); - const originalRemoveEventListener = signal.removeEventListener.bind(signal); + const originalRemoveEventListener = + signal.removeEventListener.bind(signal); const originalFetch = global.fetch; let activeAbortListeners = 0;