-
-
Notifications
You must be signed in to change notification settings - Fork 19
fix: replace persistent abort listener with AbortSignal.any() to prevent memory leak #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,41 +83,39 @@ | |
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The trade-off is explicitly documented in the PR description, but since if (typeof AbortSignal.any !== "function") {
// fallback: return existingSignal or a basic timeout signal
} |
||
| } | ||
|
|
||
| async function fetchWithFailure( | ||
| input: string, | ||
| init: RequestInit, | ||
| timeout?: number, | ||
| ): Promise<Response> { | ||
| 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 { | ||
|
|
@@ -170,7 +168,7 @@ | |
| ).then((res) => res.json() as Promise<T>); | ||
| } | ||
|
|
||
| private async _post(endpoint: string, data: Record<string, any>) { | ||
| return fetchWithFailure( | ||
| `${this.apiURL}${endpoint}`, | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 fulltimeoutduration even after the response has been received. In Node.js 20+ this is benign because the timer isunref()-ed and won't delay process exit. In browsers it is also harmless. Still, for athis.timeout = 30_000ms 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.