Skip to content

node: fix prototype property descriptor on builtin constructors - #32163

Open
robobun wants to merge 1 commit into
mainfrom
farm/a975a843/fix-builtin-prototype-descriptor
Open

robobun wants to merge 1 commit into
mainfrom
farm/a975a843/fix-builtin-prototype-descriptor

Conversation

@robobun

@robobun robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32160

Problem

const { EventEmitter } = require('events');
Object.getOwnPropertyDescriptor(EventEmitter, 'prototype');
// Bun:  { value: ..., writable: true, enumerable: true, configurable: true }
// Node: { value: ..., writable: true, enumerable: false, configurable: false }

const target = function () {};
Object.defineProperties(target, Object.getOwnPropertyDescriptors(EventEmitter));
// Bun: TypeError: Attempting to change configurable attribute of unconfigurable property.
// Node: ok

Per spec (https://tc39.es/ecma262/#sec-function-instances-prototype) a function's own prototype property is { [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: false }. Because Bun reported it as configurable, the common "copy a function's shape onto a wrapper" idiom threw: the copied descriptor conflicts with the target function's correctly non-configurable prototype.

Cause

Functions declared in builtin JS don't get an automatic own prototype property, so Fn.prototype = {} at module setup creates one via [[Set]] with default attributes (all true). The same class of bug existed in $toClass (the internal helper that mimics class setup for function-style constructors), which put prototype with only DontEnum, leaving it configurable.

Fix

$toClass is now the one place that knows the correct descriptor, and every hand-assigned Fn.prototype = ... site becomes a $toClass call:

  • jsFunctionToClass (ZigGlobalObject.cpp) defines prototype with DontEnum | DontDelete, matching the spec for all 44 existing $toClass users (stream.Readable/Writable/Duplex, net.Socket, zlib classes, ...) plus the converted sites.
  • It accepts an optional 4th argument: a caller-built prototype object (the http message classes and Bun.$.Shell pass their existing prototype objects instead of getting a fresh one).
  • It keeps a pre-existing own prototype (node:tty installs a lazy accessor first; $toClass now leaves it in place and still wires up static inheritance and the name). tty's accessors materialize into the spec-correct descriptor on first use; previously the materialized descriptor was { writable: false, enumerable: true, configurable: true }, wrong on all three attributes.
  • It keeps a pre-existing own constructor on the passed prototype (Bun.$.Shell shares a class prototype whose constructor must stay). Where it was absent or hand-assigned, prototype.constructor is now the non-enumerable data property Node has: events.EventEmitter's was enumerable (visible to for...in), and url.Url, crypto.Certificate, console.Console, and the http classes either lacked it or defined it enumerable in their prototype literals.
  • Marking the prototype object uses didBecomePrototype() instead of structure()->setMayBePrototype(true): passed objects (and constructEmptyObject(globalObject) without a base) can share structures with the literal/empty-object structure caches, and flagging those in place trips ASSERTION FAILED: !newStructure->mayBePrototype() in JSON parsing on debug builds.

tty.ReadStream's lazily created prototype also gets its own constructor now (previously inherited fs.ReadStream's); tty.WriteStream keeps sharing fs.WriteStream.prototype by design, so its constructor is unchanged.

Converted sites: events.EventEmitter (also exported as events.init), url.Url, crypto.Certificate, console.Console, http.OutgoingMessage/IncomingMessage/ClientRequest, internal stream ReadableState/WritableState, Bun.$.Shell, and tty.ReadStream.

  • $toClass now also handles class declarations correctly. A class carries its own prototype (from ClassDefinitionEvaluation), so $toClass keeps it rather than overwriting, and when a base is supplied it wires that prototype's [[Prototype]] to the base so instances still inherit (perf_hooks PerformanceNodeTiming/PerformanceResourceTiming extend PerformanceEntry). This is checked via a PropertySlot so node:tty's lazy accessor prototype is still left untouched. Previously the overwrite silently dropped the class body's own getters; nodeTiming.name/entryType now return "node" and instanceof PerformanceEntry is true, matching Node v24. Regression test added in test/js/node/perf_hooks/perf_hooks.test.ts.

Out of scope: before first access, tty.ReadStream/WriteStream.prototype is still reported as an accessor (the deliberate lazy-initialization mechanism), and native classes like Buffer (whose prototype is non-writable, a different mechanism) are unchanged.

Verification

Tests in test/js/node/events/event-emitter.test.ts assert the exact prototype descriptor for every fixed constructor, the defineProperties copy idiom from the issue, and the prototype.constructor descriptor; 25 of 96 fail on the unfixed build, all pass with this change. Both descriptor shapes verified against Node v24 for every entry.

Also ran the events, url, crypto, console, http, stream, net, tty, and shell suites plus Node's ported test-event-emitter-* files with the debug build; no new failures (the http proxy test fails identically without this change: network sandboxing in the test environment).

@robobun

robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:49 AM PT - Jun 18th, 2026

❌ @robobun, your commit ee12296 has 4 failures in Build #63337 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32163

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

bun-32163 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Failure running http-server in Bun does not occur in Node.js #20333 - The http-server package fails with TypeError: Attempted to assign to readonly property when extending OutgoingMessage, likely caused by incorrect prototype property descriptors on HTTP builtin constructors

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

Fixes #20333

🤖 Generated with Claude Code

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #20333: not fixed by this PR, it no longer reproduces on current main at all. The union package's pattern (res._headers = res._headers || {}; res._headerNames = res._headerNames || {}) returns 200 on bun 1.4.0-canary without this change; the readonly-property error was from the 1.2.16-era _http_outgoing, which has since gained proper _headers/_headerNames setter accessors. That issue can likely be closed independently, so I am not adding a Fixes tag for it here.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

jsFunctionToClass in C++ is updated to accept an optional prototypeValue, preserve existing own .prototype properties, use didBecomePrototype, and define .prototype with DontEnum|DontDelete. The $toClass TypeScript declaration gains a matching prototype? parameter. Seven JS builtins (Console, Shell, ReadableState, WritableState, Certificate, Url, EventEmitter) replace direct prototype assignments with $toClass calls. TTY ReadStream/WriteStream lazy prototype getters gain explicit descriptor materialization. Tests validate the resulting descriptor shapes.

Changes

Constructor Prototype Wiring and Descriptor Updates

Layer / File(s) Summary
C++: jsFunctionToClass prototype handling
src/jsc/bindings/ZigGlobalObject.cpp
jsFunctionToClass now captures a 4th prototypeValue argument, skips prototype installation when the target already owns .prototype, uses didBecomePrototype(vm), conditionally defines constructor, and applies DontEnum|DontDelete to the installed .prototype.
$toClass typing update
src/js/builtins.d.ts
$toClass declaration gains an optional prototype?: object parameter with updated doc comment describing reuse and preservation semantics.
Core builtins and internal streams prototype wiring
src/js/builtins/ConsoleObject.ts, src/js/builtins/shell.ts, src/js/internal/streams/readable.ts, src/js/internal/streams/writable.ts, src/js/node/crypto.ts, src/js/node/url.ts
Replaces direct prototype = {} assignments with $toClass(...) calls for Console, Shell, ReadableState, WritableState, Certificate, and Url; subsequent Object.defineProperties calls operate on the resulting prototype unchanged.
EventEmitter prototype and metadata
src/js/node/events.ts
Replaces manual EventEmitter.name definition and EventEmitter.prototype = {} reassignment with $toClass(EventEmitter, "EventEmitter"); captures prototype into EventEmitterPrototype; removes explicit EventEmitterPrototype.constructor assignment.
TTY ReadStream/WriteStream prototype descriptors
src/js/node/tty.ts
Defines ReadStream.prototype getter before $toClass to preserve lazy accessor; makes Prototype.constructor descriptor explicit; redefines materialized ReadStream.prototype and WriteStream.prototype with writable:true, enumerable:false, configurable:false; flips outer getter enumerable to false.
Prototype descriptor tests
test/js/node/events/event-emitter.test.ts
Adds tests asserting EventEmitter.prototype descriptor is writable:true, enumerable:false, configurable:false; verifies Object.defineProperties copy does not throw; parameterized tests across Node/Bun constructors check prototype and name descriptor attributes; second parameterized suite validates prototype.constructor descriptor.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing prototype property descriptors on builtin constructors to match ECMAScript specifications.
Linked Issues check ✅ Passed All coding requirements from issue #32160 are met: prototype descriptors for EventEmitter and other builtins are fixed to { writable: true, enumerable: false, configurable: false }, the Object.defineProperties idiom now works, and all 44+ builtin constructors using $toClass have correct descriptors matching Node.js behavior.
Out of Scope Changes check ✅ Passed All changes directly address the linked issue: centralizing prototype descriptor logic in $toClass, converting hand-assigned prototype sites, fixing prototype.constructor descriptors, and replacing structure flagging with didBecomePrototype(). No unrelated modifications are present.
Description check ✅ Passed PR description provides comprehensive problem statement, root cause analysis, fix details, verification approach, and specific file/site changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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.

I didn't find any issues — the descriptor changes are mechanical and the tty refactor checks out — but since this adds DontDelete in jsFunctionToClass (transitively affecting all 44 $toClass users) and touches a dozen core node-compat modules, it's worth a quick human pass.

Extended reasoning...

Overview

This PR fixes the property descriptor of the prototype own-property on Node.js-compat builtin constructors so it matches the spec ({ writable: true, enumerable: false, configurable: false }) and Node.js. It does so via three kinds of edits across 13 files:

  1. Mechanical rewrites in 10 JS builtin modules: Fn.prototype = X → Object.defineProperty(Fn, "prototype", { value: X, writable: true, enumerable: false, configurable: false }). Same value, only the attributes change.
  2. A one-line C++ change in jsFunctionToClass (ZigGlobalObject.cpp) adding PropertyAttribute::DontDelete, which transitively affects all ~44 $toClass call sites.
  3. A small refactor in node:tty: since $toClass now installs a non-configurable prototype, the lazy accessor that follows would fail. The PR inlines $toClass's other effects ($setPrototypeDirect + name) and lets the lazy accessor own prototype. I traced this through: the prototype object $toClass previously created was immediately discarded by the accessor anyway (and its constructor assignment never reached the final lazily-created prototype), so this is behaviour-preserving aside from the descriptor fix.

Security risks

None. This only adjusts property attributes on already-existing prototype properties; no new data flow, parsing, auth, or I/O.

Level of scrutiny

Medium. Each individual edit is trivially equivalent (assignment → defineProperty with the spec attributes), and the new tests cover 15 representative constructors plus the originally-reported defineProperties copy idiom. However:

  • The DontDelete flag in jsFunctionToClass is a one-way door that affects every $toClass caller. The author states tty was the only one that redefined prototype afterward; the test suite run (events/url/crypto/console/http/stream/net/tty/zlib/shell) supports that, but it's the kind of cross-cutting C++ binding change a maintainer should sign off on.
  • The files touched (events, streams, http, crypto, console, ZigGlobalObject.cpp) are very high-traffic core modules.

Other factors

  • The bug-hunting pass found nothing.
  • 17 new targeted tests, plus the author reports clean runs across the affected module suites and Node's ported test-event-emitter-* files.
  • The fix is spec-cited and matches Node v24's observed behaviour.

I'm deferring purely on breadth (core C++ binding + a dozen hot modules), not on any specific concern with the implementation.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Modify $toClass to support this?

@robobun
robobun requested a review from alii as a code owner June 12, 2026 10:36
@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 36687ba. $toClass now owns the descriptor logic and every hand-assigned Fn.prototype = ... site is a one-line $toClass call:

  • optional 4th arg passes a caller-built prototype object (http message classes, Bun.$.Shell)
  • a pre-existing own prototype is kept, so node:tty's lazy accessor keeps working (tty now calls $toClass again)
  • a pre-existing own constructor on the passed prototype is kept (Shell shares its class prototype); where absent, $toClass defines it non-enumerable like Node, which also fixes for...in leaking constructor on EventEmitter instances

One catch found while converting: marking the passed objects with structure()->setMayBePrototype(true) poisoned shared structures (object literals, the empty-object structure cache) and tripped ASSERTION FAILED: !newStructure->mayBePrototype() in LiteralParser on debug builds, so it now uses didBecomePrototype(), which transitions instead.

@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.

Caution

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

⚠️ Outside diff range comments (1)
src/js/node/tty.ts (1)

24-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve the tty-specific prototype.constructor, not the fs one.

These lazy accessors fix the prototype descriptor, but they still leave the final constructor/prototype contract wrong. ReadStream installs its own prototype accessor before $toClass(...), and jsFunctionToClass explicitly skips prototype creation when an own prototype already exists, so ReadStream.prototype.constructor stays inherited from fs.ReadStream.prototype. WriteStream has the same problem because its accessor aliases fs.WriteStream.prototype directly, so WriteStream.prototype.constructor remains fs.WriteStream.

💡 Possible fix
 Object.defineProperty(ReadStream, "prototype", {
   get() {
     const Prototype = Object.create(fs.ReadStream.prototype);
+    Object.defineProperty(Prototype, "constructor", {
+      value: ReadStream,
+      writable: true,
+      enumerable: false,
+      configurable: true,
+    });

     // Add ref/unref methods to make tty.ReadStream behave like Node.js
     // where TTY streams have socket-like behavior
     Prototype.ref = function () {
@@
 Object.defineProperty(WriteStream, "prototype", {
   get() {
-    const Real = fs.WriteStream.prototype;
+    const Prototype = Object.create(fs.WriteStream.prototype);
+    Object.defineProperty(Prototype, "constructor", {
+      value: WriteStream,
+      writable: true,
+      enumerable: false,
+      configurable: true,
+    });
     // Once materialized, match the descriptor of a regular function's "prototype".
     Object.defineProperty(WriteStream, "prototype", {
-      value: Real,
+      value: Prototype,
       writable: true,
       enumerable: false,
       configurable: false,
     });

-    WriteStream.prototype._refreshSize = function () {
+    Prototype._refreshSize = function () {
       const oldCols = this.columns;
       const oldRows = this.rows;
       const windowSizeArray = [0, 0];
@@
-    return Real;
+    return Prototype;
   },

Based on the $toClass contract in src/jsc/bindings/ZigGlobalObject.cpp, an existing own prototype property prevents the helper from auto-defining prototype.constructor.

Also applies to: 131-207

🤖 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 `@src/js/node/tty.ts` around lines 24 - 110, The lazy prototype accessor for
ReadStream (and similarly for WriteStream) copies fs.*Stream.prototype but
leaves Prototype.constructor pointing at fs.ReadStream/WriteStream; before
calling Object.defineProperty(ReadStream, "prototype", { value: Prototype, ...
}) set the Prototype.constructor to the stream constructor (e.g. ReadStream)
with the correct descriptor (non-enumerable, writable/configurable as
appropriate) so that $toClass/jsFunctionToClass does not leave the
prototype.constructor inherited from fs.ReadStream; apply the same change in the
WriteStream accessor.
🤖 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.

Outside diff comments:
In `@src/js/node/tty.ts`:
- Around line 24-110: The lazy prototype accessor for ReadStream (and similarly
for WriteStream) copies fs.*Stream.prototype but leaves Prototype.constructor
pointing at fs.ReadStream/WriteStream; before calling
Object.defineProperty(ReadStream, "prototype", { value: Prototype, ... }) set
the Prototype.constructor to the stream constructor (e.g. ReadStream) with the
correct descriptor (non-enumerable, writable/configurable as appropriate) so
that $toClass/jsFunctionToClass does not leave the prototype.constructor
inherited from fs.ReadStream; apply the same change in the WriteStream accessor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8f535f0-8277-4e3e-bc6f-10ef437e66cd

📥 Commits

Reviewing files that changed from the base of the PR and between 4a47376 and 36687ba.

📒 Files selected for processing (14)
  • src/js/builtins.d.ts
  • src/js/builtins/ConsoleObject.ts
  • src/js/builtins/shell.ts
  • src/js/internal/streams/readable.ts
  • src/js/internal/streams/writable.ts
  • src/js/node/_http_client.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_outgoing.ts
  • src/js/node/crypto.ts
  • src/js/node/events.ts
  • src/js/node/tty.ts
  • src/js/node/url.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/events/event-emitter.test.ts

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed CodeRabbit's tty finding in a2c8ec9, for the half that's correct:

  • tty.ReadStream: its lazily created prototype now gets an own constructor (non-enumerable, like Node), and the test matrix covers it.
  • tty.WriteStream: left as is. Its prototype deliberately aliases fs.WriteStream.prototype (the getter installs cursorTo/clearLine/etc. on that shared object), so pointing the shared object's constructor at tty.WriteStream would misattribute every fs.WriteStream instance, and cloning the prototype would change object identity and restructure that sharing, which is out of scope for a descriptor fix.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp
@robobun
robobun force-pushed the farm/a975a843/fix-builtin-prototype-descriptor branch from f20ff48 to bfc3b34 Compare June 18, 2026 14:44
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (bfc3b34) to clear the merge conflicts.

The conflicts were all in _http_client.ts, _http_incoming.ts, and _http_outgoing.ts. While this PR was open, main's http client rewrite (#31587) independently migrated those three constructors to $toClass(X, "X", Base), which is exactly what this PR was doing to them. So I dropped my now-redundant edits to those files and kept main's versions: the descriptor fix still reaches them through the $toClass change in ZigGlobalObject.cpp. Net effect: the diff shrank from 14 files to 11 (the three http files are no longer touched here), and the test matrix still covers http.OutgoingMessage/IncomingMessage/ClientRequest, now validating main's migrated versions.

All other files merged cleanly. The descriptor matrix test (97 pass), the stream, tty, url, console, crypto, and shell suites are green locally. (The one node-http failure is Cannot find package 'tunnel', a missing dev dependency in this environment; it fails the same way on the released bun.)

@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 issues found after the rebase — the change looks correct and is well-tested, but since it reworks jsFunctionToClass (shared by 44+ callers) and swaps setMayBePrototype for didBecomePrototype(), it's worth a human pass on the JSC bits.

Extended reasoning...

Overview

This PR fixes #32160 by making builtin constructors' prototype property report the spec-correct descriptor {writable: true, enumerable: false, configurable: false} instead of all-true. It centralizes the fix in jsFunctionToClass (ZigGlobalObject.cpp), which now: defines prototype with DontEnum | DontDelete, accepts an optional caller-built prototype object (4th arg), preserves a pre-existing own prototype (for tty's lazy accessor), preserves a pre-existing own constructor on a passed prototype, and uses didBecomePrototype() instead of structure()->setMayBePrototype(true) to avoid poisoning shared structures. Ten JS call sites (events, url, crypto, console, shell, tty, internal stream state classes) are converted from Fn.prototype = {} to $toClass(...), plus a builtins.d.ts signature update and a 76-line test matrix covering 15 constructors.

Security risks

None. This is descriptor-attribute correctness for Node.js compat; no auth, crypto primitives, network, or filesystem semantics are touched. The crypto.ts edit only swaps Certificate.prototype = {} for $toClass(Certificate, "Certificate").

Level of scrutiny

Medium-high. The JS-side conversions are mechanical and low-risk on their own, but the C++ change is a shared helper used by 44+ existing $toClass callers (all stream/net/zlib/http classes etc.), so any subtle mistake fans out widely. Two aspects in particular warrant human eyes from someone with JSC internals knowledge:

  • The switch from structure()->setMayBePrototype(true) to prototype->didBecomePrototype(vm). The PR's rationale (object literals / empty-object structure cache sharing tripping a LiteralParser debug assert) is sound and didBecomePrototype() is the canonical API for this, but it changes structure-transition behavior for every $toClass user.
  • Adding DontDelete is a tightening — spec-correct and Node-matching, but worth confirming nothing in the ecosystem was relying on reconfiguring these prototypes.

Other factors

  • My earlier inline concern about EventEmitter.name becoming writable was empirically refuted by the author (JSC's lazy name reification overrides the putDirect attributes) and locked in with a test assertion across all 15 constructors; that thread is resolved.
  • The post-rebase diff shrank from 14 → 11 files because main independently migrated the three http constructors to $toClass; the test matrix still covers them.
  • The bug-hunting pass found nothing; CodeRabbit found nothing actionable.
  • Test coverage is thorough (descriptor shape, the failing defineProperties idiom from the issue, prototype.constructor shape, and name shape, all cross-checked against Node v24).

Given the shared-helper blast radius and the JSC structure-handling change, I'm deferring rather than approving.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Functions declared in builtin JS have no own prototype property, so
plain assignments (Fn.prototype = {}) created one via [[Set]] with
default attributes (enumerable: true, configurable: true). Per spec a
function's prototype must be non-enumerable and non-configurable.

Route every such site through $toClass, which now owns the correct
descriptor: it accepts an optional prototype object, keeps a
pre-existing own prototype (node:tty's lazy accessor) or constructor
(Bun.$.Shell's shared class prototype), and defines prototype with
DontEnum | DontDelete. Marking passed objects as prototypes uses
didBecomePrototype so shared structures are not poisoned.

A class declaration also has its own prototype (holding the class
body), so $toClass keeps it instead of overwriting; when a base is
supplied it wires that prototype's [[Prototype]] to the base so
instances still inherit (e.g. perf_hooks PerformanceNodeTiming /
PerformanceResourceTiming extending PerformanceEntry).
@robobun
robobun force-pushed the farm/a975a843/fix-builtin-prototype-descriptor branch from 5b97edc to ee12296 Compare June 18, 2026 16:12
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on ee12296: the only red lane is debian 13 x64-asan - test-bun, failing on test/regression/issue/30205.test.ts ("--isolate: deferred napi finalizers from the previous global don't write to its dead cell"). This is unrelated to this PR.

The subprocess reaches 8 pass, 0 fail and then LeakSanitizer aborts at teardown (exit 134):

==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 31904 byte(s) in 997 object(s) allocated from:
    ... NapiFinalizerTask::init  src/runtime/napi/napi_body.rs:4304
    ... napi_internal_enqueue_finalizer  src/runtime/napi/napi_body.rs:2396
    ... Zig::NapiRef::callFinalizer()  napi.h:742
    ... JSC::WeakBlock::sweep / WeakSet::sweep (exit-GC)
    ... Zig__GlobalObject__destructOnExit  ZigGlobalObject.cpp:4038

It's a napi finalizer teardown-ordering leak: finalizers enqueued during the final exit-GC sweep never drain (the event loop has stopped). The test itself forces collectContinuously + Bun.gc(true) to race finalizer draining and notes "a teardown-time abort happens AFTER the summary line."

This PR's diff touches no napi code (git diff --stat = builtins $toClass + prototype descriptors only), and the failure reproduced across all 7 in-run retries, so it is not a transient this PR can flip. The prototype/descriptor suites are green locally: event-emitter matrix (97), perf_hooks, tty, stream, url, console, crypto, zlib, net. Ready for review.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for ee12296 (build 63337): three jobs are red, none caused by this diff.

  • debian 13 x64-asan - test-bun: test/regression/issue/30205.test.ts, the napi finalizer LeakSanitizer report at exit-GC described in my previous comment. Pre-existing on main.
  • darwin 14 aarch64 - test-bun: test/js/node/test/parallel/test-tls-client-destroy-soon.js asserts bytesRead === big.length and got 2097152 !== 2080768, so the stream was truncated by an early destroy after a large write. That is the bug tls: don't truncate the stream when destroy() follows a large write #32719 fixed on main on Jun 26. This branch's last push (Jun 18) predates it, so the lane is reproducing a main bug that is already fixed; a rebase clears it.
  • darwin 26 aarch64 - test-bun: test/integration/next-pages/test/dev-server.test.ts failed during setup because puppeteer could not download chrome-headless-shell v139.0.7258.66. Test infra, unrelated.

The diff itself is unchanged and ready for re-review.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The bug is real and still on main. On 1.4.3-canary (367d939), Object.defineProperties(function () {}, Object.getOwnPropertyDescriptors(EventEmitter)) throws a TypeError. The same holds for url.Url, crypto.Certificate, console.Console, assert.Assert, stream.Readable, net.Socket and http.IncomingMessage. Node v26.3.0 reports enumerable: false, configurable: false for all of them. jsFunctionToClass still defines prototype with DontEnum only (src/jsc/bindings/ZigGlobalObject.cpp:2877). #39535 (7cbd1c8) already made the same descriptor change for Module.prototype.

The head commit has the shape that the review from 2026-06-12 asked for: $toClass owns the descriptor. A plain rebase is not enough, because main has moved:

The merge conflicts are in ZigGlobalObject.cpp and event-emitter.test.ts only. After the rework, the changes-requested review needs a second look.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Built-in EventEmitter has an incorrect prototype property descriptor (enumerable/configurable are true)

2 participants