fix(hir,codegen): route class-extends userland stream-shim ctor through its real constructor (winston)#5357
Conversation
…gh its real constructor
`class Logger extends Transform` where `Transform` is bound from a userland
stream-shim package (`const { Transform } = require('readable-stream')`, as in
winston's logger.js) was routed to the native node:stream subclass-init shim
purely on the textual parent name. The shim installs the native stream surface
but never sets `this._readableState`/`_writableState`/`_transformState`, so
winston's `const { pipes } = this._readableState` threw 'Cannot convert
undefined or null to object'.
Root: the native-parent match in lower_class_decl / lower_class_from_ast
(perry-hir/src/lower_decl/class_decl.rs) treated any parent literally named
Readable/Writable/Duplex/Transform as the node:stream builtin, regardless of
where the binding came from. Genuine node:stream imports register the name as
the 'stream' native module (register_native_module(binding,"stream",..)); a
readable-stream binding does not (it is not a node builtin).
Fix:
- New is_genuine_node_stream_parent() gate: the four classic stream names map
to the native node_stream parent only when lookup_native_module(name)
resolves to the 'stream' module. Otherwise the class falls through to the
dynamic extends_expr parent path, which runs the package's real constructor
chain (Transform -> Duplex -> Readable, wired via util.inherits) on the
subclass instance, so the _readableState/_writableState/_transformState
objects are set.
- Codegen (this_super_call.rs): the stream-family names are only 'builtin'
parents for super() routing when HIR captured no extends_expr; when one was
captured (the userland case) defer to the js_fetch_or_value_super dynamic
dispatch.
Verified the genuine node:stream subclass path is unchanged
(test_read_undefined_proto_getter_promise_ctor.ts) and the new fixture
test_gap_class_extends_userland_stream_shim.ts matches node byte-for-byte.
Full corpus 57/59 (no regressions; ws and other stream users still green).
winston advances past the _readableState-undefined wall to a deeper
cross-module function-identity / util.inherits-resolution wall (the destructured
Transform re-export binds to the wrong function value and the
require('inherits')(...) chain is not fully linked), tracked separately.
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughAdds a ChangesUserland stream-shim parent discrimination
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Problem
Native-compiling winston (corpus
compilePackages:[\"*\"]) threwTypeError: Cannot convert undefined or null to objectatwinston/lib/winston/logger.js:695—const { pipes } = this._readableStatewhere
this._readableStatewasundefined.winston's
Loggerisclass Logger extends Transform, andTransformcomesfrom the
readable-streamnpm package(
const { Stream, Transform } = require('readable-stream')), NOTnode:stream.readable-stream's hierarchy is
Transform → Duplex → Readablewired with ES5require('inherits')(...); theReadableconstructor setsthis._readableState = new ReadableState(...).Root cause
The native-parent match in
lower_class_decl/lower_class_from_ast(
perry-hir/src/lower_decl/class_decl.rs) treated any parent literallynamed
Readable/Writable/Duplex/Transformas thenode:streambuiltin,purely on the textual name. So winston's
class Logger extends Transformwasrouted to the native
js_node_stream_transform_subclass_initshim, whichinstalls the native stream method surface but never sets
_readableState/
_writableState/_transformState. readable-stream's own code then readsthis._readableState.pipesand crashes.A genuine
node:streamimport registers the name as thestreamnativemodule (
register_native_module(binding, \"stream\", Some(name))); areadable-stream binding never does (it isn't a node builtin), so the two are
distinguishable.
Fix
is_genuine_node_stream_parent()gate — the four classicstream names map to the native
node_streamparent only whenlookup_native_module(name)resolves to thestreammodule. Otherwise theclass falls through to the dynamic
extends_exprparent path(
js_register_class_parent_dynamic+js_fetch_or_value_super), which runsthe package's real constructor chain on the subclass instance.
this_super_call.rs): the stream-family names are only treatedas built-in parents for
super()routing when HIR captured noextends_expr; when one was captured (the userland case) defer to thedynamic dispatch so the real
Transformbody runs.Validation
cargo fmt --checkclean;check_file_size.sh,gc_store_site_inventory.py,addr_class_inventory.pyall pass;perry-hirtests green.node:streamsubclass path unchanged(
test_read_undefined_proto_getter_promise_ctor.tsmatches node).test_gap_class_extends_userland_stream_shim.ts(ES5
inherits-builtTransform → Duplex → Readable, ES6class extends Transform) matches node byte-for-byte.wsand other stream-using packagesstill green; the other failure,
semver, is pre-existing and unrelated).winston before / after & next wall
Before:
TypeError: Cannot convert undefined or null to objectatlogger.js:695(this._readableStateundefined).After: that throw is gone. winston now reaches a deeper,
distinct cross-module wall:
TypeError: Cannot set properties of null or undefined (setting 'needReadable')at
readable-stream/lib/_stream_transform.js:97.Investigation of the new wall (probed at runtime): two interacting
cross-module issues remain, both outside the scope of this stream-routing fix:
indexdoesmodule.exports = require('./_stream_readable.js'); exports.Transform = require('./_stream_transform.js').winston's
const { Transform } = require('readable-stream')resolvesLogger's dynamic parent to the Readable function value, not Transform
(confirmed: Logger's
register_parent_dynamicparent bits == Readable's).util.inheritsreached indirectly (require('inherits')(...)/var inherits = require('util').inherits; inherits(...)) does not alwayslower to the native
js_util_inherits, so the synthetic-class parent chainTransform → Duplex → Readableisn't fully registered and theif (!(this instanceof Readable))guard re-news a throwaway object.Minimal multi-module repros of both were built and confirm the behavior. A
speculative runtime mint of the synthetic parent id was prototyped and
reverted (it did not clear winston and added unvalidated behavioral
surface).
Notes
Per CONTRIBUTING / CLAUDE.md, this PR does not bump
Cargo.tomlversion, theCurrent Versionline,CHANGELOG.md, orCargo.lock— the maintainer foldsthose in at merge.
Summary by CodeRabbit