-
Notifications
You must be signed in to change notification settings - Fork 27.8k
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
fix: work around setTimeout memory leak, improve wrappers #75727
Merged
+41
−5
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Tests Passed |
huozhi
approved these changes
Feb 6, 2025
ztanner
pushed a commit
that referenced
this pull request
Feb 10, 2025
Loading
Loading status checks…
Potential fix for a leak reported in #74855 on older node versions (see [comment](#74855 (comment))). ### Background When running middleware (or other edge functions) in `next start`, we wrap them in an edge runtime sandbox. this includes polyfills of `setTimeout` and `setInterval` which return `number` instead of `NodeJS.Timeout`. Unfortunately, on some older node versions, converting a `NodeJS.Timeout` to a number will cause that timeout to leak: nodejs/node#53335 The leaked timeout will also hold onto the callback, thus also leaking anything that was closed over (which can be a lot of things!) ### Solution Ideally, users just upgrade to a Node version that includes the fix: - [node v20.16.0](nodejs/node#53945) - [node v22.4.0](nodejs/node#53583) - node v23.0.0 But we're currently still supporting node 18, so we can't necessarily rely on that. Luckily, as noted in the description of the nodejs issue, calling `clearTimeout` seems to unleak the timeout, so we can just do that after the callback runs! ### Unrelated stuff I did While i was at it, I also fixed a (very niche) discrepancy from how `setTimeout` and `setInterval` behave on the web. when running the callback, node sets `this` to the Timeout instance: ```js > void setTimeout(function () {console.log('this in setTimeout:', this) } ) undefined > this in setTimeout: Timeout { ... } ``` but on the web, `this` is always set to `globalThis`. Our wrapper now correctly does this. ### Testing <details> <summary>Collapsed because it's long</summary> Verifying this is kinda tricky, so bear with me... Here's a script that can verify whether calling `clearTimeout` fixes the leak by using a FinalizationRegistry and triggering GC to observe whether memory leaked or not. `setTimeoutWithFix` is a simplified version of `webSetTimeoutPolyfill` from the PR. ```js // setTimeout-test.js if (typeof gc !== 'function') { console.log('this test must be run with --expose-gc') process.exit(1) } function setTimeoutWithFix(callback, ms, ...args) { const wrappedCallback = function () { try { return callback.apply(this, args) } finally { clearTimeout(timeout) } } const timeout = setTimeout(wrappedCallback, ms) return timeout } const didFinalize = {} const registry = new FinalizationRegistry((id) => { didFinalize[id] = true }) { const id = 'node setTimeout'.padEnd(26, ' ') const timeout = setTimeout(() => {}, 0) registry.register(timeout, id) didFinalize[id] = false } { const id = 'node setTimeout as number'.padEnd(26, ' ') const timeout = setTimeout(() => {}, 0) timeout[Symbol.toPrimitive]() registry.register(timeout, id) didFinalize[id] = false } { const id = 'fixed setTimeout'.padEnd(26, ' ') const timeout = setTimeoutWithFix(() => {}, 0) registry.register(timeout, id) didFinalize[id] = false } { const id = 'fixed setTimeout as number'.padEnd(26, ' ') const timeout = setTimeoutWithFix(() => {}, 0) timeout[Symbol.toPrimitive]() registry.register(timeout, id) didFinalize[id] = false } // wait for the timeouts to run void setTimeout(() => { gc() // trigger garbage collection void registry // ...but make sure we keep the registry alive // wait a task so that finalization callbacks can run setTimeout(() => console.log('did the Timeout get released after GC?', didFinalize) ) }, 10) ``` To run it, Install the required node versions: ```bash for ver in v20.15.0 v20.16.0 v22.3.0 v22.4.0 v23.0.0; do ( nvm install "$ver" ); done ``` And run the test: ```bash for ver in v20.15.0 v20.16.0 v22.3.0 v22.4.0 v23.0.0; do ( echo '-------------------' nvm use "$ver" && node --expose-gc setTimeout-test.js echo ); done ``` The output on my machine is as follows. Note that the `node setTimeout as number` case comes up as false on the older versions (because it leaks and doesn't get finalized), but `fixed setTimeout as number` (which calls `clearTimeout`) gets released fine, which is exactly what we want. ```terminal ------------------- Now using node v20.15.0 (npm v10.7.0) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': false, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v20.16.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v22.3.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': false, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v22.4.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v23.0.0 (npm v10.9.0) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ``` </details>
ztanner
pushed a commit
that referenced
this pull request
Feb 10, 2025
Loading
Loading status checks…
Potential fix for a leak reported in #74855 on older node versions (see [comment](#74855 (comment))). ### Background When running middleware (or other edge functions) in `next start`, we wrap them in an edge runtime sandbox. this includes polyfills of `setTimeout` and `setInterval` which return `number` instead of `NodeJS.Timeout`. Unfortunately, on some older node versions, converting a `NodeJS.Timeout` to a number will cause that timeout to leak: nodejs/node#53335 The leaked timeout will also hold onto the callback, thus also leaking anything that was closed over (which can be a lot of things!) ### Solution Ideally, users just upgrade to a Node version that includes the fix: - [node v20.16.0](nodejs/node#53945) - [node v22.4.0](nodejs/node#53583) - node v23.0.0 But we're currently still supporting node 18, so we can't necessarily rely on that. Luckily, as noted in the description of the nodejs issue, calling `clearTimeout` seems to unleak the timeout, so we can just do that after the callback runs! ### Unrelated stuff I did While i was at it, I also fixed a (very niche) discrepancy from how `setTimeout` and `setInterval` behave on the web. when running the callback, node sets `this` to the Timeout instance: ```js > void setTimeout(function () {console.log('this in setTimeout:', this) } ) undefined > this in setTimeout: Timeout { ... } ``` but on the web, `this` is always set to `globalThis`. Our wrapper now correctly does this. ### Testing <details> <summary>Collapsed because it's long</summary> Verifying this is kinda tricky, so bear with me... Here's a script that can verify whether calling `clearTimeout` fixes the leak by using a FinalizationRegistry and triggering GC to observe whether memory leaked or not. `setTimeoutWithFix` is a simplified version of `webSetTimeoutPolyfill` from the PR. ```js // setTimeout-test.js if (typeof gc !== 'function') { console.log('this test must be run with --expose-gc') process.exit(1) } function setTimeoutWithFix(callback, ms, ...args) { const wrappedCallback = function () { try { return callback.apply(this, args) } finally { clearTimeout(timeout) } } const timeout = setTimeout(wrappedCallback, ms) return timeout } const didFinalize = {} const registry = new FinalizationRegistry((id) => { didFinalize[id] = true }) { const id = 'node setTimeout'.padEnd(26, ' ') const timeout = setTimeout(() => {}, 0) registry.register(timeout, id) didFinalize[id] = false } { const id = 'node setTimeout as number'.padEnd(26, ' ') const timeout = setTimeout(() => {}, 0) timeout[Symbol.toPrimitive]() registry.register(timeout, id) didFinalize[id] = false } { const id = 'fixed setTimeout'.padEnd(26, ' ') const timeout = setTimeoutWithFix(() => {}, 0) registry.register(timeout, id) didFinalize[id] = false } { const id = 'fixed setTimeout as number'.padEnd(26, ' ') const timeout = setTimeoutWithFix(() => {}, 0) timeout[Symbol.toPrimitive]() registry.register(timeout, id) didFinalize[id] = false } // wait for the timeouts to run void setTimeout(() => { gc() // trigger garbage collection void registry // ...but make sure we keep the registry alive // wait a task so that finalization callbacks can run setTimeout(() => console.log('did the Timeout get released after GC?', didFinalize) ) }, 10) ``` To run it, Install the required node versions: ```bash for ver in v20.15.0 v20.16.0 v22.3.0 v22.4.0 v23.0.0; do ( nvm install "$ver" ); done ``` And run the test: ```bash for ver in v20.15.0 v20.16.0 v22.3.0 v22.4.0 v23.0.0; do ( echo '-------------------' nvm use "$ver" && node --expose-gc setTimeout-test.js echo ); done ``` The output on my machine is as follows. Note that the `node setTimeout as number` case comes up as false on the older versions (because it leaks and doesn't get finalized), but `fixed setTimeout as number` (which calls `clearTimeout`) gets released fine, which is exactly what we want. ```terminal ------------------- Now using node v20.15.0 (npm v10.7.0) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': false, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v20.16.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v22.3.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': false, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v22.4.0 (npm v10.8.1) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ------------------- Now using node v23.0.0 (npm v10.9.0) did the Timeout get released after GC? { 'node setTimeout ': true, 'node setTimeout as number ': true, 'fixed setTimeout ': true, 'fixed setTimeout as number': true } ``` </details>
ztanner
added a commit
that referenced
this pull request
Feb 10, 2025
Loading
Loading status checks…
Backports: - #75727 Co-authored-by: Janka Uryga <lolzatu2@gmail.com>
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Potential fix for a leak reported in #74855 on older node versions (see comment).
Background
When running middleware (or other edge functions) in
next start
, we wrap them in an edge runtime sandbox. this includes polyfills ofsetTimeout
andsetInterval
which returnnumber
instead ofNodeJS.Timeout
.Unfortunately, on some older node versions, converting a
NodeJS.Timeout
to a number will cause that timeout to leak:nodejs/node#53335
The leaked timeout will also hold onto the callback, thus also leaking anything that was closed over (which can be a lot of things!)
Solution
Ideally, users just upgrade to a Node version that includes the fix:
But we're currently still supporting node 18, so we can't necessarily rely on that. Luckily, as noted in the description of the nodejs issue, calling
clearTimeout
seems to unleak the timeout, so we can just do that after the callback runs!Unrelated stuff I did
While i was at it, I also fixed a (very niche) discrepancy from how
setTimeout
andsetInterval
behave on the web. when running the callback, node setsthis
to the Timeout instance:but on the web,
this
is always set toglobalThis
. Our wrapper now correctly does this.Testing
Collapsed because it's long
Verifying this is kinda tricky, so bear with me...
Here's a script that can verify whether calling
clearTimeout
fixes the leak by using a FinalizationRegistry and triggering GC to observe whether memory leaked or not.setTimeoutWithFix
is a simplified version ofwebSetTimeoutPolyfill
from the PR.To run it, Install the required node versions:
And run the test:
The output on my machine is as follows. Note that the
node setTimeout as number
case comes up as false on the older versions (because it leaks and doesn't get finalized), butfixed setTimeout as number
(which callsclearTimeout
) gets released fine, which is exactly what we want.