Skip to content

async_hooks,events,http,http2,perf_hooks: port Node.js async compatibility tests and fix the gaps they surface — ALS run/disable + withScope/defaultValue, http client ALS across reused agent sockets, http2 ALS context, AsyncResource.bind, EventEmitterAsyncResource, timerify (+22 tests) - #31825

Merged
cirospaciari merged 46 commits into
mainfrom
claude/node-v26-async-tests
Jul 17, 2026

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 4, 2026

Copy link
Copy Markdown
Member

Brings the async-tagged portion of the Node test suite in line with Node v26.3.0 by porting upstream tests verbatim and fixing the runtime gaps they expose across async_hooks, events, http, http2, and perf_hooks.

parallel sequential total
async 111/169 (65.7%) 0/2 111/171 (64.9%)

22 upstream tests added (the rest of the original 56 have since landed via #31826 / #31830; coverage 32% → 65%; every vendored test passes).

Behavior changes

  • AsyncLocalStorage.run context corruption: the context slot was restored by an index captured before the callback, so a disable() during the callback spliced the array and the restore landed on the wrong store (or resurrected a disabled one). Restore is now by identity, matching Node's enterWith(prior) semantics, including re-adding and re-enabling a previous value.
  • AsyncLocalStorage v26 API surface: the defaultValue and name constructor options, and withScope() returning a disposable RunScope.
  • AsyncResource.bind preserved neither the wrapped function's arity (fn.length) nor call-time this (it forced the resource as thisArg); both now match Node.
  • EventEmitterAsyncResource was missing the asyncId/triggerAsyncId/asyncResource getters and the asyncResource.eventEmitter back-reference; rewritten to Node's class shape (EventEmitterReferencingAsyncResource).
  • http client AsyncLocalStorage across reused agent sockets: the llhttp HTTPParser binding ignores the async-resource argument, so a keep-alive socket reused by a second request dispatched parser callbacks (and the 'response'/'data'/'end' chain) in the first request's ALS context. tickOnSocket now snapshots the active async-context frame on the request and each socket listener (data/end/error/close/drain/timeout) runs inside it via internal/async_context_frame.run (test-async-local-storage-http-agent). The frame is captured in onSocket() as well as tickOnSocket: onSocket attaches the socket's error listener synchronously but tickOnSocket only runs a tick later, and runInFrame installs the frame it is given rather than leaving the ambient one alone — so an error arriving in that window used to run the user's 'error' handler in the root context and lose the store, where Node keeps it via the socket's own AsyncWrap.
  • http2 client streams lost the AsyncLocalStorage context active at request() time — Http2Stream captures the frame at construction and native #Handlers are wrapped via withStreamFrame; the session captures its own frame so 'close' doesn't inherit the last stream's. Both frames are released once their last read is done (stream in _destroy, session after the emit in emitSessionCloseNT) so a stream or session retained past its terminal event does not pin the store — the http1 counterpart of closeRequest()'s cleanup.
  • perf_hooks: performance.timerify (and the top-level timerify export), the 'function' PerformanceObserver entry type (JS-side dispatch wrapping the WebCore observer), and PerformanceNodeEntry.
  • perf_hooks export surface now matches v26.3.0's 13 keys: PerformanceNodeEntry is no longer exported (Node names the class but deliberately does not export it — an earlier revision of this PR did), and the top-level eventLoopUtilization Node exports was missing and is added. A test pins the surface against the real v26.3.0 key list. PerformanceNodeTiming is knowingly left as a bun-only extra: it predates this branch and removing a public export is a breaking change that belongs in its own PR.
  • run() on a disabled storage now restores on the way out. The restore block was gated on !wasDisabled, so als.disable(); als.run('Y', cb) left 'Y' installed after run() returned where Node yields the defaultValue (Node's finally is an unconditional enterWith(prior)). The gate dates to fix(asyncLocalStorage) store can be disabled multiple times #7015, which guarded a disable() during the callback by reading the flag at exit; snapshotting it at entry silently widened it to skip restoration for storages disabled before the call. The was-absent case is already handled by the identity-relocating branches, so the gate is gone.
  • perf_hooks option defaults are pollution-safe. timerify() and createHistogram() defaulted options to a plain {}, so a polluted Object.prototype.histogram/figures made them throw where Node — which defaults both to kEmptyObject — succeeds. createHistogram additionally took options || {}, silently accepting null/0/'x'/[] that Node rejects with ERR_INVALID_ARG_TYPE; it now validates like timerify already did.
  • AsyncLocalStorage store comparison is SameValue, matching Node's primordial ObjectIs. Stores were compared with ===, so NaN was unusable as a store value (run(NaN), enterWith(NaN) and { defaultValue: NaN } all tripped the debug restore assertions Node has no trouble with). run()'s unchanged-value short-circuit also read Object.is off the mutable global: userland patching Object.is made run() return the wrong store in release builds, where Node is immune. Both now use a pure-operator SameValue helper — a load-time Object.is capture would not fix it, since builtins load lazily and would inherit a patch applied before the first require.
  • validateObject: defer the isArray probe (which can throw on a Proxy) until after the cheap null/callable rejections, keeping the existing RETURN_IF_EXCEPTION.
  • test/common: vendor common/repl.js, export hasQuic from index.mjs, add test-runner fixtures.

Test adaptations (commented in-file)

  • Stack-overflow tests use non-tail recursion — JSC has proper tail calls, so upstream's tail-recursive overflow never overflows.
  • test-async-local-storage-weak-asyncwrap-leak uses a FinalizationRegistry instead of v8.queryObjects.
  • webcrypto skips the unimplemented ML-KEM/getPublicKey methods.

Known limitations / follow-ups

Testing

No performance regression: a release A/B (interleaved across fresh processes, same native objects, only the JS swapped) puts AsyncLocalStorage.run() at 9.12ns vs 9.75ns (-6.5%; the pure-operator SameValue inlines where the Object.is host call did not) and http.request() over a keep-alive agent at 52.8µs vs 53.1µs (-0.55%, within noise).

All 111 vendored async tests pass with the debug build, plus the unit suites for every touched module (async_hooks/, events/, perf_hooks/, http2/, http/); remaining failures in those suites reproduce on a clean-main binary (pre-existing).

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Updated 4:17 PM PT - Jul 16th, 2026

@cirospaciari, your commit 7d52ef1 has 2 failures in Build #74117 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31825

That installs a local version of the PR into your bun-31825 executable, so you can run:

bun-31825 --bun

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. performance.timerify is not a function #9271 - PR implements performance.timerify(), PerformanceNodeEntry, and NodePerformanceObserver with 'function' entry type, directly resolving this missing feature
  2. Undefined is not an object then using node:async_hooks:72:37 #17860 - The TypeError: undefined is not an object at run (node:async_hooks:72) matches the stale-index bug in AsyncLocalStorage.run() that this PR fixes by restoring context by identity rather than by index

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #9271
Fixes #17860

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(http2): preserve AsyncLocalStorage context across client request callbacks #29046 - Also fixes http2 client streams losing AsyncLocalStorage context at request() time using AsyncResource/runInAsyncScope
  2. feat(perf_hooks): add performance.timerify() implementation #27921 - Also implements performance.timerify() in perf_hooks
  3. fix: add performance.timerify method to perf_hooks #27887 - Also implements performance.timerify() in perf_hooks

🤖 Generated with Claude Code

Comment thread src/js/node/perf_hooks.ts
Comment thread src/js/node/perf_hooks.ts
Comment thread src/js/internal/fs/cp-sync.ts
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds structured filesystem error handling for cp operations, extends AsyncLocalStorage with scoped disposal and options support, refactors EventEmitterAsyncResource with lazy-loaded async resources, adds performance.timerify for function timing observation, and includes 60+ new tests covering async context preservation, fs.cp edge cases, stream iteration, and QUIC functionality.

Changes

Filesystem copy error standardization

Layer / File(s) Summary
Structured error helper and internal validation
src/js/internal/fs/cp-sync.ts
Introduces cpSystemError() helper to construct filesystem errors with code, errno, syscall, and path fields. Adds validateCpOptions() for shared option validation including mode bounds checking and incompatible option-pair enforcement.
cp-sync error standardization
src/js/internal/fs/cp-sync.ts
Replaces multiple generic Error throws in validation, file type handling, and existence checks with structured cpSystemError calls throughout checkPathsSync, checkParentPathsSync, getStats, mayCopyFile, onDir, and onLink functions.
cp error standardization
src/js/internal/fs/cp.ts
Standardizes cp.ts error handling with cpSystemError for identical src/dest, directory/non-directory mismatches, unsupported file types (socket, FIFO, unknown), and symlink validation errors.

AsyncLocalStorage and AsyncResource enhancements

Layer / File(s) Summary
RunScope and AsyncLocalStorage options
src/js/node/async_hooks.ts
Introduces RunScope helper for scoped disposal via dispose() and Symbol.dispose. Updates AsyncLocalStorage constructor to accept options object with defaultValue and name. Improves run() finally restoration to handle context array mutations by re-finding storage by identity.
AsyncResource.bind() enhancement
src/js/node/async_hooks.ts
Updates AsyncResource.bind(fn, thisArg) to special-case undefined thisArg with explicit wrapper function that reorders arguments. Defines non-enumerable length property on returned bound function.
AsyncLocalStorage and AsyncResource tests
test/js/node/test/parallel/test-async-local-storage-*.js, test/js/node/test/parallel/test-asyncresource-bind.js
Comprehensive coverage for AsyncLocalStorage isolation, RunScope with scope disposal semantics, withScope behavior, default values, nested scopes, and AsyncResource.bind() behavior across async boundaries.

EventEmitterAsyncResource refactor

Layer / File(s) Summary
EventEmitterAsyncResource lazy-load and getter-based API
src/js/node/events.ts
Refactors EventEmitterAsyncResource to lazily load AsyncResource, store it in private field, expose asyncId/triggerAsyncId/asyncResource via getters, support both string and options-object constructor arguments, and run emit() within async scope returning the super.emit() result.
EventEmitterAsyncResource tests
test/js/node/test/parallel/test-eventemitter-asyncresource.js
Validates naming via positional string and options object, asyncId/triggerAsyncId tracking, asyncResource.eventEmitter reference, and error handling on direct prototype method calls.

Async context preservation in streams

Layer / File(s) Summary
HTTP/2 async context capture
src/js/node/http2.ts
Captures async context in ClientHttp2Stream constructor to private field, overrides emit() to run superclass emit within that captured async scope.
HTTP client error event propagation
src/js/node/_http_client.ts
Adds explicit error event scheduling in ClientRequest.destroy() on nextTick to propagate errors in fetch-backed socket scenarios via emitErrorNextTick helper.
HTTP/2 and stream async context tests
test/js/node/test/parallel/test-http2-async-local-storage.js, test/js/node/test/parallel/test-async-local-storage-http-agent.js, test/js/node/test/parallel/test-stream-finished-async-local-storage.js
Validates AsyncLocalStorage propagation across HTTP/2 concurrent requests, HTTP keep-alive agent socket reuse, and stream.finished() context preservation.

fs.cp path validation and fast-path optimization

Layer / File(s) Summary
fs.cpSync path validation and fast-path routing
src/js/node/fs.ts
Updates fs.cpSync to validate/normalize src/dest paths and run options through validateCpOptions. Routes to cpSyncImpl for special options or attempts native fast path for simple copies when dest doesn't exist.
fs.cp and fs.promises.cp option validation and routing
src/js/node/fs.promises.ts
Updates fs.cp to validate options before calling promises.cp. Implements cpImpl() for async copying with conditional native fast path based on lstat inspection of src/dest.
fs.cp error code expectations
test/js/node/fs/cp.test.ts
Updates test expectations for fs.cp error codes to Node-style ERR_FS_* format (ERR_FS_EISDIR, ERR_FS_CP_EEXIST) and updates regression test for unix socket handling.

Performance.timerify implementation

Layer / File(s) Summary
Performance timerify and node entry pipeline
src/js/node/perf_hooks.ts
Introduces PerformanceNodeEntry data model, PerformanceNodeObserverEntryList, NodePerformanceObserver that routes "function" entry observation, timerify() wrapper measuring performance.now() duration with optional histogram recording, and microtask-scheduled observer dispatch.
Performance.timerify and function entry tests
test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs, test/js/node/test/parallel/test-performance-function-async.js
Validates timerify wraps functions, integrates with histograms, and records function timing entries observable via PerformanceObserver with type:'function'.

Comprehensive fs.cp test coverage

Layer / File(s) Summary
Filesystem test helper utilities
test/js/node/test/common/fs.js, test/js/node/test/common/index.mjs, test/js/node/test/common/repl.js
Adds nextdir for unique temp paths, assertDirEquivalent for directory tree comparison, collectEntries for recursive entry collection, hasQuic export, and startNewREPLServer utilities.
Basic fs.cp file and directory copy tests
test/js/node/test/parallel/test-fs-cp-async-file-to-file.mjs, test/js/node/test/parallel/test-fs-cp-async-file-to-dir.mjs, test/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjs, test/js/node/test/parallel/test-fs-cp-async-nested-files-folders.mjs, test/js/node/test/parallel/test-fs-cp-async-file-url.mjs
Tests for simple file-to-file, file-to-directory, directory-to-file, nested directory copies, and file URL handling.
fs.cp symlink handling tests
test/js/node/test/parallel/test-fs-cp-async-copy-non-directory-symlink.mjs, test/js/node/test/parallel/test-fs-cp-async-dereference-symlink.mjs, test/js/node/test/parallel/test-fs-cp-async-dest-symlink-points-to-src-error.mjs, test/js/node/test/parallel/test-fs-cp-async-symlink-dest-points-to-src.mjs, test/js/node/test/parallel/test-fs-cp-async-symlink-points-to-dest.mjs, test/js/node/test/parallel/test-fs-cp-async-symlink-over-file.mjs
Tests for symlink copying, dereferencing, symlink-to-subdirectory detection, and symlink-to-file overwrite behavior.
fs.cp filter and preservation tests
test/js/node/test/parallel/test-fs-cp-async-async-filter-function.mjs, test/js/node/test/parallel/test-fs-cp-async-filter-function.mjs, test/js/node/test/parallel/test-fs-cp-async-filter-child-folder.mjs, test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps.mjs, test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps-readonly-file.mjs, test/js/node/test/parallel/test-fs-cp-async-with-mode-flags.mjs, test/js/node/test/parallel/test-fs-cp-async-skip-validation-when-filtered.mjs
Tests for async/sync filter functions, timestamp preservation including read-only files, mode flags, and filter-based validation skipping.
fs.cp error condition tests
test/js/node/test/parallel/test-fs-cp-async-identical-src-dest.mjs, test/js/node/test/parallel/test-fs-cp-async-subdirectory-of-self.mjs, test/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjs, test/js/node/test/parallel/test-fs-cp-async-dir-exists-error-on-exist.mjs, test/js/node/test/parallel/test-fs-cp-async-no-recursive.mjs, test/js/node/test/parallel/test-fs-cp-async-no-errors-force-false.mjs, test/js/node/test/parallel/test-fs-cp-async-invalid-*.mjs, test/js/node/test/parallel/test-fs-cp-async-socket.mjs
Tests for all error conditions: identical src/dest, subdirectory-of-self, dir/non-dir mismatches, existence conflicts, invalid options, sockets, and errorOnExist behavior.
fs.cp advanced scenarios and edge cases
test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs, test/js/node/test/parallel/test-fs-cp-sync-async-filter-error.mjs, test/js/node/test/parallel/test-fs-cp-async-same-dir-twice.mjs, test/js/node/test/parallel/test-fs-cp-async-error-on-exist.mjs, test/js/node/test/parallel/test-fs-cp-promises-async-error.mjs, test/js/node/test/parallel/test-fs-cp-async-overwrites-force-true.mjs
Tests for promises.cp async errors, sync filter errors, same-dir-twice copies, no-recursive behavior, overwrites with force, and dereference scenarios.

Stream and async iterator test coverage

Layer / File(s) Summary
Readable stream async iterator tests
test/js/node/test/parallel/test-stream-readable-async-iterators.js
Extensive test suite for Symbol.asyncIterator on readable streams covering v1 iteration, error handling, destruction semantics, object mode, binary encoding, pipelined streams, and iterator.return() behavior with destroyOnReturn option.
QUIC async stream and callback error handling tests
test/js/node/test/parallel/test-quic-callback-error-*.mjs, test/js/node/test/parallel/test-quic-stream-body-async-*.mjs, test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs, test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs
Tests for QUIC stream bodies using async iterables, async callback error propagation, error suppression, and asyncDispose semantics.

Async hooks and worker regression tests

Layer / File(s) Summary
Async hooks stack overflow regression tests
test/js/node/test/parallel/test-async-hooks-stack-overflow*.js
Tests verifying that RangeError from stack overflow is properly caught by try-catch when async_hooks is enabled, routed to uncaughtException handler, and handled through nested async operations.
Worker and WebCrypto async tests
test/js/node/test/parallel/test-worker-process-exit-async-module.js, test/js/node/test/parallel/test-webcrypto-methods-not-async.js
Tests for worker thread process.exit() ordering and WebCrypto non-async method invariant enforcement.

Suggested reviewers

  • Jarred-Sumner
  • alii
  • dylan-conway
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description covers the PR purpose and verification, but it does not use the repository's required 'What does this PR do?' and 'How did you verify your code works?' headings. Reformat the description to match the template exactly and move the existing summary/testing content under the required two headings.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main async compatibility test ports and runtime fixes across async_hooks, events, fs, http, http2, and perf_hooks.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/fs/cp.test.ts (1)

544-552: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure socket cleanup runs in all paths and is awaited.

sock.close() is not guaranteed to run if an earlier await throws, and it is not awaited. Wrap this block in try/finally and await close completion to avoid leaking a server handle in failure paths.

Proposed fix
-    await using proc = Bun.spawn({
-      cmd: [bunExe(), "-e", script],
-      env: bunEnv,
-      stdout: "pipe",
-      stderr: "pipe",
-    });
-    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
-    sock.close();
-    expect(stderr).toBe("");
-    expect(stdout.trim()).toBe("ok");
-    expect(exitCode).toBe(0);
+    try {
+      await using proc = Bun.spawn({
+        cmd: [bunExe(), "-e", script],
+        env: bunEnv,
+        stdout: "pipe",
+        stderr: "pipe",
+      });
+      const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+      expect(stderr).toBe("");
+      expect(stdout.trim()).toBe("ok");
+      expect(exitCode).toBe(0);
+    } finally {
+      await new Promise<void>(resolve => sock.close(() => resolve()));
+    }

As per coding guidelines: “Track resources (servers, clients) in arrays for cleanup in afterEach().”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/fs/cp.test.ts` around lines 544 - 552, The socket close call in
the test after spawning the process (the block using Bun.spawn with variables
proc, stdout/stderr/exitCode) isn't awaited and won't run if an earlier await
throws; wrap the await Promise.all([...]) and the subsequent logic in a
try/finally so you always await sock.close() in the finally block (e.g., await
sock.close()) and also register the socket in the test-level cleanup array per
the afterEach() resource tracking guideline so the server handle is cleaned up
on all paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/perf_hooks.ts`:
- Around line 237-242: timerify currently throws when options is null (during
destructuring) and may throw when histogram is null while checking
histogram.record; update timerify to explicitly reject null/non-object inputs:
first validate that options is not null/undefined (and if it is, throw
$ERR_INVALID_ARG_TYPE("options", "Object", options)), then extract histogram and
validate histogram !== undefined && histogram !== null && typeof histogram ===
"object" && typeof histogram.record === "function", otherwise throw
$ERR_INVALID_ARG_TYPE("options.histogram", "RecordableHistogram", histogram);
keep references to validateFunction(fn, "fn") and the $ERR_INVALID_ARG_TYPE
helper when implementing these checks.
- Around line 199-224: The observe method can add this to functionObservers
prematurely and never remove it if validation or super.observe throws or if
later calls stop requesting "function" entries; change the logic in observe (the
observe method and its use of functionObservers) so that registration to
functionObservers happens only after super.observe succeeds and ensure any prior
registration is removed when options do not include "function" (including when
entryTypes is present but contains no "function"), and also wrap the
super.observe call in a try/catch to remove the registration if an error is
thrown (i.e., delay functionObservers.add(this) until after successful
super.observe and call functionObservers.delete(this) when switching modes or on
error).
- Around line 244-255: The timerified wrapper in function timerified skips
calling processTimerifyComplete when fn throws synchronously; modify timerified
so the invocation of fn (via fn.$apply for normal calls or Reflect.construct for
constructor calls) is executed inside a try block and ensure
processTimerifyComplete(fn.name, start, args, histogram) runs in a finally block
so timing is recorded for both success and synchronous exceptions; for
promise-returning results keep the existing result.finally behavior but make
sure synchronous exceptions are rethrown after the finally so the original error
propagation is preserved (update code around timerified, fn.$apply,
Reflect.construct, start, args, histogram, and processTimerifyComplete
accordingly).

In `@test/js/node/test/common/fs.js`:
- Around line 20-23: In assertDirEquivalent, the current matching uses
entry.name which breaks for nested trees; change the matching logic that
iterates dir1Entries/dir2Entries to find pairs by comparing the computed relPath
(relative path from the root) rather than entry.name, and after pairing verify
the file type (e.g. directory vs file) on the matched entry (the existing checks
that use entry.type or entry.isDirectory()/isFile()) so nested items like
"a/file.txt" vs "b/file.txt" won't be conflated; update the two loops that
reference dir1Entries/dir2Entries (the loop starting at the snippet with
entry1/entry2 and the similar block at lines ~35-43) to locate entries by
relPath and then run the same equivalence assertions on the paired entries.
- Line 24: The assertion dereferences entry2 in the message which will throw if
entry2 is undefined; change the assert in the test so the failure message does
not access entry2.name (e.g., use a static message or reference the expected
name from the original entry variable) — update the assert call that mentions
entry2 to avoid entry2.name and instead use a safe expression or known
expectedName in the message.

In
`@test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs`:
- Around line 18-20: The test is not exercising the "force: false" path because
the options passed to cp(...) only include dereference and recursive; update the
options object in the cp(...) call (the cp function invocation) to include
force: false (e.g., cp(src, dest, { dereference: true, recursive: true, force:
false })) so the test actually validates the force-false behavior described by
the filename.

---

Outside diff comments:
In `@test/js/node/fs/cp.test.ts`:
- Around line 544-552: The socket close call in the test after spawning the
process (the block using Bun.spawn with variables proc, stdout/stderr/exitCode)
isn't awaited and won't run if an earlier await throws; wrap the await
Promise.all([...]) and the subsequent logic in a try/finally so you always await
sock.close() in the finally block (e.g., await sock.close()) and also register
the socket in the test-level cleanup array per the afterEach() resource tracking
guideline so the server handle is cleaned up on all paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44f86698-4476-443a-85bc-612f8b5cf035

📥 Commits

Reviewing files that changed from the base of the PR and between 977f054 and 719e212.

📒 Files selected for processing (69)
  • src/js/internal/fs/cp-sync.ts
  • src/js/internal/fs/cp.ts
  • src/js/node/_http_client.ts
  • src/js/node/async_hooks.ts
  • src/js/node/events.ts
  • src/js/node/fs.promises.ts
  • src/js/node/fs.ts
  • src/js/node/http2.ts
  • src/js/node/perf_hooks.ts
  • test/js/node/fs/cp.test.ts
  • test/js/node/test/common/fs.js
  • test/js/node/test/common/index.mjs
  • test/js/node/test/common/repl.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow-nested-async.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow-try-catch.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow.js
  • test/js/node/test/parallel/test-async-local-storage-http-agent.js
  • test/js/node/test/parallel/test-async-local-storage-http-parser-leak.js
  • test/js/node/test/parallel/test-async-local-storage-isolation.js
  • test/js/node/test/parallel/test-async-local-storage-run-scope.js
  • test/js/node/test/parallel/test-async-local-storage-weak-asyncwrap-leak.js
  • test/js/node/test/parallel/test-asyncresource-bind.js
  • test/js/node/test/parallel/test-eventemitter-asyncresource.js
  • test/js/node/test/parallel/test-fs-cp-async-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-copy-non-directory-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dest-symlink-points-to-src-error.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-exists-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-dir.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-url.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-child-folder.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-identical-src-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-mode-range.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-options-type.mjs
  • test/js/node/test/parallel/test-fs-cp-async-nested-files-folders.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-errors-force-false.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-recursive.mjs
  • test/js/node/test/parallel/test-fs-cp-async-overwrites-force-true.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps-readonly-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps.mjs
  • test/js/node/test/parallel/test-fs-cp-async-same-dir-twice.mjs
  • test/js/node/test/parallel/test-fs-cp-async-skip-validation-when-filtered.mjs
  • test/js/node/test/parallel/test-fs-cp-async-socket.mjs
  • test/js/node/test/parallel/test-fs-cp-async-subdirectory-of-self.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-dest-points-to-src.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-over-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-points-to-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-with-mode-flags.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-async-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-async-filter-error.mjs
  • test/js/node/test/parallel/test-http2-async-local-storage.js
  • test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs
  • test/js/node/test/parallel/test-performance-function-async.js
  • test/js/node/test/parallel/test-quic-callback-error-ondatagram-async.mjs
  • test/js/node/test/parallel/test-quic-callback-error-onstream-async.mjs
  • test/js/node/test/parallel/test-quic-callback-error-suppressed-async.mjs
  • test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs
  • test/js/node/test/parallel/test-quic-stream-body-async-error.mjs
  • test/js/node/test/parallel/test-quic-stream-body-async-iterable.mjs
  • test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs
  • test/js/node/test/parallel/test-stream-finished-async-local-storage.js
  • test/js/node/test/parallel/test-stream-readable-async-iterators.js
  • test/js/node/test/parallel/test-webcrypto-methods-not-async.js
  • test/js/node/test/parallel/test-worker-process-exit-async-module.js

Comment thread src/js/node/perf_hooks.ts
Comment thread src/js/node/perf_hooks.ts Outdated
Comment thread src/js/node/perf_hooks.ts
Comment thread test/js/node/test/common/fs.js
Comment thread test/js/node/test/common/fs.js
@cirospaciari
cirospaciari force-pushed the claude/node-v26-async-tests branch from 719e212 to 30adb17 Compare June 5, 2026 01:56
Comment thread src/js/node/perf_hooks.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/async_hooks.ts`:
- Around line 81-93: RunScope.dispose currently always calls
this.#storage.enterWith(this.#previousStore), which creates a stale store entry
when there was no prior store; change dispose to check if this.#previousStore is
undefined and, if so, call this.#storage.disable() (or the ALS instance's method
to clear/remove the store) instead of enterWith; otherwise restore with
enterWith(this.#previousStore). Apply the same check-and-disable fix to the
other similar disposal site where enterWith(previousStore) is used (the block
referenced around the other dispose-like code).

In `@src/js/node/events.ts`:
- Around line 825-827: The constructor for EventEmitterAsyncResource calls
validateString(options?.name, "options.name") even when options is omitted,
causing new EventEmitterAsyncResource() to throw; change the check so
validateString is only invoked when options is provided and options.name is
non-null/defined (e.g., guard with options != null && options.name != null)
before calling validateString("options.name"), leaving behavior unchanged when a
caller supplies an options.name; reference EventEmitterAsyncResource and
validateString to locate the constructor and adjust the conditional.

In `@src/js/node/http2.ts`:
- Around line 2338-2349: Add an override of _destroy(err, callback) on
ClientHttp2Stream that calls this.#asyncResource.emitDestroy() exactly once
(guarding if necessary) before delegating to super._destroy(err, callback); this
ensures the per-instance AsyncResource created in the constructor
(this.#asyncResource) is properly destroyed when the stream is torn down and
keeps emit(event, ...) runInAsyncScope behavior correct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: de7adb4d-3c57-4ef1-8a01-53cf5cdc61e1

📥 Commits

Reviewing files that changed from the base of the PR and between 719e212 and 30adb17.

📒 Files selected for processing (70)
  • src/js/internal/fs/cp-sync.ts
  • src/js/internal/fs/cp.ts
  • src/js/node/_http_client.ts
  • src/js/node/async_hooks.ts
  • src/js/node/events.ts
  • src/js/node/fs.promises.ts
  • src/js/node/fs.ts
  • src/js/node/http2.ts
  • src/js/node/perf_hooks.ts
  • src/jsc/bindings/NodeValidator.cpp
  • test/js/node/fs/cp.test.ts
  • test/js/node/test/common/fs.js
  • test/js/node/test/common/index.mjs
  • test/js/node/test/common/repl.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow-nested-async.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow-try-catch.js
  • test/js/node/test/parallel/test-async-hooks-stack-overflow.js
  • test/js/node/test/parallel/test-async-local-storage-http-agent.js
  • test/js/node/test/parallel/test-async-local-storage-http-parser-leak.js
  • test/js/node/test/parallel/test-async-local-storage-isolation.js
  • test/js/node/test/parallel/test-async-local-storage-run-scope.js
  • test/js/node/test/parallel/test-async-local-storage-weak-asyncwrap-leak.js
  • test/js/node/test/parallel/test-asyncresource-bind.js
  • test/js/node/test/parallel/test-eventemitter-asyncresource.js
  • test/js/node/test/parallel/test-fs-cp-async-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-copy-non-directory-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dest-symlink-points-to-src-error.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-exists-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-dir.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-url.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-child-folder.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-identical-src-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-mode-range.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-options-type.mjs
  • test/js/node/test/parallel/test-fs-cp-async-nested-files-folders.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-errors-force-false.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-recursive.mjs
  • test/js/node/test/parallel/test-fs-cp-async-overwrites-force-true.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps-readonly-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps.mjs
  • test/js/node/test/parallel/test-fs-cp-async-same-dir-twice.mjs
  • test/js/node/test/parallel/test-fs-cp-async-skip-validation-when-filtered.mjs
  • test/js/node/test/parallel/test-fs-cp-async-socket.mjs
  • test/js/node/test/parallel/test-fs-cp-async-subdirectory-of-self.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-dest-points-to-src.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-over-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-points-to-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-with-mode-flags.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-async-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-async-filter-error.mjs
  • test/js/node/test/parallel/test-http2-async-local-storage.js
  • test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs
  • test/js/node/test/parallel/test-performance-function-async.js
  • test/js/node/test/parallel/test-quic-callback-error-ondatagram-async.mjs
  • test/js/node/test/parallel/test-quic-callback-error-onstream-async.mjs
  • test/js/node/test/parallel/test-quic-callback-error-suppressed-async.mjs
  • test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs
  • test/js/node/test/parallel/test-quic-stream-body-async-error.mjs
  • test/js/node/test/parallel/test-quic-stream-body-async-iterable.mjs
  • test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs
  • test/js/node/test/parallel/test-stream-finished-async-local-storage.js
  • test/js/node/test/parallel/test-stream-readable-async-iterators.js
  • test/js/node/test/parallel/test-webcrypto-methods-not-async.js
  • test/js/node/test/parallel/test-worker-process-exit-async-module.js

Comment thread src/js/node/async_hooks.ts
Comment thread src/js/node/events.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/internal/fs/cp-sync.ts Outdated
Comment thread src/js/node/perf_hooks.ts Outdated
Vendors the async-related tests from node v26.3.0 test/parallel that Bun can
support (57 files: fs.cp, AsyncLocalStorage, AsyncResource, stack-overflow,
stream iterators, quic skip-guards, webcrypto, perf_hooks) and fixes the
runtime bugs they exposed. All 56 vendored async tests pass, plus the existing
unit suites for every touched module.

Runtime fixes:
- AsyncLocalStorage.run restored its context slot by the index captured before
  the callback; disable() during the callback splices the array and corrupts
  the restore. Now restores by identity, matching node's enterWith(prior)
  semantics (including re-adding and re-enabling a previous value).
- AsyncLocalStorage: defaultValue + name constructor options, withScope()
  disposable RunScope (new in node 26).
- AsyncResource.bind lost the wrapped function's arity and forced thisArg to
  the resource; now preserves fn.length and forwards call-time this.
- EventEmitterAsyncResource was missing the asyncId/triggerAsyncId/
  asyncResource getters and the asyncResource.eventEmitter back-reference;
  rewritten to node's class shape (EventEmitterReferencingAsyncResource).
- fs.cp callback got undefined instead of null on success.
- fs.cp/cpSync/promises.cp now run node's validateCpOptions and route through
  the JS port (with node's ERR_FS_CP_* SystemError shapes restored in
  internal/fs/cp{,-sync}) whenever node error semantics can fire; the native
  clonefile fast path is kept for fresh-destination copies of plain files and
  directory trees. Also ports node 26's "dest dir exists + errorOnExist +
  !force => ERR_FS_CP_EEXIST" rule and URL src/dest normalization.
- ClientRequest.destroy(err) never emitted 'error' on the request (node relays
  it via the socket error listener).
- http2 client streams lost the AsyncLocalStorage context active at request()
  time; events from native parser callbacks now run in it via an AsyncResource.
- perf_hooks: implement performance.timerify + top-level timerify export, the
  'function' PerformanceObserver entry type (JS-side dispatch wrapping the
  WebCore observer), and PerformanceNodeEntry.
- test/common: vendor common/fs.js + common/repl.js, export hasQuic from
  index.mjs, add test-runner fixtures.

Test adaptations (commented in-file): stack-overflow tests use non-tail
recursion (JSC has proper tail calls), weak-asyncwrap-leak uses a
FinalizationRegistry instead of v8.queryObjects, webcrypto skips the
unimplemented ML-KEM/getPublicKey methods, readable-async-iterators forces
multi-chunk reads where Bun's eager EOF made a close non-premature.

cp.test.ts updates: EISDIR/EEXIST expectations become node's ERR_FS_EISDIR/
ERR_FS_CP_EEXIST, and the AsyncCpTask use-after-free regression test now
triggers via a unix socket in a fresh destination on Linux (the per-file
native path is no longer reachable on macOS, where fresh trees are a single
clonefile and existing destinations go through the JS port).
- PerformanceObserver.observe: the entryTypes form replaces the observed
  set, so drop a prior 'function' registration when the new list lacks it,
  and only register after super.observe() succeeds
- timerify: validate options with validateObject and reject null histograms
  with ERR_INVALID_ARG_TYPE instead of crashing on property access
- cpSync: give the identical-src-dest error the same SystemError shape
  (errno, syscall, path, info) as the async path
- fs.cp/cpSync: stop resolving src/dest before passing them to the JS
  implementation; node hands the caller's strings to filter callbacks
  verbatim, and resolving broke mixed-separator paths on Windows
- NodeValidator: check the exception scope after JSC::isArray in
  validateObject; isArray can throw through the proxy slow path
- cp.test.ts: same-file copy on Windows now expects ERR_FS_CP_EINVAL,
  matching node (EINVAL fires before any file op, so EBUSY cannot occur)
node's getValidatedPath only converts file URLs and validates the string;
it never resolves or normalizes. The path field on ERR_FS_EISDIR and
ERR_FS_CP_EEXIST therefore echoes the string the caller passed in. The
test built its inputs with string concatenation but asserted against
join(), which only agrees on posix; on Windows join() produces
backslashes while the error carries the original forward-slash
concatenation.
…servers on function-only re-observe

- getValidatedCpPath: accept Uint8Array/Buffer paths like node's
  getValidatedPath does; decode to a string and skip file: URL sniffing
  since node treats byte paths literally
- PerformanceObserver.supportedEntryTypes now includes 'function', which
  this module supports via timerify, so feature detection works
- observe({entryTypes: ['function']}) after a web-type subscription now
  disconnects the underlying web observer; the entryTypes form replaces
  the whole observed set in node
@cirospaciari
cirospaciari force-pushed the claude/node-v26-async-tests branch from 65099eb to 190edd3 Compare June 5, 2026 20:11
Comment thread src/js/node/fs.ts Outdated
node's fs.cp validates src and dest before starting the async work, so an
invalid path type throws ERR_INVALID_ARG_TYPE synchronously and the
callback is never invoked. We already did this for options; hoist the
path validation too instead of letting it reject inside promises.cp.
Comment thread src/js/node/async_hooks.ts
The debug-only assert at the end of run()'s finally compared getStore()
against the raw previous value. With the new defaultValue option,
getStore() falls through to the default once the entry is removed from
the context, so any run() on a storage with a non-undefined defaultValue
and no prior entry threw an AssertionError in debug builds (masking the
callback's result). Compare against the value getStore() actually
reports: the previous value when one existed, undefined when a disable()
during the callback left the storage disabled, and the defaultValue
otherwise.
Comment thread src/js/node/perf_hooks.ts Outdated
Comment thread src/js/node/fs.promises.ts Outdated
…promises.cp options

- PerformanceNodeEntry's prototype chain now links to PerformanceEntry so
  observer entries pass instanceof checks like node's. extends can't work
  (the WebCore constructor throws) and $toClass would swap the prototype
  object out, dropping the class's own toJSON; setPrototypeOf keeps the
  class body intact, and the instances' own data fields shadow the
  inherited brand-checked accessors.
- fsPromises.cp is now async like node's, so option-validation errors
  (e.g. mode out of range) surface as rejections instead of synchronous
  throws. The callback form keeps its intentional sync throw.
Comment thread src/js/node/events.ts
Accessing an ES private field on a wrong receiver throws the engine's
brand-check TypeError before any === undefined comparison runs, and the
constructor always assigns the field, so these ERR_INVALID_THIS throws
could never fire. node v26's lib/events.js uses guard-free private-field
getters with the same observable behavior, so delete the dead checks
rather than reintroducing Symbol-keyed storage.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All prior review feedback has been addressed and the latest pass found nothing new, but this is a large change spanning six core Node-compat modules (ALS run/restore semantics, fs.cp native↔JS-port routing with an acknowledged perf trade-off, http2 async context, timerify, EventEmitterAsyncResource rewrite, plus a NodeValidator.cpp tweak) — worth a human pass before merge.

Extended reasoning...

Overview

This PR ports ~56 upstream Node.js async-tagged tests and fixes the runtime gaps they expose across async_hooks (ALS run() restore-by-identity, defaultValue/name/withScope, AsyncResource.bind arity/this), events (EventEmitterAsyncResource rewritten to Node's class shape), fs.cp/cpSync/promises.cp (Node's validateCpOptions, ERR_FS_CP_* SystemError shapes, native-fast-path gating), _http_client (destroy(err) now emits 'error'), http2 (per-stream AsyncResource so events run in the request()-time ALS context), and perf_hooks (timerify, PerformanceNodeEntry, NodePerformanceObserver wrapping the WebCore observer for the 'function' entry type). It also touches src/jsc/bindings/NodeValidator.cpp to add an exception check around JSC::isArray. ~60 of the 71 files are vendored upstream tests or test-common helpers.

Security risks

None identified. No auth/crypto/permissions surfaces are touched; the C++ change is a defensive RETURN_IF_EXCEPTION reordering. The fs.cp changes affect error-shape and routing only — no new path-traversal or symlink-following behavior beyond what the Node port already had.

Level of scrutiny

High. While the bulk of the diff is vendored tests, the runtime changes are non-trivial and cross-cutting:

  • The fs.cp family now gates the native fast path on lstat(dest) returning ENOENT and routes existing-destination merges through the JS port. The PR description explicitly calls this out as a perf trade-off with a follow-up to teach the native task Node's error shapes. This is a design decision a maintainer should sign off on.
  • AsyncLocalStorage.run()'s finally restoration logic was rewritten (restore-by-identity instead of by captured index) and interacts with disable(), #defaultValue, and a debug-only $assert. This area went through multiple review iterations.
  • http2.ClientHttp2Stream now wraps every emit() in runInAsyncScope — a hot-path change.
  • perf_hooks exports a subclassed PerformanceObserver instead of the WebCore one; the observe/disconnect replace-vs-additive semantics took several rounds to get right.

Other factors

The author has been responsive — every inline comment I and CodeRabbit raised across four review rounds (observe() replace semantics, timerify null guards, cp-sync areIdentical SystemError shape, Buffer cp paths, supportedEntryTypes, sync-vs-async cp path validation, the defaultValue debug-assert, PerformanceNodeEntry prototype chain, fsPromises.cp async, dead ERR_INVALID_THIS guards) was either fixed or correctly rebutted as upstream-verbatim/Node-matching behavior. The latest bug-hunting pass found nothing. CI build #61022 is referenced. Given the breadth and the explicitly acknowledged follow-up, human review is appropriate; I'm not approving.

@cirospaciari
cirospaciari force-pushed the claude/node-v26-async-tests branch from 4aa666f to a34c136 Compare June 8, 2026 18:59
robobun and others added 2 commits June 9, 2026 18:39
- _http_client.ts: take main. #31825's destroy(err) error-emit
  workaround targeted the now-removed fetch-backed client; #31587's
  rewrite routes destroy(err) through the real socket.

- perf_hooks.ts: main generalized node-only entry routing
  (PerformanceObserverForNodeTypes + NodeEntryObserver in
  internal/shared, kNodeEntryTypes={net,dns,http}); #31825 built a
  parallel single-purpose observer for the 'function' type (timerify).
  Resolved by porting timerify + PerformanceNodeEntry onto main's
  framework: 'function' added to kNodeEntryTypes, a new
  enqueueNodeEntry() exported from internal/shared, and
  processTimerifyComplete() routes through it instead of the now-
  dropped function-only Set/dispatch.
Comment thread src/js/internal/fs/cp-sync.ts Outdated
…-tests

# Conflicts:
#	src/js/internal/fs/cp-sync.ts
#	src/js/internal/fs/cp.ts
#	src/js/node/fs.promises.ts
#	src/js/node/fs.ts
#	src/jsc/bindings/NodeValidator.cpp
#	test/js/node/fs/cp.test.ts
#	test/js/node/test/parallel/test-fs-cp-async-file-url.mjs
#	test/js/node/test/parallel/test-stream-finished-async-local-storage.js
#	test/js/node/test/parallel/test-stream-readable-async-iterators.js
…ient parser callbacks

A keep-alive socket dispatched parser callbacks (and the response/data/end
chain) in the ALS context of whichever request first connected it, because
Bun's native HTTPParser ignores the async-resource argument. tickOnSocket
now snapshots the active $asyncContext frame on the request and the socket
data/end listeners restore it; the snapshot is cleared at request close so
retaining the request object does not pin the stores. The raw frame is used
directly so http.request() does not flip on async-context tracking when no
ALS is in use.

Also fixes the run() finally to scan only key (even) slots when re-locating
the storage — Array.prototype.indexOf could match an odd value slot when
another ALS stored this storage as its value — and gates timerify entry
emission on hasObserver('function').
… store

getStore() reports #defaultValue while the storage is disabled, but the active
frame can still hold a real value for it — a snapshot() taken before disable()
restores exactly that. run()'s unchanged-value short-circuit compared against
getStore(), so inside such a frame `exit()` / `run(undefined, ...)` matched the
mask, returned without installing the store, and the callback then read the
frame's unmasked value: 'X' where Node gives undefined. Skip the short-circuit
while disabled and take the full install/restore path, which re-enables the
storage as before.
Comment thread test/js/node/async_hooks/AsyncLocalStorage.test.ts
…ocess

The destroy(err) fixture was the only one of the four still exiting abruptly.
It happens to be safe today -- destroy(err) tears the session down first -- but
the sibling that did the same leaked its session and aborted the x64-asan
shard, so align it rather than leave the one exception.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new findings on fafa64d — the last nit (subprocess process.exit(0) alignment) is addressed and all prior threads are resolved. Deferring to a human for sign-off given the scope: this reworks AsyncLocalStorage.run() restore semantics and threads async-context frames through the http/http2 client hot paths.

What was reviewed:

  • run()'s new sameValue short-circuit + identity-based restore against disable()/snapshot()/enterWith() interleavings — the disabled-storage masking case is now guarded.
  • Frame-clear ordering vs throwing listeners across every runInFrame emit site in http2 (destroy, emitSessionCloseNT, _destroy) and _http_client (upgrade/connect, closeRequest).
  • The four subprocess frame-cleanup tests for timer-free conditions and ASAN-safe teardown.
Extended reasoning...

Overview

33 files across five Node-compat modules. The runtime changes are: AsyncLocalStorage (defaultValue/name/withScope, SameValue comparison, identity-based restore in run()'s finally, unconditional restoration on disabled storages, !#disabled guard on the short-circuit); AsyncResource.bind (arity + call-time this); EventEmitterAsyncResource (rewritten to Node's EventEmitterReferencingAsyncResource shape with own-property emit deletion); _http_client.ts (per-request kClientAsyncContext snapshot, seven socket listeners split into runInFrame wrappers, cleared in closeRequest); http2.ts (per-stream and per-session frame capture, withStreamFrame around client #Handlers, read-and-clear before 'error'/'close' emits in both destroy() paths); perf_hooks (timerify, PerformanceNodeEntry, 'function' observer type, kEmptyObject defaults + validateObject for createHistogram); and internal/async_context_frame (current/exchange/run). The rest is 22 vendored Node parallel tests, harness additions (hasQuic, common/repl.js), and unit-test coverage in AsyncLocalStorage.test.ts / EventEmitterAsyncResource.test.ts / perf_hooks.test.ts.

Security risks

None identified. The pollution-safety changes (kEmptyObject defaults, __proto__: null descriptors in bind/timerify) are hardening. runInFrame uses $apply intrinsics; sameValue is pure operators so a userland Object.is patch cannot influence run(). No auth/crypto/permissions surface.

Level of scrutiny

High. AsyncLocalStorage.run() is on the hot path of essentially every request-scoped-context workload, and its restore logic now branches on four cases (context2===context, idx>-1 && hasPrevious, idx>-1 && !hasPrevious, idx===-1 && hasPrevious, idx===-1 && !hasPrevious) each verified against Node v26. Wrapping http/http2 socket listeners changes when user callbacks observe which store on every request. The review history bears this out — roughly a dozen real defects were surfaced and fixed over six weeks (index-based restore corruption, emitSessionCloseNT throw-past-clear, sibling destroy() error-emit throw-past-clear, _destroy 'aborted' throw-past-clear, upgrade-handler throw-past-clear, disabled-storage short-circuit unmasking, setTimeout-in-test flakiness, ASAN subprocess exits). That churn is exactly why a maintainer should sign off on the final shape rather than an automated pass.

Other factors

All 89 review threads are resolved. The author mutation-checked each fix and cross-referenced Node v26.3.0 behaviour explicitly. Test coverage is thorough (each new run() branch and each frame-clear site has a dedicated test that fails without its fix), and the PR description records a release A/B showing no perf regression. A human reviewer (alii) has been tracking outstanding items and last commented 2026-07-09; the seven commits since then address that round plus the subsequent bot findings, so a fresh human pass over the settled diff is the right next step.

Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
},
streamError(self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) {
}),
streamError: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wrapping streamError also changes the session-teardown fan-out: session.destroy(err) (and GOAWAY-with-error) calls emitErrorToAllStreams, which now runs each open client stream's 'error'/'close' inside that stream's request()-time frame. Node runs them in the destroy() caller's context (or the session's context for a peer-driven teardown) — never the request()-time one — and Bun before this PR matched Node, so this specific case is a divergence introduced here.

Repro (connect in ctx CONNECT, request() in ctx REQUEST, then als.run("DESTROY", () => client.destroy(err))):

  • Node v25.6.1: stream 'error'/'close'DESTROY, session 'error'/'close'CONNECT
  • this branch: stream 'error'/'close'REQUEST, session 'error'/'close'CONNECT

Single-stream RST (aborted/streamError for one stream) matches Node, so it's only the session-destroy fan-out. It also contradicts the "user-initiated destroy() keeps the caller's context" principle stated in the description. Could emitErrorToAllStreams bypass the stream-frame swap (i.e. keep the ambient/caller frame on the teardown path)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 1f2e4a6e57. The destroy()/emitErrorToAllStreams fan-out now runs each open stream's 'error'/'close' in the destroy() caller's captured context instead of re-entering the stream's request()-time frame (session 'error'/'close' still use the connect-time frame; the native single-stream RST swap is unchanged), verified against v26.3.0. One aside from my repro: v26.3.0's peer-driven teardown isn't self-consistent (one stream's 'close' ran in the request context, the other's in the session context), so I keyed off the user-initiated case here.

Comment thread src/js/node/events.ts
return this.#asyncResource.asyncId();
}

get triggerAsyncId() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This regresses the returned value: #asyncResource is a plain AsyncResource, whose triggerAsyncId() is Bun's stub that always returns 0, so the triggerAsyncId option passed to the constructor is now dropped.

new EventEmitterAsyncResource({ name: "x", triggerAsyncId: 7 }).triggerAsyncId
// Node: 7 · bun main: 7 · this branch: 0

The pre-PR code echoed options.triggerAsyncId ?? 0. Since Bun's AsyncResource already receives triggerAsyncId in its options, having it store the value and return it from triggerAsyncId() would restore parity (and fix the same getter for plain AsyncResource too).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1f2e4a6e57AsyncResource now stores the resolved triggerAsyncId and returns it from triggerAsyncId(), so EventEmitterAsyncResource#triggerAsyncId echoes the option again (7 → 7) and the plain AsyncResource getter is fixed too; the default stays 0 (Bun's execution async id). Added a test pinning both.

…w covered by withStreamFrame

The merge with main brought in main's enterStreamAsyncContext/exitStreamAsyncContext
helpers alongside this branch's withStreamFrame wrapper, so streamEnd swapped the
async-context frame twice. withStreamFrame already wraps every client #Handlers
callback, so the inner swap and its supporting symbol/field/constructor are dead.

Also: the 'aborted' frame-clear test used a default GET request, which since the
merge ends the writable side at request() time, so destroy() no longer emits
'aborted' and the subprocess waited forever. Use POST so the writable side stays
open and the 'aborted' path is actually exercised.
Comment thread src/js/node/http2.ts
Comment thread src/js/node/events.ts
Comment thread test/js/node/async_hooks/AsyncLocalStorage.test.ts
…m:false, keep the destroy() caller's context on session teardown

AsyncResource stores the resolved triggerAsyncId and returns it from
triggerAsyncId() instead of a hardcoded 0, so EventEmitterAsyncResource's
triggerAsyncId getter echoes the constructor option like Node instead of
always reporting 0. The default stays 0 (Bun's execution async id).

ClientHttp2Session.destroy() fans stream teardown out through the native
emitErrorToAllStreams, whose dispatch re-enters JS under the session's
captured frame. Capture the destroy() caller's frame on the session and run
each open stream's error handler under it, so those streams' 'error'/'close'
observe the destroy() caller's async context (matching Node) while the
session's own 'error'/'close' keep the connect-time context. The frame lives
on the session, so a coincident dispatch on another session is unaffected.

request() only defaults endStream to true for GET/HEAD/DELETE when the caller
did not specify it: an explicit endStream:false now keeps the writable side
open like Node (destroy() emits 'aborted' and a body can follow), and the
no-payload content-length rejection keys on the resolved endStream rather than
the method alone.

The aborted-listener async-context test now keeps the writable side open with
endStream:false: a plain GET ends it up front and never emits 'aborted' (in
Node either), which the merged transport rewrite correctly exposed.
…uit tests leave behind

Match the sibling NaN test's cleanup: enterWith() is not scoped, so splice
the entries back out in a finally so later tests start from an empty
async-context frame.

No-Verification-Needed: test-only cleanup, no runtime surface
@cirospaciari
cirospaciari merged commit a8b4f62 into main Jul 17, 2026
75 of 76 checks passed
@cirospaciari
cirospaciari deleted the claude/node-v26-async-tests branch July 17, 2026 03:52
robobun added a commit that referenced this pull request Jul 17, 2026
…annel-node26

Take main's RunScope/withScope from #31825; drop this PR's setStoreInCurrentContext
workaround since main's enterWith-based implementation passes the vendored tests.
robobun added a commit that referenced this pull request Jul 17, 2026
The branch that runs when m_nextTickQueue is set cleared the microtask-tick
hook without draining the queue, so als.enterWith(x); process.nextTick(cb) on
an otherwise-idle tick never ran cb. With #31825 on main, RunScope uses
enterWith and diagnostics_channel's RunStoresScope reports transformer errors
via process.nextTick, so test-diagnostics-channel-bind-store.js needs this
drain to fire uncaughtException. Drops the setStoreInCurrentContext workaround
this PR previously carried in async_hooks.ts (now identical to main), and the
Known divergence in the PR description no longer applies.
robobun added a commit that referenced this pull request Jul 17, 2026
…type emit

#31825's EventEmitterAsyncResource picked between emitWithRejectionCapture
and super.emit based on this[kCapture], and deleted the own-property emit
the base constructor stamped. This PR removed both of those: there is one
prototype emit now, it gates rejection capture on kCapture internally via
addCatch's early return, and the constructor never stamps an own emit.

Always route through super.emit (which is what node's lib/events.js does),
and drop the now-dead own-property delete. Verified against node v26.3.0:
listener and rejection handler both observe the resource's creation-time
store with captureRejections on.
cirospaciari added a commit that referenced this pull request Jul 17, 2026
Only src/js/node/worker_threads.ts conflicted, in three hunks where #34338
("don't hang when captured stdout/stderr is never consumed") and this branch
touch the same lines. #34338 removed the #stdoutAutoPipe/#stderrAutoPipe fields
and moved the stdio port ref/unref out of ref()/unref() — ports now manage their
own ref via makePortReadable's incrementsPortRef. This branch only added #hasRef
bookkeeping there, so main's structure is taken wholesale and only the two
`if (!this.#exited) this.#hasRef = ...` lines and the field are kept.

async_hooks.ts (#31825) and VirtualMachine.rs (#34293, #32498) auto-merged.

`git diff origin/main -- src/js/node/worker_threads.ts` is a pure addition:
zero deleted lines, so nothing from #34338 or #31825 is reverted.

Verified on the merge result: test-worker-hasref, test-worker-error-stack-
getter-throws, test-perf-hooks-worker-timeorigin, test-diagnostics-channel-
worker-threads and the new "online fires before the entry point finishes" all
pass; #34338's own repro still exits 0 like node; BroadcastChannel ref()/unref()
and the 'online' timing fix both still match node v26.3.0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants