Skip to content

fix(stream): node:stream classic subclassing (class X extends Writable/Readable/Duplex/Transform)#5095

Merged
proggeramlug merged 1 commit into
mainfrom
fix/node-stream-classic-subclass
Jun 13, 2026
Merged

fix(stream): node:stream classic subclassing (class X extends Writable/Readable/Duplex/Transform)#5095
proggeramlug merged 1 commit into
mainfrom
fix/node-stream-classic-subclass

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Originally chartered to close the node-suite console/ formatting gap (11 diffs / 119). Investigation found the console formatter itself has zero bugs — of the 11 diffs:

  • 9 are time/ tests — byte-identical format, only the measured X.XXXms duration varies → timing-variant (verified: diff is empty after normalizing the ms value).
  • 1 is output/error-cause.ts — the only differences are V8 stack-trace frames (real file paths + node-internal frames; Perry renders at <anonymous>). The surrounding inspect structure (Error: outer { code: 'E_OUTER', [cause]: TypeError: inner }) matches byte-for-byte → environment-variant.
  • 1 is console-class/stream-errors.ts — a genuine bug, but not a formatter bug: class Boom extends Writable threw TypeError: Class extends value is not a constructor, so the test couldn't even run.

This PR fixes that last one — the real, deterministic gap.

Root cause

User classes extending the classic node:stream base classes (Writable/Readable/Duplex/Transform) threw at new. The full machinery already existed:

  • codegen: lower_node_stream_super_init (this_super_call.rs)
  • runtime: js_node_stream_{writable,readable,duplex,transform}_subclass_init, which install the native stream methods directly onto this and pick up the subclass _write/_read override.

But the HIR class_decl lowering never tagged these bare names as native parents (only the Web Streams WritableStream/ReadableStream/TransformStream were). So they fell to the unknown-Ident extends_expr path, which emits js_register_class_parent_dynamic — and that rejects the callable-but-non-constructor node:stream export.

Fix

Pure wiring of the already-built path:

  1. crates/perry-hir/src/lower_decl/class_decl.rs — add Readable/Writable/Duplex/Transform to both native_parent match arms (module tag node_stream, which is inert in the sole lookup_class_native_extends consumer — it gates on the Web Streams tags).
  2. crates/perry-codegen/src/expr/this_super_call.rs — complete both node_stream_kind super-call arms (one was missing Readable, the other Writable) so all four bases reach lower_node_stream_super_init regardless of which parent-resolution branch fires.

No new dispatch entry / FFI ⇒ no API_MANIFEST row.

Before / after — console/console-class/stream-errors.ts

# before (Perry)
TypeError: Class extends value is not a constructor
    at <anonymous>

# after (Perry == node v26, byte-for-byte)
ignore start
stdout error: boom
stderr error: boom
ignore end
invalid stream: TypeError ERR_CONSOLE_WRITABLE_STREAM

Validation

  • console suite: stream-errors now passes. Remaining 10 diffs are all documented environment-variant (9 timing-value, 1 stack-trace) — none are formatter bugs.
  • net improvement to stream suite: the 6 dedicated stream/extends-* + passthrough-* + transform-constructor subclass tests (which previously threw) now pass.
  • no regression: all 12 remaining node-suite/stream diffs are pre-existing (none subclass a stream base; my change is codegen-isolated to subclassing paths). The 9 console/time + error-cause diffs are unchanged.
  • cargo test -p perry-hir -p perry-codegen green. perry-runtime has 2 pre-existing url::node_compat::path_to_file_url_posix_* failures (CWD-sensitive, in the untouched url crate).

Refs #1545.

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced support for Node.js stream base classes (Readable, Writable, Duplex, Transform) when used in class inheritance, ensuring proper initialization and routing through stream-specific handlers.

…` (node:stream classic subclassing)

User classes extending the classic node:stream base classes threw
`TypeError: Class extends value is not a constructor` at `new`. The
complete codegen path (`lower_node_stream_super_init`) and runtime
helpers (`js_node_stream_*_subclass_init`, which install the native
stream methods directly onto `this` and pick up the subclass `_write`/
`_read` override) were already built, but the HIR `class_decl` lowering
never recognised these bare names as native parents — so they fell to
the unknown-Ident `extends_expr` path, which emits a dynamic
parent-registration that rejects the (callable but non-constructor)
node:stream export.

Fix is the missing HIR wiring: add Readable/Writable/Duplex/Transform to
both `native_parent` match arms in `class_decl.rs` (module tag
`node_stream`, inert in the only `lookup_class_native_extends` consumer,
which gates on the Web Streams tags). Also complete both
`node_stream_kind` super-call arms in `this_super_call.rs` so all four
bases reach `lower_node_stream_super_init` regardless of which
parent-resolution branch fires.

Flips console/console-class/stream-errors.ts (custom Writable subclass +
ignoreErrors + ERR_CONSOLE_WRITABLE_STREAM) to byte-for-byte parity, and
the 6 dedicated stream/extends-* subclass tests (previously throwing) now
pass. No new dispatch entry / FFI, so no API_MANIFEST row. Refs #1545.
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds support for Node.js stream base classes (Readable, Writable, Duplex, Transform) throughout Perry's compilation pipeline. Class declarations now recognize these as native parents, and stream-based super(...) calls are lowered consistently through the stream initialization path.

Changes

Node.js Stream Base Class Support

Layer / File(s) Summary
Class declaration native parent mapping
crates/perry-hir/src/lower_decl/class_decl.rs
Added native-parent recognition for Node.js stream base classes (Readable, Writable, Duplex, Transform) in both lower_class_decl and lower_class_from_ast, mapping them to the node_stream module with corresponding class names.
Stream super call code generation
crates/perry-codegen/src/expr/this_super_call.rs
Added "Readable" kind mapping to the node_stream_kind match sites in stream-based super(...) lowering, ensuring super(...) with extends Readable is routed through the same stream super-init path as other stream families.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 A readable stream, a writable too,
Now super() calls know just what to do,
Transform and duplex, now unified,
Perry's stream support—amplified!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 describes the main fix: enabling classic node:stream subclassing for Writable/Readable/Duplex/Transform classes, which is the primary change in this PR.
Description check ✅ Passed The description covers all required template sections: summary (root cause and fix), concrete changes with file paths, related issue reference (#1545), test plan validation, and confirmation of following contribution guidelines.
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/node-stream-classic-subclass

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
crates/perry-hir/src/lower_decl/class_decl.rs (1)

190-233: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve class bindings before native-name fallback for Readable/Writable/Duplex/Transform.

At Line 190 and Line 1210, the new bare-name native mapping runs before ctx.lookup_class(&parent_name). That can mis-lower valid user code like class Readable {} then class X extends Readable {} by forcing node_stream instead of the lexical parent.

Suggested fix
-            // First check if it's a native module class
-            let native_parent = match parent_name.as_str() {
+            // Prefer lexical/class resolution first; fall back to native-name mapping.
+            let parent_cid = ctx.lookup_class(&parent_name);
+            let native_parent = if parent_cid.is_none() {
+                match parent_name.as_str() {
                 "EventEmitter" => Some(("events".to_string(), "EventEmitter".to_string())),
                 ...
                 "Readable" => Some(("node_stream".to_string(), "Readable".to_string())),
                 "Writable" => Some(("node_stream".to_string(), "Writable".to_string())),
                 "Duplex" => Some(("node_stream".to_string(), "Duplex".to_string())),
                 "Transform" => Some(("node_stream".to_string(), "Transform".to_string())),
                 _ => None,
-            };
-            if native_parent.is_some() {
+                }
+            } else {
+                None
+            };
+            if let Some(cid) = parent_cid {
+                (Some(cid), Some(parent_name), None, None)
+            } else if native_parent.is_some() {
                 (None, Some(parent_name), native_parent, None)
             } else {
-                let parent_cid = ctx.lookup_class(&parent_name);
-                if parent_cid.is_none() {
+                if true {
                     ...
                 } else {
                     ...
                 }
             }

Also applies to: 1210-1239

🤖 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 `@crates/perry-hir/src/lower_decl/class_decl.rs` around lines 190 - 233, The
native-parent bare-name mapping for "Readable"/"Writable"/"Duplex"/"Transform"
currently runs before checking for a user-defined class and thus can mis-lower
e.g. a lexical class named Readable; change the logic so you first call
ctx.lookup_class(&parent_name) and, if it returns Some (a resolved user class),
use that result and skip the native-name fallback, otherwise then apply the
existing native mapping that sets native_parent; apply this same fix in both
places where the mapping appears (the class-decl lowering branch that builds
native_parent and the parallel arm in lower_class_from_ast) so the lexical class
lookup wins and native fallback is only used when lookup returns None.
🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/this_super_call.rs (1)

291-297: ⚡ Quick win

Deduplicate node_stream_kind mapping to prevent future branch drift.

The two separate match parent_name.as_str() tables encode the same mapping; this exact duplication already diverged once. Centralizing it avoids repeating this regression class.

Suggested refactor
+fn node_stream_kind(parent_name: &str) -> Option<&'static str> {
+    match parent_name {
+        "Readable" => Some("readable"),
+        "Writable" => Some("writable"),
+        "Duplex" => Some("duplex"),
+        "Transform" => Some("transform"),
+        _ => None,
+    }
+}
...
-                    let node_stream_kind = match parent_name.as_str() {
-                        "Readable" => Some("readable"),
-                        "Writable" => Some("writable"),
-                        "Duplex" => Some("duplex"),
-                        "Transform" => Some("transform"),
-                        _ => None,
-                    };
-                    if let Some(kind) = node_stream_kind {
+                    if let Some(kind) = node_stream_kind(parent_name.as_str()) {
                         ...
                     }
...
-                    let node_stream_kind = match parent_name.as_str() {
-                        "Readable" => Some("readable"),
-                        "Writable" => Some("writable"),
-                        "Duplex" => Some("duplex"),
-                        "Transform" => Some("transform"),
-                        _ => None,
-                    };
-                    if let Some(kind) = node_stream_kind {
+                    if let Some(kind) = node_stream_kind(parent_name.as_str()) {
                         ...
                     }

Also applies to: 338-344

🤖 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 `@crates/perry-codegen/src/expr/this_super_call.rs` around lines 291 - 297, The
duplicated match on parent_name.as_str() that produces node_stream_kind should
be centralized: extract the mapping into a single helper (e.g., a function like
map_parent_to_stream_kind(parent_name: &str) -> Option<&'static str> or a static
lookup) and replace both match blocks with calls to that helper (use the same
helper where node_stream_kind is computed and in the other duplicated site),
ensuring the helper returns Some("readable"/"writable"/"duplex"/"transform") or
None as before so behavior is unchanged.
🤖 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 `@crates/perry-hir/src/lower_decl/class_decl.rs`:
- Around line 190-233: The native-parent bare-name mapping for
"Readable"/"Writable"/"Duplex"/"Transform" currently runs before checking for a
user-defined class and thus can mis-lower e.g. a lexical class named Readable;
change the logic so you first call ctx.lookup_class(&parent_name) and, if it
returns Some (a resolved user class), use that result and skip the native-name
fallback, otherwise then apply the existing native mapping that sets
native_parent; apply this same fix in both places where the mapping appears (the
class-decl lowering branch that builds native_parent and the parallel arm in
lower_class_from_ast) so the lexical class lookup wins and native fallback is
only used when lookup returns None.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/this_super_call.rs`:
- Around line 291-297: The duplicated match on parent_name.as_str() that
produces node_stream_kind should be centralized: extract the mapping into a
single helper (e.g., a function like map_parent_to_stream_kind(parent_name:
&str) -> Option<&'static str> or a static lookup) and replace both match blocks
with calls to that helper (use the same helper where node_stream_kind is
computed and in the other duplicated site), ensuring the helper returns
Some("readable"/"writable"/"duplex"/"transform") or None as before so behavior
is unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e973a80-3b6e-42ec-a1ac-dab2120fb00a

📥 Commits

Reviewing files that changed from the base of the PR and between 12ae086 and 956adf5.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs

@proggeramlug proggeramlug merged commit e3f9674 into main Jun 13, 2026
14 checks passed
@proggeramlug proggeramlug deleted the fix/node-stream-classic-subclass branch June 13, 2026 17:37
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