Skip to content

Commit e602dfe

Browse files
authored
fix(ext/node): preserve AsyncLocalStorage context across node:net callbacks (#35237)
Fixes #35154
1 parent c3d7c51 commit e602dfe

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

ext/node/polyfills/net.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,30 @@ let debug = debuglog("net", (fn) => {
185185
const kLastWriteQueueSize = Symbol("lastWriteQueueSize");
186186
const kBytesRead = Symbol("kBytesRead");
187187
const kBytesWritten = Symbol("kBytesWritten");
188+
// Holds the async context (AsyncLocalStorage / async_hooks) snapshot captured
189+
// when a connect request or listening handle is set up. The native libuv
190+
// callbacks in tcp_wrap/pipe_wrap invoke the completion callbacks directly,
191+
// outside of any microtask or nextTick, so the snapshot has to be captured at
192+
// registration time and restored before dispatching. Otherwise
193+
// `AsyncLocalStorage.getStore()` returns `undefined` inside `connect` and
194+
// `connection` handlers. See https://github.com/denoland/deno/issues/35154.
195+
const kAsyncContext = Symbol("kAsyncContext");
196+
197+
// Runs `run` with the async context snapshot that was active when the
198+
// operation was initiated, restoring the previous context afterwards.
199+
function _runInAsyncContext(snapshot: any, run: () => void) {
200+
if (snapshot === undefined) {
201+
run();
202+
return;
203+
}
204+
const prior = core.getAsyncContext();
205+
core.setAsyncContext(snapshot);
206+
try {
207+
run();
208+
} finally {
209+
core.setAsyncContext(prior);
210+
}
211+
}
188212

189213
const DEFAULT_IPV4_ADDR = "0.0.0.0";
190214
const DEFAULT_IPV6_ADDR = "::";
@@ -468,6 +492,19 @@ function _afterConnect(
468492
req: PipeConnectWrap | TCPConnectWrap,
469493
readable: boolean,
470494
writable: boolean,
495+
) {
496+
_runInAsyncContext(
497+
handle?.[kAsyncContext],
498+
() => _afterConnectImpl(status, handle, req, readable, writable),
499+
);
500+
}
501+
502+
function _afterConnectImpl(
503+
status: number,
504+
handle: any,
505+
req: PipeConnectWrap | TCPConnectWrap,
506+
readable: boolean,
507+
writable: boolean,
471508
) {
472509
let socket = handle[ownerSymbol];
473510

@@ -1619,6 +1656,11 @@ Socket.prototype.connect = function (...args) {
16191656
_initSocketHandle(this);
16201657
}
16211658

1659+
// Capture the async context now, while we are still running synchronously
1660+
// inside the caller's context. The DNS lookup that precedes the actual
1661+
// connect is async and would otherwise drop it before `_afterConnect` runs.
1662+
this._handle[kAsyncContext] = core.getAsyncContext();
1663+
16221664
if (cb !== null) {
16231665
this.once("connect", cb);
16241666
}
@@ -2568,6 +2610,15 @@ function _emitListeningNT(server: Server) {
25682610
}
25692611

25702612
function _onconnection(this: any, err: number, clientHandle?: Handle) {
2613+
// deno-lint-ignore no-this-alias
2614+
const handle = this;
2615+
_runInAsyncContext(
2616+
handle[kAsyncContext],
2617+
() => FunctionPrototypeCall(_onconnectionImpl, handle, err, clientHandle),
2618+
);
2619+
}
2620+
2621+
function _onconnectionImpl(this: any, err: number, clientHandle?: Handle) {
25712622
// deno-lint-ignore no-this-alias
25722623
const handle = this;
25732624
const self = handle[ownerSymbol];
@@ -2684,6 +2735,7 @@ function _setupListenHandle(
26842735

26852736
this[asyncIdSymbol] = _getNewAsyncId(this._handle);
26862737
this._handle.onconnection = _onconnection;
2738+
this._handle[kAsyncContext] = core.getAsyncContext();
26872739
this._handle[ownerSymbol] = this;
26882740

26892741
// For TCP and Pipe handles, wrap the onconnection callback to create

tests/unit_node/async_hooks_test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,49 @@ Deno.test(async function asyncLocalStoragePreservedInStreamFinished() {
258258

259259
await promise;
260260
});
261+
262+
// Regression test for https://github.com/denoland/deno/issues/35154. The
263+
// native libuv callbacks for node:net connect/accept invoke their completion
264+
// callbacks directly, so the async context that was active when the operation
265+
// was started has to be captured and restored, otherwise it is lost inside the
266+
// `connect` and `connection` handlers.
267+
Deno.test(async function asyncLocalStoragePreservedInNetCallbacks() {
268+
const net = await import("node:net");
269+
const als = new AsyncLocalStorage();
270+
const observed: { serverConnection: unknown; clientConnect: unknown } = {
271+
serverConnection: null,
272+
clientConnect: null,
273+
};
274+
275+
await new Promise<void>((resolve, reject) => {
276+
als.run("server-context", () => {
277+
const server = net.createServer((socket) => {
278+
observed.serverConnection = als.getStore() ?? null;
279+
socket.end("ok");
280+
server.close();
281+
});
282+
283+
server.once("error", reject);
284+
server.listen(0, () => {
285+
const { port } = server.address() as { port: number };
286+
287+
als.run("client-context", () => {
288+
const client = net.connect({ port }, () => {
289+
observed.clientConnect = als.getStore() ?? null;
290+
});
291+
292+
client.once("error", reject);
293+
client.once("data", () => {
294+
client.destroy();
295+
resolve();
296+
});
297+
});
298+
});
299+
});
300+
});
301+
302+
assertEquals(observed, {
303+
serverConnection: "server-context",
304+
clientConnect: "client-context",
305+
});
306+
});

0 commit comments

Comments
 (0)