Skip to content

fix(runtime): native ctor-class exports expose a real .prototype (#5268)#5269

Merged
proggeramlug merged 1 commit into
mainfrom
fix/undefined-proto-object-create
Jun 16, 2026
Merged

fix(runtime): native ctor-class exports expose a real .prototype (#5268)#5269
proggeramlug merged 1 commit into
mainfrom
fix/undefined-proto-object-create

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

#5268Object prototype may only be an Object or null: undefined (corpus top pattern: graceful-fs, fs-extra, pino)

Traced root cause

Native-module constructor class exports (fs.ReadStream, fs.WriteStream, events.EventEmitter, …) are truthy callable closures (bound-native exports) whose .prototype resolved to undefined.

ordinary_function_prototype_value_for_read (crates/perry-runtime/src/object/class_registry.rs) short-circuited to None for every bound-native export except a hardcoded http/https whitelist (added for #3527/#4973). For everything else, .prototype read back undefined.

The three packages all guard on truthiness then read .prototype:

  • graceful-fs graceful-fs.js:242: var fs$ReadStream = fs.ReadStream; if (fs$ReadStream) { ReadStream.prototype = Object.create(fs$ReadStream.prototype) }Object.create(undefined).
  • fs-extra: re-exports graceful-fs → same throw.
  • pino lib/proto.js:77: Object.setPrototypeOf(prototype, EventEmitter.prototype)Object.setPrototypeOf(x, undefined).

Each fed undefined into the proto-validation throw site.

How it was traced

Temporary eprintln! + backtrace at the three throw sites (object_ops.rs setPrototypeOf, descriptors.rs Object.create, proxy/prototype.rs Reflect). Running the real corpus drivers showed:

  • graceful-fs/fs-extra → js_object_create_with_props with proto = undefined (and props undefined).
  • pino → js_object_set_prototype_of with proto = undefined, receiver a real object.

Reduced to fs.ReadStream.prototype === undefined / EventEmitter.prototype === undefined, then localized to the bound-native whitelist gate. All tracing removed before commit.

Fix

Replace the http/https whitelist with a principled rule: a bound-native export is a constructor class when its method name uses Node's constructor-cased convention (leading uppercase ASCII letter, e.g. ReadStream/EventEmitter/Server) and it isn't flagged non-constructable (built-in prototype methods like String.prototype.charAt carry that flag). Such exports flow through the existing synthetic-class path (ensure_function_prototype_object), which materializes a stable .prototype object. Non-constructor bound methods (fs.readFile, path.join, …) keep prototype === undefined, matching Node's built-in non-constructor functions.

One file changed: crates/perry-runtime/src/object/class_registry.rs.

Tests

  • New crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs (compile+run) — passes.
  • New test-files/test_issue_5268_native_ctor_prototype_undefined.ts.
  • Updated stale comment in test-files/test_issue_pino_prototype_undefined.ts (it claimed EventEmitter.prototype stays undefined; now it's a real object — the test's lenient assertions still pass).
  • cargo test -p perry-runtime --lib -- --test-threads=1: 1044 passed, 0 failed. (Parallel runs surface a nondeterministic set of pre-existing shared-global-side-table flakes — url path_to_file_url, closure dynamic_props tests_1802, etc. — independent of this change; verified by stashing the fix and observing a different failing set on clean baseline parallel runs.)
  • issue_4908_subclass_native_member_base, issue_5257_require_adopt_no_default_namespace, existing test_gap_3527_http_ctor_prototype + test_issue_pino_prototype_undefined: pass.

3-package before/after (real corpus, PERRY_NO_AUTO_OPTIMIZE=1)

driver before after
graceful_fs_.ts throws Object prototype … : undefined prints graceful-fs object
fs_extra_.ts throws Object prototype … : undefined prints fs-extra object ✅ (benign fs.realpath.native warning)
pino_.ts throws Object prototype … : undefined throw gone; next wall: TypeError: Cannot read properties of undefined (reading 'get') (unrelated, not fixed here)

Risks

Low. The change widens which bound-native exports get a synthesized .prototype from a 6-name whitelist to "constructor-cased, constructable". This only affects .prototype reads on native exports (no behavior change for user closures, declared classes, or http/https which remain covered). Non-constructable exports are now explicitly excluded earlier, so built-in prototype methods are unaffected.

Note for maintainer

No Cargo.toml version / CLAUDE.md / CHANGELOG.md / Cargo.lock edits (per workflow — fold in at merge).

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed native-module constructor exports to properly expose .prototype objects at runtime, enabling valid inheritance patterns and prototype chain manipulation operations that previously failed with undefined values.
  • Tests

    • Added comprehensive regression test coverage to verify native-module constructors consistently expose valid prototype objects for proper subclassing and object prototype manipulation.

Native-module constructor *class* exports (`fs.ReadStream`,
`fs.WriteStream`, `events.EventEmitter`, …) were truthy callable closures
(bound-native exports) whose `.prototype` resolved to `undefined`:
`ordinary_function_prototype_value_for_read` short-circuited to `None`
for every bound-native export except a hardcoded http/https whitelist.

So graceful-fs's `ReadStream.prototype = Object.create(fs$ReadStream.prototype)`
(it guards on truthiness of `fs.ReadStream`, then reads `.prototype`),
fs-extra (re-exports graceful-fs), and pino's
`Object.setPrototypeOf(prototype, EventEmitter.prototype)` all passed
`undefined` into `Object.create` / `Object.setPrototypeOf` and threw
`TypeError: Object prototype may only be an Object or null: undefined`
at module init — the #5268 corpus top pattern across 3 packages.

Fix: replace the http/https whitelist with a principled rule — a
bound-native export is a constructor class when its method name uses
Node's constructor-cased convention (leading uppercase ASCII letter) AND
it isn't flagged non-constructable. Such exports then flow through the
existing synthetic-class path, which materializes a stable `.prototype`
object. Non-constructor bound methods (`fs.readFile`, `path.join`, …)
keep `prototype === undefined`, matching Node's built-in non-constructors.

Regression tests: crates/perry/tests/issue_5268_*.rs (compile+run) and
test-files/test_issue_5268_*.ts. graceful-fs / fs-extra now run; pino
clears this throw and hits an unrelated later wall.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 952dc437-f4b4-42c3-8e04-eab0b1a7effa

📥 Commits

Reviewing files that changed from the base of the PR and between 392af84 and 4389e27.

📒 Files selected for processing (4)
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs
  • test-files/test_issue_5268_native_ctor_prototype_undefined.ts
  • test-files/test_issue_pino_prototype_undefined.ts

📝 Walkthrough

Walkthrough

Replaces a hard-coded allowlist of specific Node constructor (module, method) pairs in ordinary_function_prototype_value_for_read with a heuristic that treats any bound native export whose method name begins with an uppercase ASCII letter as a constructor, returning a real .prototype object. Adds a Rust regression test and TypeScript test files for fs.ReadStream, fs.WriteStream, and EventEmitter.

Changes

Native constructor .prototype heuristic fix

Layer / File(s) Summary
Uppercase-initial heuristic in ordinary_function_prototype_value_for_read
crates/perry-runtime/src/object/class_registry.rs
Updated inline comment to describe the new heuristic; replaced the fixed (module, method) allowlist with logic that excludes non-constructable built-in closures and accepts bound native exports whose method name's first byte is an uppercase ASCII letter as constructors.
Regression test and TypeScript scripts
crates/perry/tests/issue_5268_native_ctor_prototype_undefined.rs, test-files/test_issue_5268_native_ctor_prototype_undefined.ts, test-files/test_issue_pino_prototype_undefined.ts
Adds a Rust end-to-end regression test compiling and running a TypeScript program via the perry CLI, a new TypeScript test script asserting real .prototype for fs.ReadStream/WriteStream/EventEmitter and undefined for fs.readFile, and updates a comment in the pino test to reflect the synthesized-prototype behavior.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • PerryTS/perry#5182: Modifies the same ordinary_function_prototype_value_for_read function in class_registry.rs to handle bound native-module constructor-class exports returning a real .prototype, establishing the original allowlist that this PR now replaces with the uppercase heuristic.

Poem

🐇 A rabbit once sorted constructors by name,
"Is it uppercase? Then .prototype you claim!"
No more lists of http and streams to maintain —
One letter decides if you're in the prototype game.
ReadStream, WriteStream, EventEmitter too —
All get real objects, not undefined — phew! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing native constructor class exports to expose a real .prototype object instead of undefined, addressing issue #5268.
Description check ✅ Passed The description provides comprehensive detail including traced root cause, affected packages, how the issue was traced, the fix mechanism, test coverage, and risk assessment, aligning well with the template requirements.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/undefined-proto-object-create

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

@proggeramlug proggeramlug merged commit d46feff into main Jun 16, 2026
13 of 15 checks passed
@proggeramlug proggeramlug deleted the fix/undefined-proto-object-create branch June 16, 2026 13:44
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