Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/internal/async_local_storage/async_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ const RunScope = require('internal/async_local_storage/run_scope');
const { kEmptyObject } = require('internal/util');

const storageList = [];

function clearStoreFromResource(resource) {
for (let i = 0; i < storageList.length; ++i) {
resource[storageList[i].kResourceStore] = undefined;
}
}

const storageHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
const currentResource = executionAsyncResource();
Expand Down Expand Up @@ -152,3 +159,4 @@ class AsyncLocalStorage {
}

module.exports = AsyncLocalStorage;
module.exports.clearStoreFromResource = clearStoreFromResource;
81 changes: 70 additions & 11 deletions lib/internal/timers.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,38 @@

const async_context_frame = Symbol('kAsyncContextFrame');

function removeStoresFromResource(resource) {

@Flarna Flarna Jun 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be extended to somthing like this to cover also the async context frame variant of ALS:

function removeStoresFromResource(resource) {
  if (AsyncContextFrame.enabled) {
    if (resource[async_context_frame] !== undefined) {
      resource[async_context_frame] = undefined;
    }
  } else {
    if (resource[async_local_storage_context_symbol] !== undefined) {
      resource[async_local_storage_context_symbol] = undefined;
    }
  }
}

if (AsyncContextFrame.enabled) {
if (resource[async_context_frame] !== undefined) {
resource[async_context_frame] = undefined;
}
} else {
require('internal/async_local_storage/async_hooks').clearStoreFromResource(resource);
}
}

function cleanTimer(timer) {
removeStoresFromResource(timer);
timer._onTimeout = undefined;
timer._timerArgs = undefined;
}

function cleanImmediate(immediate) {
removeStoresFromResource(immediate);
immediate._onImmediate = undefined;
immediate._argv = undefined;
}

function enqueueRemoveStoresFromResource(resource) {
enqueueMicrotask(() => removeStoresFromResource(resource));

Check failure on line 151 in lib/internal/timers.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'enqueueMicrotask' is not defined
}

function enqueueRemoveStoresIfNotReinserted(resource) {
enqueueMicrotask(() => {

Check failure on line 155 in lib/internal/timers.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'enqueueMicrotask' is not defined
if (!resource._idleNext && !resource._idlePrev)
removeStoresFromResource(resource);
});
}
// *Must* match Environment::ImmediateInfo::Fields in src/env.h.
const kCount = 0;
const kRefCount = 1;
Expand Down Expand Up @@ -498,14 +530,22 @@
const asyncId = immediate[async_id_symbol];
emitBefore(asyncId, immediate[trigger_async_id_symbol], immediate);

let threw = true;
try {
const argv = immediate._argv;
if (!argv)
immediate._onImmediate();
else
immediate._onImmediate(...argv);
threw = false;
} finally {
immediate._onImmediate = null;
if (threw) {
immediate._onImmediate = undefined;
immediate._argv = undefined;
enqueueRemoveStoresFromResource(immediate);
} else {
cleanImmediate(immediate);
}

emitDestroy(asyncId);

Expand Down Expand Up @@ -599,26 +639,42 @@
start = binding.getLibuvNow();
}

let threw = true;
try {
const args = timer._timerArgs;
if (args === undefined)
timer._onTimeout();
else
ReflectApply(timer._onTimeout, timer, args);
threw = false;
} finally {
if (timer._repeat && timer._idleTimeout !== -1) {
timer._idleTimeout = timer._repeat;
insert(timer, timer._idleTimeout, start);
} else if (!timer._idleNext && !timer._idlePrev && !timer._destroyed) {
timer._destroyed = true;

if (timer[kHasPrimitive])
delete knownTimersById[asyncId];

if (timer[kRefed])
timeoutInfo[0]--;

emitDestroy(asyncId);
} else if (!timer._idleNext && !timer._idlePrev) {
if (timer._destroyed) {
timer._onTimeout = undefined;
timer._timerArgs = undefined;
if (threw)
enqueueRemoveStoresIfNotReinserted(timer);
else
removeStoresFromResource(timer);
} else {
if (threw)
enqueueRemoveStoresIfNotReinserted(timer);
else
removeStoresFromResource(timer);

timer._destroyed = true;

if (timer[kHasPrimitive])
delete knownTimersById[asyncId];

if (timer[kRefed])
timeoutInfo[0]--;

emitDestroy(asyncId);
}
}
}

Expand Down Expand Up @@ -698,8 +754,11 @@
kTimeout: Symbol('timeout'), // For hiding Timeouts on other internals.
async_id_symbol,
trigger_async_id_symbol,
async_context_frame,
Timeout,
Immediate,
cleanImmediate,
cleanTimer,
kRefed,
kHasPrimitive,
initAsyncResource,
Expand Down
8 changes: 8 additions & 0 deletions lib/timers.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const {
async_id_symbol,
Timeout,
Immediate,
cleanTimer,
cleanImmediate,
decRefCount,
immediateInfoFields: {
kCount,
Expand Down Expand Up @@ -136,13 +138,17 @@ ObjectDefineProperty(setTimeout, customPromisify, {
function clearTimeout(timer) {
if (timer?._onTimeout) {
timer._onTimeout = null;
timer._timerArgs = undefined;
cleanTimer(timer);
unenroll(timer);
return;
}
if (typeof timer === 'number' || typeof timer === 'string') {
const timerInstance = knownTimersById[timer];
if (timerInstance !== undefined) {
timerInstance._onTimeout = null;
timerInstance._timerArgs = undefined;
cleanTimer(timerInstance);
unenroll(timerInstance);
}
}
Expand Down Expand Up @@ -239,6 +245,8 @@ function clearImmediate(immediate) {
emitDestroy(immediate[async_id_symbol]);

immediate._onImmediate = null;
immediate._argv = undefined;
cleanImmediate(immediate);

immediateQueue.remove(immediate);
}
Expand Down
128 changes: 128 additions & 0 deletions test/parallel/test-timers-async-store-leak.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Flags: --expose-internals --expose-gc
'use strict';

const common = require('../common');
const { AsyncLocalStorage } = require('async_hooks');
const assert = require('assert');

const AsyncContextFrame = require('internal/async_context_frame');
const async_context_frame = require('internal/timers').async_context_frame;

// When async-context-frame is enabled, stores are stored in the async context
// frame, not directly on the resource. The resource holds a reference to the
// frame via async_context_frame.
const isACFEnabled = AsyncContextFrame.enabled;

function assertNoStore(resource, als) {
if (isACFEnabled) {
assert.strictEqual(resource[async_context_frame], undefined);
} else {
assert.strictEqual(resource[als.kResourceStore], undefined);
}
}

// Test that setTimeout does not retain a reference to the async store after
// firing. The callback and arguments must stay on the Timeout object so that
// refresh() can reactivate the timer.
{
const asyncLocalStorage = new AsyncLocalStorage();
const store = {};
const arg = {};
asyncLocalStorage.run(store, common.mustCall(() => {
const timeout = setTimeout(common.mustCall((received) => {
assert.strictEqual(received, arg);
setImmediate(common.mustCall(() => {
assertNoStore(timeout, asyncLocalStorage);
}));
}), 1, arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
}));
}

// Test that clearTimeout cleans up the store, callback, and arguments before
// firing.
{
const asyncLocalStorage = new AsyncLocalStorage();
const store = {};
const arg = {};
asyncLocalStorage.run(store, common.mustCall(() => {
const timeout = setTimeout(common.mustNotCall(), common.platformTimeout(1000), arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
clearTimeout(timeout);
assertNoStore(timeout, asyncLocalStorage);
assert.strictEqual(timeout._onTimeout, undefined);
assert.strictEqual(timeout._timerArgs, undefined);
}));
}

// Test that setInterval does not retain a reference to the async store,
// callback, or arguments after it is cleared.
{
const asyncLocalStorage = new AsyncLocalStorage();
const store = {};
const arg = {};
asyncLocalStorage.run(store, common.mustCall(() => {
const interval = setInterval(common.mustCall((received) => {
assert.strictEqual(received, arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
clearInterval(interval);
setImmediate(common.mustCall(() => {
assertNoStore(interval, asyncLocalStorage);
assert.strictEqual(interval._onTimeout, undefined);
assert.strictEqual(interval._timerArgs, undefined);
}));
}), 1, arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
}));
}

// Test that setImmediate does not retain a reference to the async store,
// callback, or arguments after firing.
{
const asyncLocalStorage = new AsyncLocalStorage();
const store = {};
const arg = {};
asyncLocalStorage.run(store, common.mustCall(() => {
const immediate = setImmediate(common.mustCall((received) => {
assert.strictEqual(received, arg);
setImmediate(common.mustCall(() => {
assertNoStore(immediate, asyncLocalStorage);
assert.strictEqual(immediate._onImmediate, undefined);
assert.strictEqual(immediate._argv, undefined);
}));
}), arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
}));
}

// Test that clearImmediate cleans up the store, callback, and arguments before
// firing.
{
const asyncLocalStorage = new AsyncLocalStorage();
const store = {};
const arg = {};
asyncLocalStorage.run(store, common.mustCall(() => {
const immediate = setImmediate(common.mustNotCall(), arg);
assert.strictEqual(asyncLocalStorage.getStore(), store);
clearImmediate(immediate);
assertNoStore(immediate, asyncLocalStorage);
assert.strictEqual(immediate._onImmediate, undefined);
assert.strictEqual(immediate._argv, undefined);
}));
}

// Test that the async store reference is cleaned up and can be GC'd.
{
const asyncLocalStorage = new AsyncLocalStorage();
let timeout;
const weakStore = new WeakRef({});
asyncLocalStorage.run(weakStore.deref(), common.mustCall(() => {
timeout = setTimeout(common.mustNotCall(), common.platformTimeout(1000));
assert.strictEqual(weakStore.deref() !== undefined, true);
clearTimeout(timeout);
}));
setImmediate(common.mustCall(() => {
global.gc();
assert.strictEqual(weakStore.deref(), undefined);
}));
}
Loading