Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1456ea8
node compat: port node v26.3.0 async test suite, fix the bugs it found
cirospaciari Jun 4, 2026
0713084
Address review feedback on perf_hooks, fs.cp, and validateObject
cirospaciari Jun 5, 2026
25db90c
Expect verbatim caller paths in cp error assertions
cirospaciari Jun 5, 2026
b8d3801
Accept Buffer cp paths, advertise 'function' entry type, clear web ob…
cirospaciari Jun 5, 2026
a5a7c06
Validate cp callback-form paths synchronously
cirospaciari Jun 5, 2026
f3cf3bf
Fix stale post-run assert for AsyncLocalStorage defaultValue
cirospaciari Jun 6, 2026
642d170
Make timerify entries instanceof PerformanceEntry; reject on invalid …
cirospaciari Jun 6, 2026
a34c136
Drop unreachable receiver guards in EventEmitterAsyncResource getters
cirospaciari Jun 6, 2026
a21eb30
Merge branch 'main' into claude/node-v26-async-tests
robobun Jun 10, 2026
4edc0f0
Merge branch 'main' into claude/node-v26-async-tests
cirospaciari Jun 17, 2026
2dd6199
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jun 22, 2026
12df281
node:http: restore the request-time AsyncLocalStorage frame around cl…
cirospaciari Jun 22, 2026
2f09f80
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 22, 2026
f284564
Scan key slots only on the run() entry path; use raw $asyncContext fo…
cirospaciari Jun 22, 2026
424e897
Cache options.name in a local before testing it (oxlint no-duplicate-…
cirospaciari Jun 23, 2026
39eb8f4
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jun 23, 2026
7d91da8
http2: keep newListener/removeListener in the caller's ALS context
cirospaciari Jun 23, 2026
40871a7
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jul 1, 2026
7aa22f3
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jul 1, 2026
accf085
Route stopPerf through enqueueNodeEntry for a single observer-dispatc…
cirospaciari Jul 1, 2026
d3aca84
Address review: swap async-context frame at native seams, not emit()
alii Jul 8, 2026
5dfc048
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 8, 2026
5165c49
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
alii Jul 8, 2026
5b3d9e9
perf_hooks/async_hooks: null-prototype descriptors; drop spurious per…
alii Jul 8, 2026
5efa4bd
perf_hooks: restore top-level timerify export (Node v25.2+)
alii Jul 8, 2026
cc1f7b6
Address second-pass review: ALS defaultValue after disable, http/http…
alii Jul 9, 2026
c481392
http client: wrap responseOnTimeout in the request's async-context frame
alii Jul 10, 2026
45ce5f0
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jul 14, 2026
a6b4058
Drop test-async-local-storage-weak-asyncwrap-leak
cirospaciari Jul 14, 2026
5d06818
Merge remote-tracking branch 'origin/main' into claude/node-v26-async…
cirospaciari Jul 14, 2026
16146a0
Merge branch 'main' into claude/node-v26-async-tests
cirospaciari Jul 15, 2026
915d80a
async_hooks,perf_hooks: compare stores by SameValue, align perf_hooks…
cirospaciari Jul 15, 2026
817b76a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 15, 2026
5a1b930
http,http2: keep the request's async context on early socket errors, …
cirospaciari Jul 15, 2026
51d7947
test: pin the injected error in the early-socket-error async-context …
cirospaciari Jul 15, 2026
a1d425e
async_hooks,perf_hooks: restore after run() on a disabled storage, ha…
cirospaciari Jul 15, 2026
5e2dd9a
http2: clear the session async-context frame before emitting 'close'
cirospaciari Jul 15, 2026
5f95b0c
http2: clear the session async-context frame before destroy() can throw
cirospaciari Jul 15, 2026
7d82861
http,http2: clear the captured async-context frame before user code c…
cirospaciari Jul 15, 2026
5e8d421
test: close the session and socket the async-context frame tests leav…
cirospaciari Jul 15, 2026
658b731
async_hooks: don't short-circuit run() on a disabled storage's masked…
cirospaciari Jul 15, 2026
fafa64d
test: drain instead of process.exit() in the last frame-cleanup subpr…
cirospaciari Jul 16, 2026
c7bb492
Merge branch 'main' into claude/node-v26-async-tests
robobun Jul 16, 2026
d804938
http2: drop the merge-duplicated enter/exitStreamAsyncContext path no…
robobun Jul 16, 2026
1f2e4a6
async_hooks, http2: echo AsyncResource triggerAsyncId, honor endStrea…
cirospaciari Jul 16, 2026
7d52ef1
test: disable() the enterWith()-installed stores the run() short-circ…
cirospaciari Jul 16, 2026
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
39 changes: 31 additions & 8 deletions src/js/internal/async_context_frame.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
// Minimal port of node's lib/internal/async_context_frame.js surface for
// --expose-internals consumers (vendored node tests).
// Port of node's lib/internal/async_context_frame.js surface for
// --expose-internals consumers (vendored node tests) and for Bun's own
// built-ins that need to swap the active async-context frame around a
// callback — the JS-side equivalent of the native
// AsyncContextFrame::call / withAsyncContextIfNeeded.
//
// Bun tracks async context natively in the engine (AsyncLocalStorage rides
// JSC's async context), so context propagation is always enabled. Frame
// objects, however, are never materialized — current() has nothing to
// expose and returns undefined. This diverges from node, where enabled
// implies current() is non-null inside a frame; tests relying on that
// coupling (rather than on enabled/falsiness checks) will not pass.
// JSC's async context), so context propagation is always enabled and the
// "frame" is the raw internal-field value (an even-length [ALS, value, ...]
// array or undefined) — see the comment at the top of node/async_hooks.ts.
const AsyncContextFrame = {
enabled: true,
current() {
return undefined;
return $getInternalField($asyncContext, 0);
},
/** Install `frame` as the active async-context frame; returns the previous one. */
exchange(frame) {
const prev = $getInternalField($asyncContext, 0);
$putInternalField($asyncContext, 0, frame);
return prev;
},
/**
* Call `fn` with `frame` installed as the active async-context frame,
* restoring the previous frame afterwards. Fast-paths when `frame` is
* already active (which includes the "no ALS in use anywhere" case where
* both are undefined).
*/
run(frame, fn, thisArg?, ...args) {
const prev = $getInternalField($asyncContext, 0);
if (frame === prev) return fn.$apply(thisArg, args);
$putInternalField($asyncContext, 0, frame);
try {
return fn.$apply(thisArg, args);
} finally {
$putInternalField($asyncContext, 0, prev);
}
},
};

Expand Down
65 changes: 51 additions & 14 deletions src/js/internal/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,55 @@ const observerCounts = new Map();
const kObservers = new Set();

/** Entry types routed through this JS-side registry instead of the native observer. */
const kNodeEntryTypes = new Set(["net", "dns", "http"]);
const kNodeEntryTypes = new Set(["net", "dns", "http", "function"]);

function hasObserver(type) {
return (observerCounts.get(type) ?? 0) > 0;
}

/**
* Hand a finished entry to every registered observer. Used by callers that
* construct the entry themselves (e.g. perf_hooks timerify) instead of the
* startPerf/stopPerf pair.
*/
function enqueueNodeEntry(entry) {
for (const observer of kObservers) {
observer.bufferEntry(entry);
}
}

// Node's PerformanceNodeEntry — the shape used by every JS-side entry type
// ('function', 'net', 'dns', 'http'). Lives here (not in perf_hooks.ts) so
// stopPerf can construct it without a circular require. The prototype chain
// is linked to PerformanceEntry by perf_hooks.ts at load time using its
// captured global (every construction is gated behind hasObserver(), which
// is only true after perf_hooks has loaded).
class PerformanceNodeEntry {
name;
entryType;
startTime;
duration;
detail;

constructor(name, entryType, startTime, duration, detail) {
this.name = name;
this.entryType = entryType;
this.startTime = startTime;
this.duration = duration;
this.detail = detail;
}

toJSON() {
return {
name: this.name,
entryType: this.entryType,
startTime: this.startTime,
duration: this.duration,
detail: this.detail,
};
}
}

function startPerf(target, key, context) {
context.startTime = performance.now();
target[key] = context;
Expand All @@ -181,19 +224,11 @@ function stopPerf(target, key, context) {
}
target[key] = undefined;
const startTime = ctx.startTime;
const entry = {
name: ctx.name,
entryType: ctx.type,
startTime,
duration: performance.now() - startTime,
// Node.js merges the detail recorded at startPerf() with the detail
// passed to stopPerf() (e.g. http entries carry both req and res).
detail:
ctx.detail !== undefined || context?.detail !== undefined ? { ...ctx.detail, ...context?.detail } : undefined,
};
for (const observer of kObservers) {
observer.bufferEntry(entry);
}
// Node.js merges the detail recorded at startPerf() with the detail
// passed to stopPerf() (e.g. http entries carry both req and res).
const detail =
ctx.detail !== undefined || context?.detail !== undefined ? { ...ctx.detail, ...context?.detail } : undefined;
enqueueNodeEntry(new PerformanceNodeEntry(ctx.name, ctx.type, startTime, performance.now() - startTime, detail));
}

/**
Expand Down Expand Up @@ -286,8 +321,10 @@ export default {
hasObserver,
startPerf,
stopPerf,
enqueueNodeEntry,
kNodeEntryTypes,
NodeEntryObserver,
PerformanceNodeEntry,

kHandle: Symbol("kHandle"),
kAutoDestroyed: Symbol("kAutoDestroyed"),
Expand Down
64 changes: 54 additions & 10 deletions src/js/node/_http_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ class HTTPClientAsyncResource {
}
}

// Node's parser AsyncWrap + _http_agent asyncResetHandle() make every socket
// callback re-enter the current request's async scope; Bun bridges this in
// JS by snapshotting the frame at tickOnSocket and running each socket
// listener (data/end/error/close/drain/timeout) inside it.
const kClientAsyncContext = Symbol("kClientAsyncContext");
const runInFrame = require("internal/async_context_frame").run;

function closeRequest(req) {
if (req[kClientAsyncContext] !== undefined) req[kClientAsyncContext] = undefined;
req._closed = true;
req.emit("close");
}

function isURLInstance(input) {
return input != null && typeof input === "object" && input instanceof URL;
}
Expand Down Expand Up @@ -515,6 +528,10 @@ function emitAbortNT(req) {
}

function ondrain() {
return runInFrame(this._httpMessage?.[kClientAsyncContext], ondrainInner, this);
}

function ondrainInner() {
const msg = this._httpMessage;
if (msg && !msg.finished && msg[kNeedDrain]) {
msg[kNeedDrain] = false;
Expand All @@ -523,6 +540,10 @@ function ondrain() {
}

function socketCloseListener() {
return runInFrame(this._httpMessage?.[kClientAsyncContext], socketCloseListenerInner, this);
}

function socketCloseListenerInner() {
const socket = this;
const req = socket._httpMessage;
$debug("HTTP socket close");
Expand All @@ -541,8 +562,7 @@ function socketCloseListener() {
if (!res.complete) {
res.destroy(new ConnResetException("aborted"));
}
req._closed = true;
req.emit("close");
closeRequest(req);
if (!res.aborted && res.readable) {
res.push(null);
}
Expand All @@ -554,8 +574,7 @@ function socketCloseListener() {
req.socket._hadError = true;
emitErrorEvent(req, new ConnResetException("socket hang up"));
}
req._closed = true;
req.emit("close");
closeRequest(req);
}

// Too bad. That output wasn't getting written.
Expand All @@ -571,6 +590,10 @@ function socketCloseListener() {
}

function socketErrorListener(err) {
return runInFrame(this._httpMessage?.[kClientAsyncContext], socketErrorListenerInner, this, err);
}
Comment thread
claude[bot] marked this conversation as resolved.

function socketErrorListenerInner(err) {
const socket = this;
const req = socket._httpMessage;
$debug("SOCKET ERROR:", err);
Expand All @@ -595,6 +618,10 @@ function socketErrorListener(err) {
}

function socketOnEnd() {
return runInFrame(this._httpMessage?.[kClientAsyncContext], socketOnEndInner, this);
}

function socketOnEndInner() {
const socket = this;
const req = this._httpMessage;
const parser = this.parser;
Expand All @@ -613,6 +640,10 @@ function socketOnEnd() {
}

function socketOnData(d) {
return runInFrame(this._httpMessage?.[kClientAsyncContext], socketOnDataInner, this, d);
}

function socketOnDataInner(d) {
const socket = this;

// HTTPParser.execute() is not reentrant. User code can synchronously push
Expand Down Expand Up @@ -689,10 +720,12 @@ function processClientData(socket, d, parser) {
socket._httpMessage = null;
socket.readableFlowing = null;

// Clear before the emit: a throwing upgrade/connect handler would skip
// closeRequest() and leave the retained request pinning the store.
req[kClientAsyncContext] = undefined;
req.emit(eventName, res, socket, bodyHead);
req.destroyed = true;
req._closed = true;
req.emit("close");
closeRequest(req);
} else {
// Requested Upgrade or used CONNECT method, but have no handler.
socket.destroy();
Expand Down Expand Up @@ -901,6 +934,10 @@ function responseOnEnd() {
}

function responseOnTimeout() {
return runInFrame(this._httpMessage?.[kClientAsyncContext], responseOnTimeoutInner, this);
}

function responseOnTimeoutInner() {
const req = this._httpMessage;
if (!req) return;
const res = req.res;
Expand All @@ -921,8 +958,7 @@ function requestOnFinish() {
}

function emitFreeNT(req) {
req._closed = true;
req.emit("close");
closeRequest(req);
const socket = req.socket;
if (socket) {
socket.emit("free");
Expand All @@ -932,6 +968,7 @@ function emitFreeNT(req) {
function tickOnSocket(req, socket) {
const parser = parsers.alloc();
req.socket = socket;
req[kClientAsyncContext] = $getInternalField($asyncContext, 0);
const lenientFlags = calculateLenientFlags(req.httpValidation, req.insecureHTTPParser);
parser.initialize(
HTTPParser.RESPONSE,
Expand Down Expand Up @@ -967,6 +1004,10 @@ function tickOnSocket(req, socket) {
}

function emitRequestTimeout() {
return runInFrame(this._httpMessage?.[kClientAsyncContext], emitRequestTimeoutInner, this);
}
Comment thread
alii marked this conversation as resolved.

function emitRequestTimeoutInner() {
const req = this._httpMessage;
if (req) {
req.emit("timeout");
Expand Down Expand Up @@ -1000,6 +1041,10 @@ ClientRequest.prototype.onSocket = function onSocket(socket, err) {
// to be set so we set it here too.
if (socket && !err) {
socket._httpMessage = this;
// Capture the frame here, not just in tickOnSocket: onSocket runs in the
// request's context, and an error in the window before onSocketNT would
// otherwise run socketErrorListener with no frame and clear the context.
this[kClientAsyncContext] = $getInternalField($asyncContext, 0);
socket.on("error", socketErrorListener);
}
process.nextTick(onSocketNT, this, socket, err);
Expand All @@ -1017,8 +1062,7 @@ function destroyRequestOnSocketNT(req, socket, err) {
// The request is dead with no parser: close the trace span on the paths
// that skip emitErrorEvent above (proxy tunnel; error already emitted).
traceClientResponseEnd(req);
req._closed = true;
req.emit("close");
closeRequest(req);
}

function onSocketNT(req, socket, err) {
Expand Down
Loading
Loading