Skip to content

fix(runtime): instanceof default @@hasInstance and Readable.fromWeb on non-web operand (Next.js /list hang) - #13720

Merged
proggeramlug merged 2 commits into
mainfrom
fix/nextjs-list-gc-loop
Nov 2, 2026
Merged

proggeramlug merged 2 commits into
mainfrom
fix/nextjs-list-gc-loop

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Next.js 16 App Router standalone (secret-tests/nextjs-hello): on main, GET /list (a force-dynamic server component) never answers. The server spins at 100% CPU, with the profile dominated by GC work (oldgen evacuation snapshot, conservative pinning, ValidPointerSetBuilder). The regression is in eea3ac6..30e6cba: the same app on eea3ac6 served /list identically to node.

Causes

  1. instanceof misses the default @@hasInstance. Since fix(runtime): the shape is the one record of [[Prototype]] (null links, function links) #13666, js_instanceof_dynamic (object/instanceof/dynamic_dispatch.rs) recognized the default Function.prototype[@@hasInstance] by comparing the hook value against a fresh read of that hook. The read can allocate, and the comparison can miss. On a miss, the hook ran with this = undefined and returned false. So Next's e instanceof Readable ? e : Readable.fromWeb(e) sent a real node Readable into fromWeb. A user static [Symbol.hasInstance] also ran without this === C.
  2. Readable.fromWeb loops on a non-web operand. js_node_stream_from_web and js_node_stream_duplex_from_web (node_stream_constructors/web_adapter.rs) bounced between each other forever, minting a duplex per turn that died immediately. That is the GC-dominated profile, with RSS flat at ~308 MB. Node throws ERR_INVALID_ARG_TYPE.

Fix

  • The default @@hasInstance is recognized by its code, not its value.
  • Every other hook is called with the constructor as this.
  • Readable/Writable/Duplex.fromWeb throw node's ERR_INVALID_ARG_TYPE TypeError for a non-web-stream operand, instead of recursing.

Tests

test-files/test_gap_stream_from_web_non_web_operand.ts: fixed == node; main times out (rc=124).

Verification (qb9, base 1cb8838, main arm 6725177)

Check node fixed main
new test — == node timeout
11 instanceof checks (Readable subclass, Function/Array/Object/Error, user class) baseline == node == node
static [Symbol.hasInstance] with this === C true false == node false false
Readable.fromWeb(new PassThrough()) ERR_INVALID_ARG_TYPE TypeError == node hangs
Readable.fromWeb(web ReadableStream) data a, end == node hangs
nextjs-hello /list 200 body == node, responds hangs
nextjs-hello /, /nope — body == node body == node
load on / (5000 requests) — 0 errors —
perry-runtime instanceof unit tests — 21 passed / 0 failed —
node_stream unit tests — 135 passed / 0 failed —

The base predates #13695, so the fixture's API routes (500) and vary differ in that run. Both are fixed on main by #13695 and are independent of this change.

rustfmt: touched hunks are clean. A pre-existing diff at static_dispatch.rs:303 is also on the base.

Next.js /list hung for two chained reasons.

instanceof: for a linked right-hand side the default Function.prototype
[@@hasInstance] was recognized by comparing bits against a fresh, allocating
read, so a GC move made the compare fail and the default thunk ran with an
undefined receiver (always false). The default is now recognized by its code,
and other hooks are called with the constructor as this.

Readable/Writable.fromWeb on a non-web operand fell back to the generic
from_web, which routed a Node Readable into duplex_from_web and back,
looping forever. They now throw ERR_INVALID_ARG_TYPE like node, and
duplex_from_web throws when neither side installs.
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The hook read and call sat inline in js_instanceof_dynamic. Removing the
bits compare in the previous commit changed register allocation in that
large function, so every ordinary instanceof paid about 19 instructions
more (fastify startup +154 Ir). dispatch_own_has_instance now takes the
hook as a value or as the key to read it with, roots and refreshes both
operands across that read, and is cold and out of line. The ordinary
dispatch no longer carries the handle scope or the getter call, and the
runtime keeps one hook function as before.
@proggeramlug
proggeramlug marked this pull request as ready for review November 2, 2026 09:16
@proggeramlug
proggeramlug merged commit 7a48c59 into main Nov 2, 2026
119 of 136 checks passed
@proggeramlug
proggeramlug deleted the fix/nextjs-list-gc-loop branch November 2, 2026 09:17
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.

1 participant