Skip to content

Commit 8beff4f

Browse files
proggeramlugRalph Küpper
andauthored
fix: native-compile winston (ES5 .call(this) super-chain → 59/59 corpus) (#5368)
* fix(codegen): emit per-module install on native-module value-read property path Reading a native-module callable export AS A VALUE (`const f = util.inherits`) and invoking it indirectly dispatches through the per-module NM_DISPATCH_REGISTRY, populated by `js_nm_install_<module>()`. The PropertyGet on a NativeModuleRef (`util.inherits` value) lowered to `js_native_module_property_by_name` without ever emitting the install. The *direct* call form (`util.inherits(a, b)`) is statically lowered straight to the runtime extern and never touches the registry, so a module reached ONLY through the value-read path left the registry empty and the indirect call resolved to `undefined`. This broke winston's `class Logger extends Transform` (readable-stream): `require('inherits')(Transform, Duplex)` is an indirect `util.inherits` value-call. With it skipped, the ES5 parent edges were never registered, so the nested `Readable.call(this)` guard `if (!(this instanceof Readable))` saw false, returned a discarded `new Readable()`, and `this._readableState.needReadable = true` threw on null. Fix: emit `js_nm_install_<module>()` before the `js_native_module_property_by_name` call in the NativeModuleRef PropertyGet branch (property_get.rs). Adds tests/test_native_module_value_read_install_codegen.sh (runtime + IR assertion). * fix(stream): expose EventEmitter methods on legacy Stream.prototype for .call borrows readable-stream's `Readable.prototype.on = function (ev, fn) { var res = Stream.prototype.on.call(this, ev, fn); … }` (with `Stream = require('stream')`) needs `Stream.prototype.on` to be EventEmitter.prototype.on. In Node `require('stream')` IS the legacy Stream constructor; Perry models it as a namespace object, so both `require('stream').prototype` and `require('stream').Stream.prototype` lacked the EventEmitter methods — `Stream.prototype.on` was undefined and the `.call` threw "Function.prototype.call was called on a value that is not a function". Two parts: - install_event_emitter_prototype_methods() installs on/once/emit/ removeListener/removeAllListeners/addListener/etc. on the legacy Stream.prototype AND the Readable/Writable/Duplex/Transform/PassThrough prototypes, as receiver-from-`this` values (slot-0 capture = TAG_UNDEFINED sentinel → this_value reads IMPLICIT_THIS, set by Function.prototype.call/ apply). Existing instance-bound closures always capture a real object so are unaffected. - get_native_module_constant resolves `require('stream').prototype` to the same legacy Stream.prototype the `.Stream` export carries. Adds tests/test_stream_prototype_eventemitter_borrow.sh. * fix(runtime): rebind this on inherited defineProperty(Class.prototype) getters A getter installed via Object.defineProperty(Class.prototype, name, { get }) is an ordinary method closure whose body reads this from its captured receiver slot, not IMPLICIT_THIS. The inherited prototype-accessor walk (inherited_proto_accessor_value) merely set IMPLICIT_THIS and called the closure with js_closure_call0, so the getter observed the PROTOTYPE it lives on instead of the instance — this.<ownField> came back undefined. winston's Object.defineProperty(Logger.prototype, 'transports', { get() { const { pipes } = this._readableState; … } }), read as this.transports inside the Logger constructor, then threw 'Cannot convert undefined or null to object' (this._readableState was undefined on the prototype). Fix: route inherited_proto_accessor_value through invoke_accessor_getter, which clones the getter closure with this rebound to the real receiver (and applies strict/sloppy coercion), matching the own-accessor read path. Adds tests/test_defineproperty_prototype_getter_this.sh. --------- Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
1 parent c9fad1e commit 8beff4f

9 files changed

Lines changed: 435 additions & 9 deletions

crates/perry-codegen/src/expr/property_get.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
611611
let prop_idx = ctx.strings.intern(property);
612612
let prop_bytes_global = format!("@{}", ctx.strings.entry(prop_idx).bytes_global);
613613
let prop_len_str = property.len().to_string();
614+
// The value read of a native-module callable export (`const f =
615+
// util.inherits`) mints a BOUND_METHOD closure that, when invoked
616+
// indirectly, dispatches through the per-module `NM_DISPATCH_REGISTRY`
617+
// populated by `js_nm_install_<module>()`. The *direct* call form
618+
// (`util.inherits(a, b)`) is statically lowered to the runtime extern
619+
// and never touches the registry, so a module reached ONLY via this
620+
// value-read path would leave the registry empty and the indirect call
621+
// would resolve to `undefined` (winston/readable-stream's
622+
// `require('inherits')` → `util.inherits` value → `inherits(Sub, Base)`
623+
// silently skipped, breaking the ES5 super-chain). Emit the install
624+
// here so the value-read path's later dispatch finds the module fn.
625+
if let Some(install_sym) = crate::nm_install::nm_install_symbol(module_name) {
626+
ctx.block().call_void(install_sym, &[]);
627+
}
614628
return Ok(ctx.block().call(
615629
DOUBLE,
616630
"js_native_module_property_by_name",

crates/perry-runtime/src/node_stream.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,18 @@ fn this_value(closure: *const ClosureHeader) -> f64 {
148148
// host object value cast to i64; reverse the cast.
149149
if !closure.is_null() {
150150
let bits = js_closure_get_capture_ptr(closure, 0) as u64;
151-
return f64::from_bits(bits);
151+
// A `TAG_UNDEFINED` slot-0 marks a *prototype-method value* (e.g.
152+
// `Stream.prototype.on`, installed by `attach_event_emitter_prototype_methods`)
153+
// rather than an instance-bound method: it has no fixed receiver and must
154+
// read the call-site `this` (set by `Function.prototype.call`/`apply` into
155+
// IMPLICIT_THIS), so `Stream.prototype.on.call(streamInstance, ev, fn)`
156+
// — readable-stream's `Readable.prototype.on` wrapper — registers the
157+
// listener on the instance, not on the prototype. `build_object` always
158+
// captures a real object pointer, so existing instance-bound closures
159+
// never hit this branch.
160+
if bits != crate::value::TAG_UNDEFINED {
161+
return f64::from_bits(bits);
162+
}
152163
}
153164
crate::object::js_implicit_this_get()
154165
}

crates/perry-runtime/src/node_stream_dispatch.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,42 @@ pub(super) fn install_methods_on_existing_object(
109109
}
110110
}
111111

112+
/// Install the EventEmitter prototype methods (`on`/`once`/`emit`/
113+
/// `removeListener`/`removeAllListeners`/…) on a *prototype* object as named
114+
/// own properties, each a closure that reads its receiver from the call-site
115+
/// `this` (IMPLICIT_THIS) rather than a captured instance.
116+
///
117+
/// readable-stream's `Readable.prototype.on` is `function (ev, fn) { var res =
118+
/// Stream.prototype.on.call(this, ev, fn); … }` — the legacy `Stream.prototype`
119+
/// must therefore expose `.on` (and siblings) as VALUES so the `.call(this)`
120+
/// borrow dispatches the EventEmitter `on` against the real stream instance.
121+
/// Before this, `Stream.prototype.on` was `undefined` and the `.call` threw
122+
/// "Function.prototype.call was called on a value that is not a function".
123+
///
124+
/// The closures capture `TAG_UNDEFINED` in slot 0; `this_value` treats that as
125+
/// "no fixed receiver — read IMPLICIT_THIS", which `Function.prototype.call`/
126+
/// `.apply` sets to the borrowed `this`.
127+
pub(crate) fn install_event_emitter_prototype_methods(proto: *mut ObjectHeader) {
128+
register_stub_arities();
129+
let methods = super::emitter_methods();
130+
let mut on_method: Option<f64> = None;
131+
for (name, func) in methods {
132+
if name == "addListener" {
133+
if let Some(val) = on_method {
134+
js_object_set_field_by_name(proto, hidden_key(name.as_bytes()), val);
135+
continue;
136+
}
137+
}
138+
let closure = js_closure_alloc(func as *const u8, 1);
139+
crate::closure::js_closure_set_capture_ptr(closure, 0, crate::value::TAG_UNDEFINED as i64);
140+
let val = f64::from_bits(JSValue::pointer(closure as *const u8).bits());
141+
if name == "on" {
142+
on_method = Some(val);
143+
}
144+
js_object_set_field_by_name(proto, hidden_key(name.as_bytes()), val);
145+
}
146+
}
147+
112148
pub(super) fn register_stub_arities() {
113149
let register = |func: *const u8, arity: u32| {
114150
crate::closure::js_register_closure_arity(func, arity);

crates/perry-runtime/src/object/class_registry.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -759,14 +759,20 @@ unsafe fn inherited_proto_accessor_value(
759759
if acc.get == 0 {
760760
return Some(JSValue::undefined());
761761
}
762-
let closure = (acc.get & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader;
763-
if closure.is_null() {
764-
return Some(JSValue::undefined());
765-
}
766-
let previous_this = js_implicit_this_set(receiver);
767-
let value = crate::closure::js_closure_call0(closure);
768-
js_implicit_this_set(previous_this);
769-
Some(JSValue::from_bits(value.to_bits()))
762+
// Route through `invoke_accessor_getter` rather than a bare
763+
// `js_implicit_this_set` + `js_closure_call0`. A getter installed via
764+
// `Object.defineProperty(Class.prototype, name, { get })` is an ORDINARY
765+
// method closure whose body reads `this` from its captured receiver slot —
766+
// not from IMPLICIT_THIS — so merely setting IMPLICIT_THIS left the getter
767+
// observing the prototype it lives on instead of the instance (winston's
768+
// `get transports()` saw the prototype, whose `this._readableState` is
769+
// undefined → "Cannot convert undefined or null to object").
770+
// `invoke_accessor_getter` clones the closure with `this` rebound to the
771+
// real receiver (and applies strict/sloppy coercion), matching the
772+
// own-accessor read path.
773+
Some(super::field_get_set::invoke_accessor_getter(
774+
acc.get, receiver,
775+
))
770776
}
771777

772778
unsafe fn resolve_proto_chain_field_inner(

crates/perry-runtime/src/object/native_module.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6445,6 +6445,28 @@ pub(crate) unsafe fn get_native_module_constant(
64456445
return cjs_default_export_value("process");
64466446
}
64476447

6448+
// Node's `require('stream')` IS the legacy `Stream` constructor (a function
6449+
// that also carries `.Readable`/`.Writable`/… statics), so its `.prototype`
6450+
// is the EventEmitter-derived `Stream.prototype`. Perry models the module as
6451+
// a namespace OBJECT, so `require('stream').prototype` was `undefined`.
6452+
// readable-stream's `Readable.prototype.on = function (ev, fn) { var res =
6453+
// Stream.prototype.on.call(this, ev, fn); … }` (where `Stream =
6454+
// require('stream')`) then threw "Function.prototype.call was called on a
6455+
// value that is not a function". Resolve `require('stream').prototype` to the
6456+
// same legacy `Stream.prototype` the `.Stream` export carries (minted +
6457+
// cached by `bound_native_callable_export_value("stream", "Stream")`), which
6458+
// now exposes the EventEmitter prototype methods.
6459+
if module_name == "stream" && property == "prototype" {
6460+
let stream_ctor = bound_native_callable_export_value("stream", "Stream");
6461+
let ctor_ptr = (stream_ctor.to_bits() & crate::value::POINTER_MASK) as usize;
6462+
if ctor_ptr != 0 {
6463+
let proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype");
6464+
if !JSValue::from_bits(proto.to_bits()).is_undefined() {
6465+
return Some(proto);
6466+
}
6467+
}
6468+
}
6469+
64486470
if property == "default" && !is_cjs_default_object && module_name != "process" {
64496471
if let Some(value) = cjs_default_export_value(module_name) {
64506472
return Some(value);

crates/perry-runtime/src/object/native_module_stream.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ pub(crate) fn attach_stream_legacy_prototype(constructor_value: f64) {
3636
b"constructor\0".len() as u32,
3737
);
3838
js_object_set_field(proto, 0, JSValue::from_bits(constructor_value.to_bits()));
39+
// readable-stream's `Readable.prototype.on` borrows `Stream.prototype.on`
40+
// via `.call(this)`; expose the EventEmitter methods on the legacy
41+
// `Stream.prototype` as receiver-from-`this` values so the borrow works.
42+
crate::node_stream::install_event_emitter_prototype_methods(proto);
3943
let proto_value = crate::value::js_nanbox_pointer(proto as i64);
4044
STREAM_EVENT_EMITTER_PROTOTYPES.with(|protos| {
4145
let mut protos = protos.borrow_mut();
@@ -79,6 +83,11 @@ pub(crate) fn attach_stream_constructor_prototype(constructor_value: f64, name:
7983
b"constructor\0".len() as u32,
8084
);
8185
js_object_set_field(proto, 0, JSValue::from_bits(constructor_value.to_bits()));
86+
// `Readable`/`Writable`/`Duplex`/`Transform`/`PassThrough` also chain their
87+
// own prototype methods onto `<Ctor>.prototype.<m>.call(this, …)` (e.g.
88+
// `Duplex.prototype.on` ↔ readable-stream borrows). Expose the EventEmitter
89+
// methods on these prototypes too.
90+
crate::node_stream::install_event_emitter_prototype_methods(proto);
8291
let proto_value = crate::value::js_nanbox_pointer(proto as i64);
8392
STREAM_EVENT_EMITTER_PROTOTYPES.with(|protos| {
8493
let mut protos = protos.borrow_mut();
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env bash
2+
# Regression: a getter installed via `Object.defineProperty(Class.prototype,
3+
# name, { get })` must run with `this` bound to the INSTANCE, not the prototype
4+
# object it lives on.
5+
#
6+
# Such a getter is an ordinary method closure whose body reads `this` from its
7+
# captured receiver slot (not IMPLICIT_THIS). The inherited-accessor walk used
8+
# to merely set IMPLICIT_THIS and call the closure, so the getter observed the
9+
# prototype — `this.<ownField>` came back undefined. winston's
10+
# `Object.defineProperty(Logger.prototype, 'transports', { get() { const {
11+
# pipes } = this._readableState; … } })` (read as `this.transports` inside the
12+
# Logger constructor) then threw "Cannot convert undefined or null to object".
13+
#
14+
# Fix: the inherited prototype-accessor path routes through
15+
# `invoke_accessor_getter`, which clones the getter closure with `this` rebound
16+
# to the real receiver (matching the own-accessor read path).
17+
set -euo pipefail
18+
19+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
21+
PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}"
22+
23+
if [[ ! -x "$PERRY" ]]; then
24+
PERRY="$REPO_ROOT/target/debug/perry"
25+
fi
26+
if [[ ! -x "$PERRY" ]]; then
27+
echo "SKIP: perry binary not found (build with cargo build -p perry)"
28+
exit 0
29+
fi
30+
31+
TMPDIR="$(mktemp -d)"
32+
trap 'rm -rf "$TMPDIR"' EXIT
33+
34+
SRC="$TMPDIR/defineproperty_proto_getter.ts"
35+
BIN="$TMPDIR/defineproperty_proto_getter"
36+
37+
cat >"$SRC" <<'TS'
38+
let failures = 0;
39+
40+
// Plain class: getter reads an own data field through `this`.
41+
class A {
42+
_x = 42;
43+
}
44+
Object.defineProperty(A.prototype, "px", {
45+
get() { return (this as any)._x; },
46+
});
47+
const a: any = new A();
48+
if (a.px !== 42) {
49+
console.log("FAIL: defineProperty prototype getter read prototype, not instance (px=" + a.px + ")");
50+
failures = failures + 1;
51+
}
52+
53+
// Getter that DESTRUCTURES a nested own object — the exact winston shape.
54+
class L {
55+
_readableState: any;
56+
constructor() { this._readableState = { pipes: null }; }
57+
}
58+
Object.defineProperty(L.prototype, "transports", {
59+
configurable: false,
60+
enumerable: true,
61+
get() {
62+
const { pipes } = (this as any)._readableState;
63+
return !Array.isArray(pipes) ? [pipes].filter(Boolean) : pipes;
64+
},
65+
});
66+
const l: any = new L();
67+
let threw = false;
68+
let result: any = "unset";
69+
try {
70+
result = l.transports;
71+
} catch (e) {
72+
threw = true;
73+
}
74+
if (threw) {
75+
console.log("FAIL: destructuring getter threw (this bound to prototype, _readableState undefined)");
76+
failures = failures + 1;
77+
} else if (JSON.stringify(result) !== "[]") {
78+
console.log("FAIL: destructuring getter wrong result: " + JSON.stringify(result));
79+
failures = failures + 1;
80+
}
81+
82+
if (failures !== 0) {
83+
throw new Error("defineProperty prototype getter this-binding regression failed");
84+
}
85+
console.log("defineProperty proto getter this ok");
86+
TS
87+
88+
"$PERRY" compile --no-cache --no-auto-optimize "$SRC" -o "$BIN" >"$TMPDIR/compile.log" 2>&1 || {
89+
echo "FAIL: compile failed"
90+
sed 's/^/ /' "$TMPDIR/compile.log" | tail -80
91+
exit 1
92+
}
93+
94+
"$BIN" >"$TMPDIR/run.log" 2>&1 || {
95+
echo "FAIL: program failed"
96+
sed 's/^/ /' "$TMPDIR/run.log" | tail -80
97+
exit 1
98+
}
99+
100+
if ! grep -q "defineProperty proto getter this ok" "$TMPDIR/run.log"; then
101+
echo "FAIL: expected success marker"
102+
sed 's/^/ /' "$TMPDIR/run.log" | tail -80
103+
exit 1
104+
fi
105+
106+
echo "PASS: defineProperty prototype getter this-binding"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
# Regression: reading a native-module callable export AS A VALUE
3+
# (`const f = util.inherits`) and invoking it indirectly must dispatch to the
4+
# real runtime impl. The indirect call resolves through the per-module
5+
# NM_DISPATCH_REGISTRY, which is populated by `js_nm_install_<module>()`. The
6+
# *direct* call form (`util.inherits(a, b)`) is statically lowered straight to
7+
# the runtime extern and never touches that registry, so a module reached ONLY
8+
# through the value-read path used to leave the registry empty — the indirect
9+
# call silently resolved to `undefined`.
10+
#
11+
# Concretely: winston's `class Logger extends Transform` (readable-stream)
12+
# relies on `require('inherits')(Transform, Duplex)` — an indirect
13+
# `util.inherits` value-call — to wire the ES5 super-chain so the nested
14+
# `Readable.call(this)` `if (!(this instanceof Readable))` guard takes the
15+
# in-place branch and sets `this._readableState`. With the install skipped,
16+
# the guard saw `false`, returned a discarded `new Readable()`, and
17+
# `this._readableState.needReadable = true` threw on `null`.
18+
#
19+
# This test pins both halves:
20+
# 1. RUNTIME: the value-read `inherits(Sub, Base)` registers the ES5 parent
21+
# edge so `new Sub() instanceof Base` is true and base ctor writes persist.
22+
# 2. CODEGEN: the `PropertyGet { NativeModuleRef("util"), "inherits" }`
23+
# value-read emits `call void @js_nm_install_util()`.
24+
set -euo pipefail
25+
26+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
27+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
28+
PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}"
29+
30+
if [[ ! -x "$PERRY" ]]; then
31+
PERRY="$REPO_ROOT/target/debug/perry"
32+
fi
33+
if [[ ! -x "$PERRY" ]]; then
34+
echo "SKIP: perry binary not found (build with cargo build -p perry)"
35+
exit 0
36+
fi
37+
38+
TMPDIR="$(mktemp -d)"
39+
trap 'rm -rf "$TMPDIR"' EXIT
40+
41+
SRC="$TMPDIR/nm_value_read_install.ts"
42+
BIN="$TMPDIR/nm_value_read_install"
43+
OBJ="$TMPDIR/nm_value_read_install.o"
44+
45+
cat >"$SRC" <<'TS'
46+
import * as util from "util";
47+
48+
// Read the native-module callable export AS A VALUE, then invoke indirectly.
49+
const inh: any = (util as any).inherits;
50+
51+
function Base(this: any) {
52+
if (!(this instanceof Base)) return new (Base as any)();
53+
this._state = { x: 1 };
54+
}
55+
function Sub(this: any) {
56+
(Base as any).call(this);
57+
this._state.y = 2; // mirrors readable-stream's post-super `this._x` writes
58+
}
59+
inh(Sub, Base);
60+
61+
const s: any = new (Sub as any)();
62+
63+
let failures = 0;
64+
if (!(s instanceof Base)) {
65+
console.log("FAIL: value-read util.inherits did not register the ES5 parent edge");
66+
failures = failures + 1;
67+
}
68+
if (!s._state || s._state.x !== 1 || s._state.y !== 2) {
69+
console.log("FAIL: base ctor writes did not persist through the super-chain");
70+
failures = failures + 1;
71+
}
72+
73+
if (failures !== 0) {
74+
throw new Error("native-module value-read inherits regression failed");
75+
}
76+
console.log("nm value-read inherits ok");
77+
TS
78+
79+
"$PERRY" compile --no-cache --no-auto-optimize "$SRC" -o "$BIN" >"$TMPDIR/compile.log" 2>&1 || {
80+
echo "FAIL: compile failed"
81+
sed 's/^/ /' "$TMPDIR/compile.log" | tail -80
82+
exit 1
83+
}
84+
85+
"$BIN" >"$TMPDIR/run.log" 2>&1 || {
86+
echo "FAIL: program failed"
87+
sed 's/^/ /' "$TMPDIR/run.log" | tail -80
88+
exit 1
89+
}
90+
91+
if ! grep -q "nm value-read inherits ok" "$TMPDIR/run.log"; then
92+
echo "FAIL: expected success marker"
93+
sed 's/^/ /' "$TMPDIR/run.log" | tail -80
94+
exit 1
95+
fi
96+
97+
(
98+
cd "$TMPDIR"
99+
"$PERRY" compile --no-cache --no-auto-optimize --trace llvm --no-link \
100+
"$SRC" -o "$OBJ" >"$TMPDIR/trace-compile.log" 2>&1
101+
) || {
102+
echo "FAIL: trace compile failed"
103+
sed 's/^/ /' "$TMPDIR/trace-compile.log" | tail -80
104+
exit 1
105+
}
106+
107+
TRACE_DIR="$TMPDIR/.perry-trace/llvm"
108+
if [[ ! -d "$TRACE_DIR" ]]; then
109+
echo "FAIL: LLVM trace directory not found"
110+
exit 1
111+
fi
112+
113+
if ! grep -R "call void @js_nm_install_util()" "$TRACE_DIR" >/dev/null; then
114+
echo "FAIL: expected js_nm_install_util() call on the native-module value-read path"
115+
exit 1
116+
fi
117+
118+
echo "PASS: native-module value-read install codegen"

0 commit comments

Comments
 (0)