From e411e7aa16fc91589abbac57085ae1793b565660 Mon Sep 17 00:00:00 2001 From: Zelys Date: Tue, 28 Apr 2026 14:24:24 -0500 Subject: [PATCH 1/4] fix(server): prevent RequestStore retention via AsyncLocalStorage after SSR Wrap the store passed to `als.run()` in a single-property container so that async resources created inside the render callback (Promise continuations, Svelte 4 component_subscribe callbacks, etc.) only retain a reference to the container rather than the full RequestStore. After the render promise settles we null out `container.current`, allowing the RequestStore and RequestEvent to be garbage-collected even when stale async resources from the render still linger in memory. Without this fix, every `als.run(store, render)` call causes Node.js to associate `kResourceStore = store` with every async resource created during the render (nodejs/node#53408). Under load, hundreds of RequestEvent objects accumulate as those resources are not GC'd, leading to linear heap growth and eventual OOM. Fixes #15764 Co-Authored-By: Claude Sonnet 4.6 --- packages/kit/src/exports/internal/event.js | 28 ++++- .../kit/src/exports/internal/event.spec.js | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 packages/kit/src/exports/internal/event.spec.js diff --git a/packages/kit/src/exports/internal/event.js b/packages/kit/src/exports/internal/event.js index 9d658494e1be..20aca1ce7bf8 100644 --- a/packages/kit/src/exports/internal/event.js +++ b/packages/kit/src/exports/internal/event.js @@ -7,7 +7,9 @@ import { IN_WEBCONTAINER } from '../../runtime/server/constants.js'; /** @type {RequestStore | null} */ let sync_store = null; -/** @type {AsyncLocalStorage | null} */ +/** @typedef {{ current: RequestStore | null }} RequestStoreContainer */ + +/** @type {AsyncLocalStorage | null} */ let als; import('node:async_hooks') @@ -63,7 +65,7 @@ export function get_request_store() { } export function try_get_request_store() { - return sync_store ?? als?.getStore() ?? null; + return sync_store ?? als?.getStore()?.current ?? null; } /** @@ -74,7 +76,27 @@ export function try_get_request_store() { export function with_request_store(store, fn) { try { sync_store = store; - return als ? als.run(store, fn) : fn(); + if (als) { + // Wrap the store in a container so that async resources created inside fn + // (e.g. Svelte 4 subscription callbacks, Promise continuations) only hold a + // reference to the container rather than the full RequestStore. After fn + // completes we null out container.current, allowing the RequestStore and its + // RequestEvent to be garbage-collected even if stale async resources linger. + // See https://github.com/nodejs/node/issues/53408 + const container = /** @type {RequestStoreContainer} */ ({ current: store }); + const result = als.run(container, fn); + if (result !== null && typeof result === 'object' && typeof (/** @type {any} */ (result)).then === 'function') { + return /** @type {T} */ ( + /** @type {Promise} */ (/** @type {unknown} */ (result)).then( + (value) => { container.current = null; return value; }, + (error) => { container.current = null; throw error; } + ) + ); + } + container.current = null; + return result; + } + return fn(); } finally { // Since AsyncLocalStorage is not working in webcontainers, we don't reset `sync_store` // and handle only one request at a time in `src/runtime/server/index.js`. diff --git a/packages/kit/src/exports/internal/event.spec.js b/packages/kit/src/exports/internal/event.spec.js new file mode 100644 index 000000000000..6a3960542cfb --- /dev/null +++ b/packages/kit/src/exports/internal/event.spec.js @@ -0,0 +1,101 @@ +/** @import { RequestStore } from 'types' */ +import { describe, expect, test } from 'vitest'; +import { with_request_store, try_get_request_store, getRequestEvent } from './event.js'; + +/** @returns {RequestStore} */ +function make_store() { + return { + event: /** @type {any} */ ({ url: 'http://localhost/' }), + state: /** @type {any} */ ({}) + }; +} + +describe('with_request_store', () => { + test('exposes store via try_get_request_store during sync execution', () => { + const store = make_store(); + let seen = null; + with_request_store(store, () => { + seen = try_get_request_store(); + }); + expect(seen).toBe(store); + }); + + test('exposes store via try_get_request_store during async execution', async () => { + const store = make_store(); + let seen = null; + await with_request_store(store, async () => { + await Promise.resolve(); + seen = try_get_request_store(); + }); + expect(seen).toBe(store); + }); + + test('returns null from try_get_request_store after sync execution completes', () => { + const store = make_store(); + with_request_store(store, () => {}); + expect(try_get_request_store()).toBeNull(); + }); + + test('returns null from try_get_request_store after async execution completes', async () => { + const store = make_store(); + await with_request_store(store, async () => 'rendered'); + expect(try_get_request_store()).toBeNull(); + }); + + // Regression test for https://github.com/sveltejs/kit/issues/15764 + // Async resources created inside als.run() inherit kResourceStore and can retain the + // RequestStore after the render completes (Node.js AsyncLocalStorage leak). The fix wraps + // the store in a container object; after render the container is nulled, allowing GC. + test('async continuations created inside render cannot access store after render completes', async () => { + const store = make_store(); + let store_seen_in_continuation = /** @type {RequestStore | null | undefined} */ (undefined); + + // Simulate what Svelte 4's component_subscribe does: create a Promise-based + // callback inside the ALS context that outlives the render. + let late_callback_promise; + + await with_request_store(store, async () => { + late_callback_promise = new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + // This runs inside the ALS context that was active when setTimeout was called + // (i.e. the context from als.run()). Without the fix, try_get_request_store() + // returns `store` here. With the fix, the container is nulled so it returns null. + store_seen_in_continuation = try_get_request_store(); + }); + return 'rendered'; + }); + + await late_callback_promise; + + expect(store_seen_in_continuation).toBeNull(); + }); + + test('getRequestEvent returns event during execution', async () => { + const store = make_store(); + let event = null; + await with_request_store(store, async () => { + event = getRequestEvent(); + }); + expect(event).toBe(store.event); + }); + + test('getRequestEvent throws outside execution context', () => { + expect(() => getRequestEvent()).toThrowError('Can only read the current request event'); + }); + + test('nested with_request_store calls use innermost store', async () => { + const outer = make_store(); + const inner = make_store(); + let seen_in_inner = null; + let seen_after_inner = null; + + await with_request_store(outer, async () => { + await with_request_store(inner, async () => { + seen_in_inner = try_get_request_store(); + }); + seen_after_inner = try_get_request_store(); + }); + + expect(seen_in_inner).toBe(inner); + expect(seen_after_inner).toBe(outer); + }); +}); From 106b7340052c8a44aefb41b93b965e7848dbf035 Mon Sep 17 00:00:00 2001 From: Zelys Date: Tue, 28 Apr 2026 14:29:05 -0500 Subject: [PATCH 2/4] chore: apply Prettier formatting and add changeset Co-Authored-By: Claude Sonnet 4.6 --- .changeset/fix-ssr-als-memory-leak.md | 5 +++++ packages/kit/src/exports/internal/event.js | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-ssr-als-memory-leak.md diff --git a/.changeset/fix-ssr-als-memory-leak.md b/.changeset/fix-ssr-als-memory-leak.md new file mode 100644 index 000000000000..ac3e50e75c47 --- /dev/null +++ b/.changeset/fix-ssr-als-memory-leak.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: prevent RequestEvent retention in AsyncLocalStorage async resources after SSR diff --git a/packages/kit/src/exports/internal/event.js b/packages/kit/src/exports/internal/event.js index 20aca1ce7bf8..bbe6285f7d05 100644 --- a/packages/kit/src/exports/internal/event.js +++ b/packages/kit/src/exports/internal/event.js @@ -85,11 +85,21 @@ export function with_request_store(store, fn) { // See https://github.com/nodejs/node/issues/53408 const container = /** @type {RequestStoreContainer} */ ({ current: store }); const result = als.run(container, fn); - if (result !== null && typeof result === 'object' && typeof (/** @type {any} */ (result)).then === 'function') { + if ( + result !== null && + typeof result === 'object' && + typeof (/** @type {any} */ (result).then) === 'function' + ) { return /** @type {T} */ ( /** @type {Promise} */ (/** @type {unknown} */ (result)).then( - (value) => { container.current = null; return value; }, - (error) => { container.current = null; throw error; } + (value) => { + container.current = null; + return value; + }, + (error) => { + container.current = null; + throw error; + } ) ); } From a61e29ae6e37647f28fb64148f9cdaf32bc953aa Mon Sep 17 00:00:00 2001 From: Zelys Date: Tue, 28 Apr 2026 14:38:14 -0500 Subject: [PATCH 3/4] fix(test): resolve ESLint require-await and await-thenable violations in event.spec Co-Authored-By: Claude Sonnet 4.6 --- packages/kit/src/exports/internal/event.spec.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/kit/src/exports/internal/event.spec.js b/packages/kit/src/exports/internal/event.spec.js index 6a3960542cfb..86aaee5259a4 100644 --- a/packages/kit/src/exports/internal/event.spec.js +++ b/packages/kit/src/exports/internal/event.spec.js @@ -38,7 +38,9 @@ describe('with_request_store', () => { test('returns null from try_get_request_store after async execution completes', async () => { const store = make_store(); - await with_request_store(store, async () => 'rendered'); + // Use Promise.resolve() rather than async/await to return a thenable without + // triggering the require-await lint rule on a trivial async arrow. + await with_request_store(store, () => Promise.resolve('rendered')); expect(try_get_request_store()).toBeNull(); }); @@ -52,16 +54,16 @@ describe('with_request_store', () => { // Simulate what Svelte 4's component_subscribe does: create a Promise-based // callback inside the ALS context that outlives the render. - let late_callback_promise; + let late_callback_promise = Promise.resolve(); // typed; overwritten inside render fn await with_request_store(store, async () => { + await Promise.resolve(); // ensure the async code path through with_request_store late_callback_promise = new Promise((resolve) => setTimeout(resolve, 0)).then(() => { // This runs inside the ALS context that was active when setTimeout was called // (i.e. the context from als.run()). Without the fix, try_get_request_store() // returns `store` here. With the fix, the container is nulled so it returns null. store_seen_in_continuation = try_get_request_store(); }); - return 'rendered'; }); await late_callback_promise; @@ -69,10 +71,10 @@ describe('with_request_store', () => { expect(store_seen_in_continuation).toBeNull(); }); - test('getRequestEvent returns event during execution', async () => { + test('getRequestEvent returns event during execution', () => { const store = make_store(); let event = null; - await with_request_store(store, async () => { + with_request_store(store, () => { event = getRequestEvent(); }); expect(event).toBe(store.event); @@ -89,7 +91,8 @@ describe('with_request_store', () => { let seen_after_inner = null; await with_request_store(outer, async () => { - await with_request_store(inner, async () => { + await Promise.resolve(); // exercise the async path + await with_request_store(inner, () => { seen_in_inner = try_get_request_store(); }); seen_after_inner = try_get_request_store(); From a1192cbba5e6b542322abbb7f07c00fa6cb6a62b Mon Sep 17 00:00:00 2001 From: Zelys Date: Tue, 28 Apr 2026 16:14:35 -0500 Subject: [PATCH 4/4] fix(server): scope gc_barrier nulling to SSR render path only Narrows the AsyncLocalStorage container-nulling fix to the SSR render call in render.js, which is the only context where async resources (Svelte 4 component_subscribe callbacks) can outlive the request and cause a memory leak. Other with_request_store callers (command handlers, query batch, etc.) now receive gc_barrier=false (the default), so their async continuations (get_response await-0 deferral, batch setTimeout callbacks) can still read the store for the duration of the request. Fixes the query.batch refresh-in-command single-flight regression by restoring store access for those async resources without reintroducing the original memory leak. Co-Authored-By: Claude Sonnet 4.6 --- packages/kit/src/exports/internal/event.js | 46 ++++++------ .../kit/src/exports/internal/event.spec.js | 28 +++++--- .../kit/src/runtime/server/page/render.js | 71 ++++++++++--------- 3 files changed, 81 insertions(+), 64 deletions(-) diff --git a/packages/kit/src/exports/internal/event.js b/packages/kit/src/exports/internal/event.js index bbe6285f7d05..28ded3551aa5 100644 --- a/packages/kit/src/exports/internal/event.js +++ b/packages/kit/src/exports/internal/event.js @@ -72,17 +72,20 @@ export function try_get_request_store() { * @template T * @param {RequestStore | null} store * @param {() => T} fn + * @param {boolean} [gc_barrier] - When true, nulls the ALS container after `fn` settles so that + * async resources created during `fn` (e.g. Svelte 4 subscription callbacks) cannot retain the + * RequestStore beyond the lifetime of the call. Only needed for the SSR render context where + * long-lived subscriptions would otherwise pin the RequestEvent in memory. + * See https://github.com/nodejs/node/issues/53408 */ -export function with_request_store(store, fn) { +export function with_request_store(store, fn, gc_barrier = false) { try { sync_store = store; if (als) { - // Wrap the store in a container so that async resources created inside fn - // (e.g. Svelte 4 subscription callbacks, Promise continuations) only hold a - // reference to the container rather than the full RequestStore. After fn - // completes we null out container.current, allowing the RequestStore and its - // RequestEvent to be garbage-collected even if stale async resources linger. - // See https://github.com/nodejs/node/issues/53408 + // Wrap the store in a container so that async resources created inside fn only hold a + // reference to the container object rather than the full RequestStore. When gc_barrier + // is true (SSR render path) we null container.current after fn settles, severing the + // only path from lingering async resources back to the RequestStore and allowing GC. const container = /** @type {RequestStoreContainer} */ ({ current: store }); const result = als.run(container, fn); if ( @@ -90,20 +93,23 @@ export function with_request_store(store, fn) { typeof result === 'object' && typeof (/** @type {any} */ (result).then) === 'function' ) { - return /** @type {T} */ ( - /** @type {Promise} */ (/** @type {unknown} */ (result)).then( - (value) => { - container.current = null; - return value; - }, - (error) => { - container.current = null; - throw error; - } - ) - ); + if (gc_barrier) { + return /** @type {T} */ ( + /** @type {Promise} */ (/** @type {unknown} */ (result)).then( + (value) => { + container.current = null; + return value; + }, + (error) => { + container.current = null; + throw error; + } + ) + ); + } + return /** @type {T} */ (result); } - container.current = null; + if (gc_barrier) container.current = null; return result; } return fn(); diff --git a/packages/kit/src/exports/internal/event.spec.js b/packages/kit/src/exports/internal/event.spec.js index 86aaee5259a4..46be1ad6e233 100644 --- a/packages/kit/src/exports/internal/event.spec.js +++ b/packages/kit/src/exports/internal/event.spec.js @@ -47,7 +47,8 @@ describe('with_request_store', () => { // Regression test for https://github.com/sveltejs/kit/issues/15764 // Async resources created inside als.run() inherit kResourceStore and can retain the // RequestStore after the render completes (Node.js AsyncLocalStorage leak). The fix wraps - // the store in a container object; after render the container is nulled, allowing GC. + // the store in a container object; when gc_barrier=true (the render path), the container is + // nulled after render, allowing GC. test('async continuations created inside render cannot access store after render completes', async () => { const store = make_store(); let store_seen_in_continuation = /** @type {RequestStore | null | undefined} */ (undefined); @@ -56,15 +57,20 @@ describe('with_request_store', () => { // callback inside the ALS context that outlives the render. let late_callback_promise = Promise.resolve(); // typed; overwritten inside render fn - await with_request_store(store, async () => { - await Promise.resolve(); // ensure the async code path through with_request_store - late_callback_promise = new Promise((resolve) => setTimeout(resolve, 0)).then(() => { - // This runs inside the ALS context that was active when setTimeout was called - // (i.e. the context from als.run()). Without the fix, try_get_request_store() - // returns `store` here. With the fix, the container is nulled so it returns null. - store_seen_in_continuation = try_get_request_store(); - }); - }); + // gc_barrier=true mirrors the SSR render call in render.js + await with_request_store( + store, + async () => { + await Promise.resolve(); // ensure the async code path through with_request_store + late_callback_promise = new Promise((resolve) => setTimeout(resolve, 0)).then(() => { + // This runs inside the ALS context that was active when setTimeout was called + // (i.e. the context from als.run()). Without the fix, try_get_request_store() + // returns `store` here. With the fix, the container is nulled so it returns null. + store_seen_in_continuation = try_get_request_store(); + }); + }, + true + ); await late_callback_promise; @@ -92,7 +98,7 @@ describe('with_request_store', () => { await with_request_store(outer, async () => { await Promise.resolve(); // exercise the async path - await with_request_store(inner, () => { + with_request_store(inner, () => { seen_in_inner = try_get_request_store(); }); seen_after_inner = try_get_request_store(); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0e7c68cf6056..aed1369eeb6e 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -224,42 +224,47 @@ export async function render_response({ const state = { ...event_state, is_in_render: true }; - rendered = await with_request_store({ event, state }, async () => { - // use relative paths during rendering, so that the resulting HTML is as - // portable as possible, but reset afterwards - if (paths.relative) paths.override({ base, assets }); - - const maybe_promise = options.root.render(props, render_opts); - // We have to invoke .then eagerly here in order to kick off rendering: it's only starting on access, - // and `await maybe_promise` would eagerly access the .then property but call its function only after a tick, which is too late - // for the paths.reset() below and for any eager getRequestEvent() calls during rendering without AsyncLocalStorage available. - const rendered = - options.async && 'then' in maybe_promise - ? /** @type {ReturnType & Promise} */ ( - maybe_promise - ).then((r) => r) - : maybe_promise; - - // TODO 3.0 remove options.async - if (options.async) { - // we reset this synchronously, rather than after async rendering is complete, - // to avoid cross-talk between requests. This is a breaking change for - // anyone who opts into async SSR, since `base` and `assets` will no - // longer be relative to the current pathname. - // TODO 3.0 remove `base` and `assets` in favour of `resolve(...)` and `asset(...)` - paths.reset(); - } + rendered = await with_request_store( + { event, state }, + async () => { + // use relative paths during rendering, so that the resulting HTML is as + // portable as possible, but reset afterwards + if (paths.relative) paths.override({ base, assets }); + + const maybe_promise = options.root.render(props, render_opts); + // We have to invoke .then eagerly here in order to kick off rendering: it's only starting on access, + // and `await maybe_promise` would eagerly access the .then property but call its function only after a tick, which is too late + // for the paths.reset() below and for any eager getRequestEvent() calls during rendering without AsyncLocalStorage available. + const rendered = + options.async && 'then' in maybe_promise + ? /** @type {ReturnType & Promise} */ ( + maybe_promise + ).then((r) => r) + : maybe_promise; + + // TODO 3.0 remove options.async + if (options.async) { + // we reset this synchronously, rather than after async rendering is complete, + // to avoid cross-talk between requests. This is a breaking change for + // anyone who opts into async SSR, since `base` and `assets` will no + // longer be relative to the current pathname. + // TODO 3.0 remove `base` and `assets` in favour of `resolve(...)` and `asset(...)` + paths.reset(); + } - const { head, html, css, hashes } = /** @type {ReturnType} */ ( - options.async ? await rendered : rendered - ); + const { head, html, css, hashes } = + /** @type {ReturnType} */ ( + options.async ? await rendered : rendered + ); - if (hashes) { - csp.add_script_hashes(hashes.script); - } + if (hashes) { + csp.add_script_hashes(hashes.script); + } - return { head, html, css, hashes }; - }); + return { head, html, css, hashes }; + }, + true + ); } finally { if (DEV) { globalThis.fetch = fetch;