fix: replace persistent abort listener with AbortSignal.any() to prevent memory leak#50
Conversation
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: ActivityWatch/aw-watcher-web#222
Greptile SummaryThis PR fixes a genuine memory leak in Note: the PR title and the "Before/After" section of the description describe an Confidence Score: 5/5Safe to merge — the fix is correct, idempotent, and well-tested; both prior review concerns are resolved by the explicit cleanup approach. All execution paths (success, timeout, external abort, pre-aborted signal) correctly invoke the idempotent cleanup() exactly once, removing the listener and clearing the timer. No remaining P0/P1 findings; the only open item is a PR title/description mismatch that does not affect runtime behaviour. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["makeTimeoutSignal(timeout, existingSignal)"] --> B{Both undefined?}
B -- Yes --> C["return { signal: undefined, cleanup: no-op }"]
B -- No --> D["new AbortController()"]
D --> E{timeout defined?}
E -- Yes --> F["setTimeout → controller.abort() + cleanup()"]
E -- No --> G{existingSignal defined?}
F --> G
G -- Yes --> H{existingSignal.aborted?}
H -- Yes --> I["controller.abort() + cleanup() → early return"]
H -- No --> J["addEventListener('abort', abortListener, {once:true})"]
J --> K["return { signal: controller.signal, cleanup }"]
G -- No --> K
K --> L["fetch(…, { signal })"]
L --> M[".finally(cleanup)"]
M --> N["clearTimeout(timeoutId)\nremoveEventListener('abort', abortListener)"]
Reviews (2): Last reviewed commit: "style: run prettier on test.ts to fix li..." | Re-trigger Greptile |
| 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); |
There was a problem hiding this comment.
Browser/Node.js runtime compatibility gap
AbortSignal.any() requires Chrome 116+, Firefox 115+, Safari 17.4+ (March 2024), and Node.js 20.3+. On any older runtime, AbortSignal.any is undefined, so makeTimeoutSignal will throw TypeError: AbortSignal.any is not a function on every request where both a timeout and an existing signal are present — effectively breaking all network calls. Node.js 18 LTS (EOL April 2025) and Safari < 17.4 are the most likely affected environments.
The trade-off is explicitly documented in the PR description, but since package.json has no engines field and tsconfig.json targets es6, downstream consumers have no compile-time or install-time warning. Consider adding "engines": { "node": ">=20.3" } to package.json and/or a one-line runtime guard like:
if (typeof AbortSignal.any !== "function") {
// fallback: return existingSignal or a basic timeout signal
}| // 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)); |
There was a problem hiding this comment.
Timeout timer not cancelled after successful fetch
The old code called clearTimeout(timeoutId) in .finally() to immediately release the timer once the request completed. AbortSignal.timeout() provides no equivalent hook, so its internal timer continues running for the full timeout duration even after the response has been received. In Node.js 20+ this is benign because the timer is unref()-ed and won't delay process exit. In browsers it is also harmless. Still, for a this.timeout = 30_000 ms default, each completed request now holds a live (though side-effect-free) timer for up to 30 seconds, which could be surprising during profiling or in environments with very high request volume.
The console.debug calls passed entire Tab objects, which in Firefox include favIconUrl as base64 data URIs. Browser console buffers retain references to logged objects, so over thousands of heartbeats this accumulated hundreds of MBs of duplicate favicon data in memory. Fix: destructure only needed fields from Tab objects before use, and log only the URL string instead of the full Tab object. This addresses the favicon accumulation half of ActivityWatch#222 (the AbortController leak is being fixed in aw-client-js via ActivityWatch/aw-client-js#50).
* fix: prevent favicon data retention in heartbeat debug logs The console.debug calls passed entire Tab objects, which in Firefox include favIconUrl as base64 data URIs. Browser console buffers retain references to logged objects, so over thousands of heartbeats this accumulated hundreds of MBs of duplicate favicon data in memory. Fix: destructure only needed fields from Tab objects before use, and log only the URL string instead of the full Tab object. This addresses the favicon accumulation half of #222 (the AbortController leak is being fixed in aw-client-js via ActivityWatch/aw-client-js#50). * fix(heartbeat): restore previousData and data in debug logs These objects contain only small fields (url, title, audible, incognito, tabCount) — no favIconUrl or large data. Removing them was unnecessary and reduced debugging utility without fixing any memory issue. The memory leak came from logging full Tab objects in the listener functions, not from these internal heartbeat log calls. Addresses Greptile review feedback on #223.
|
@TimeToBuildBob Address review comments? |
|
Addressed the review comments. I dropped the
So this keeps the memory leak fix without requiring newer runtimes. I also added a focused test that verifies the propagated abort listener count returns to zero after a successful request. Commit pushed: |
|
@greptileai review |
Problem
makeTimeoutAbortSignal()attached anaddEventListener("abort", ...)listener toexistingSignal(which isAWClient.controller.signal) on every request, but never removed it. SinceAWClientis a singleton in long-running consumers,controller.signalaccumulates one listener per request — forever.In practice: aw-watcher-web sends a heartbeat every ~5 seconds. After 5 days of browsing without restarting Firefox, this produced ~250,000 accumulated
AbortControllerinstances (reported byabout:memoryas~123 MB of AbortController objects+~645 MB of closures), causing 5 GB+ RSS and 100% CPU in the extensions process.See: ActivityWatch/aw-watcher-web#222
Fix
Replace the manual
setTimeout+addEventListenerpattern withAbortSignal.any()+AbortSignal.timeout():AbortSignal.timeout(ms)is a self-managing built-in — noclearTimeoutneededAbortSignal.any([...signals])uses weak-reference semantics (per the WHATWG spec; verified in Blink/Gecko): the combined signal — and its closure references — become GC-eligible once the fetch holds no more references to itBrowser support: Chrome 116+, Firefox 115+, Safari 17.4+, Node.js 20.3+
Before / After