Skip to content
This repository was archived by the owner on Jan 21, 2026. It is now read-only.

Commit 8454389

Browse files
authored
fix: avoid memory leaks due to undisposed promise resources (#885)
1 parent 199cb42 commit 8454389

2 files changed

Lines changed: 133 additions & 8 deletions

File tree

src/cls/async-hooks.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,17 @@ export class AsyncHooksCLS<Context extends {}> implements CLS<Context> {
5858
// init is called when a new AsyncResource is created. We want code
5959
// that runs within the scope of this new AsyncResource to see the same
6060
// context as its "parent" AsyncResource. The criteria for the parent
61-
// depends on the type of the AsyncResource.
61+
// depends on the type of the AsyncResource. (If the parent doesn't have
62+
// an associated context, don't do anything.)
6263
if (type === 'PROMISE') {
6364
// Opt not to use the trigger ID for Promises, as this causes context
6465
// confusion in applications using async/await.
6566
// Instead, use the ID of the AsyncResource in whose scope we are
6667
// currently running.
67-
this.contexts[id] = this.contexts[this.ah.executionAsyncId()];
68+
const currentId = this.ah.executionAsyncId();
69+
if (this.contexts[currentId] !== undefined) {
70+
this.contexts[id] = this.contexts[currentId];
71+
}
6872
} else {
6973
// Use the trigger ID for any other type. In Node core, this is
7074
// usually equal the ID of the AsyncResource in whose scope we are
@@ -75,17 +79,31 @@ export class AsyncHooksCLS<Context extends {}> implements CLS<Context> {
7579
// AsyncResource API, because users of that API can specify their own
7680
// trigger ID. In this case, we choose to respect the user's
7781
// selection.
78-
this.contexts[id] = this.contexts[triggerId];
82+
if (this.contexts[triggerId] !== undefined) {
83+
this.contexts[id] = this.contexts[triggerId];
84+
}
7985
}
80-
// Note that this function always assigns values in this.contexts to
81-
// values under other keys, which may or may not be undefined. Consumers
82-
// of the CLS API will get the sentinel (default) value if they query
83-
// the current context when it is stored as undefined.
86+
// Note that this function only assigns values in this.contexts to
87+
// values under other keys; it never generates new context values.
88+
// Consumers of the CLS API will get the sentinel (default) value if
89+
// they query the current context when it is not stored in
90+
// this.contexts.
8491
},
8592
destroy: (id: number) => {
8693
// destroy is called when the AsyncResource is no longer used, so also
8794
// delete its entry in the map.
8895
delete this.contexts[id];
96+
},
97+
promiseResolve: (id: number) => {
98+
// Promise async resources may not get their destroy hook entered for
99+
// a long time, so we listen on promiseResolve hooks as well. If this
100+
// event is emitted, the async scope of the Promise will not be entered
101+
// again, so it is generally safe to delete its entry in the map. (There
102+
// is a possibility that a future async resource will directly reference
103+
// this Promise as its trigger parent -- in this case, it will have
104+
// the wrong parent, but this is still better than a potential memory
105+
// leak.)
106+
delete this.contexts[id];
89107
}
90108
});
91109
}

test/test-cls-ah.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ maybeSkip(describe)('AsyncHooks-based CLS', () => {
9090
}, 'modified');
9191
});
9292

93-
describe('Using AsyncResource API', () => {
93+
describe('Compatibility with AsyncResource API', () => {
9494
it('Supports context propagation without trigger ID', async () => {
9595
let res!: asyncHooksModule.AsyncResource;
9696
await cls.runWithContext(async () => {
@@ -115,4 +115,111 @@ maybeSkip(describe)('AsyncHooks-based CLS', () => {
115115
});
116116
});
117117
});
118+
119+
describe('Memory consumption with Promises', () => {
120+
const createdPromiseIDs: number[] = [];
121+
let hook: asyncHooksModule.AsyncHook;
122+
123+
before(() => {
124+
hook = asyncHooks
125+
.createHook({
126+
init: (uid: number, type: string) => {
127+
if (type === 'PROMISE') {
128+
createdPromiseIDs.push(uid);
129+
}
130+
}
131+
})
132+
.enable();
133+
});
134+
135+
after(() => {
136+
hook.disable();
137+
});
138+
139+
const testCases:
140+
Array<{description: string; skip?: boolean; fn: () => {}}> = [
141+
{description: 'a no-op async function', fn: async () => {}}, {
142+
description: 'an async function that throws',
143+
fn: async () => {
144+
throw new Error();
145+
}
146+
},
147+
{
148+
description: 'an async function that awaits a rejected value',
149+
fn: async () => {
150+
await new Promise(reject => setImmediate(reject));
151+
}
152+
},
153+
{
154+
description: 'an async function with awaited values',
155+
fn: async () => {
156+
await 0;
157+
await new Promise(resolve => resolve());
158+
await new Promise(resolve => setImmediate(resolve));
159+
}
160+
},
161+
{
162+
description: 'an async function that awaits another async function',
163+
fn: async () => {
164+
await (async () => {
165+
await Promise.resolve();
166+
})();
167+
}
168+
},
169+
{
170+
description: 'a plain function that returns a Promise',
171+
fn: () => Promise.resolve()
172+
},
173+
{
174+
description:
175+
'a plain function that returns a Promise that will reject',
176+
fn: () => Promise.reject()
177+
},
178+
{
179+
description: 'an async function with spread args',
180+
// TODO(kjin): A possible bug in exists that causes an extra Promise
181+
// async resource to be initialized when an async function with
182+
// spread args is invoked. promiseResolve is not called for this
183+
// async resource. Fix this bug and then remove this skip directive.
184+
skip: true,
185+
fn: async (...args: number[]) => args
186+
}
187+
];
188+
189+
for (const testCase of testCases) {
190+
const skipIfTestSpecifies = !!testCase.skip ? it.skip : it;
191+
skipIfTestSpecifies(
192+
`Doesn't retain stale references when running ${
193+
testCase.description} in a context`,
194+
async () => {
195+
createdPromiseIDs.length = 0;
196+
try {
197+
// Run the test function in a new context.
198+
await cls.runWithContext(testCase.fn, 'will-be-stale');
199+
} catch (e) {
200+
// Ignore errors; they aren't important for this test.
201+
} finally {
202+
// At this point, Promises created from invoking the test function
203+
// should have had either their destroy or promiseResolve hook
204+
// called. We observe this by iterating through the Promises that
205+
// were created in this context, and checking to see that getting
206+
// the current context in the scope of an async resource that
207+
// references any of these Promises as its trigger parent doesn't
208+
// yield the stale context value from before. The only way this is
209+
// possible is if the CLS implementation internally kept a stale
210+
// reference to a context-local value keyed on the ID of a PROMISE
211+
// resource that should have been disposed.
212+
const stalePromiseIDs = createdPromiseIDs.filter((id) => {
213+
const a = new AsyncResource('test', id);
214+
const result = a.runInAsyncScope(() => {
215+
return cls.getContext() === 'will-be-stale';
216+
});
217+
a.emitDestroy();
218+
return result;
219+
});
220+
assert.strictEqual(stalePromiseIDs.length, 0);
221+
}
222+
});
223+
}
224+
});
118225
});

0 commit comments

Comments
 (0)