Skip to content

Commit a976a99

Browse files
committed
fix(hir/codegen): cross-module class + array-method dispatch + Date instanceof
While porting Mango (a Perry app) to use the pure-TypeScript `@perryts/mongodb` driver, several long-latent dispatch holes surfaced once a real cross-module class graph was being compiled. Each is fixed at the level it manifests rather than worked around in user code. - compile.rs: honour a `"perry"` exports condition in `package.json` so packages can ship TS source (`./src/index.ts`) for Perry-native compilation alongside a pre-built JS dist for Node/Bun consumers. - compile.rs + codegen.rs: imported classes now carry `static_method_names`, populated from the source HIR's `class.static_methods`. Codegen registers each entry in `method_names` keyed by `(effective_name, sm)` so `MyClass.connect(...)` on an imported class resolves to the source-side `_perry_static_<src>__<MyClass>__<connect>` symbol instead of falling through to `0.0`. Matching no-op `__perry_extern_closure_<Class>` wrappers are emitted for class names so referencing the class as a value (instanceof, eq) doesn't fail at link time. - lower.rs (`StaticMethodCall` candidate detection): treat any uppercase imported identifier as a candidate class so `Foo.connect(...)` on a cross-module class lowers to `Expr::StaticMethodCall` and reaches the `method_names` table above. Without this, the same call lowered to a `Call(PropertyGet(...))` shape that read garbage off the static `ClosureHeader` global and crashed. - lower.rs (array-method fast path): add a class-instance / unknown receiver guard so `coll.find(filter)`, `xs.map(fn)`, etc. on a user-class instance no longer collapse to `Expr::ArrayFind` / `Expr::ArrayMap` and dispatch to `js_array_<method>` on a class handle — the runtime returned `0` and the smoke ended up calling `0.toArray()`. An overlapping-method list (find / map / filter / forEach / reduce / some / every / join …) is the trigger; receivers whose static type is `Type::Any` plus one of those names now skip the array fast-path and fall through to method-dispatch resolution. - js_transform.rs: cross-module pass propagates native-instance tagging through plain ident-rebinds (`let sock: Socket = plainSock`) via a fixed-point scan. Without this, an explicit type annotation re-erased the `(net, Socket)` tag set on the source variable and the next `sock.on('data', cb)` skipped the native dispatcher and segfaulted reading `[handle - 8]`. - date.rs + object.rs: `instanceof Date` previously returned `true` for every finite f64. Add a thread-local `DATE_REGISTRY` of f64 bit patterns produced by `js_date_new*` and consult it from the `js_instanceof` Date arm. Restores the typed BSON-encoder dispatch that was mis-classifying `batchSize: 100` as a Date and rejecting the wire find command. destructuring.rs: comment-only — points readers at the cross-module js_transform pass that subsumes the variable-to-variable native instance propagation that used to live here. Tested end to end against MongoDB 7.0 from a perry-native binary: connect, ping, listDatabases, insertOne, find().toArray(), findOne, updateOne, deleteOne all roundtrip with correct ObjectId bytes. Mango (92 modules) still compiles to a 9.4 MB native binary.
1 parent bc4462e commit a976a99

7 files changed

Lines changed: 388 additions & 137 deletions

File tree

crates/perry-codegen/src/codegen.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ pub struct ImportedClass {
167167
pub constructor_param_count: usize,
168168
/// Method names defined on this class.
169169
pub method_names: Vec<String>,
170+
/// Static method names defined on this class. Without this, calls like
171+
/// `MyClass.staticMethod(...)` on an imported class are treated as a
172+
/// missing method and fall through to `0.0` — turning every
173+
/// `await Foo.connect(...)` into a no-op that resolves with the number 0.
174+
pub static_method_names: Vec<String>,
170175
/// Getter property names. Without these, cross-module `obj.prop` for a
171176
/// getter property silently falls through to `undefined` because the
172177
/// dispatch site at `expr.rs::PropertyGet` looks up `(class, "__get_prop")`
@@ -1009,6 +1014,27 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
10091014
ctor_params.push(DOUBLE);
10101015
}
10111016
llmod.declare_function(&ctor_fn, VOID, &ctor_params);
1017+
1018+
// Cross-module static methods. Source module emits these as
1019+
// `perry_static_<source_prefix>__<class>__<method>` (no `this`
1020+
// receiver). Register them in `method_names` under the same
1021+
// (class, method) key the StaticMethodCall lowering looks up.
1022+
for sm in &ic.static_method_names {
1023+
let llvm_fn = format!(
1024+
"perry_static_{}__{}__{}",
1025+
sanitize(src),
1026+
sanitize(&ic.name),
1027+
sanitize(sm),
1028+
);
1029+
method_names
1030+
.entry((effective_name.to_string(), sm.clone()))
1031+
.or_insert_with(|| llvm_fn.clone());
1032+
// Declare conservatively with 6 double params; LLVM's direct-call
1033+
// resolution doesn't require an exact arity match for declarations.
1034+
let param_types: Vec<crate::types::LlvmType> =
1035+
std::iter::repeat(DOUBLE).take(6).collect();
1036+
llmod.declare_function(&llvm_fn, DOUBLE, &param_types);
1037+
}
10121038
}
10131039

10141040
// Resolve user function names up-front so body lowering can emit
@@ -1381,16 +1407,58 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
13811407
{
13821408
use std::collections::HashSet;
13831409
let mut emitted_wrappers: HashSet<String> = HashSet::new();
1410+
// Build a quick lookup of imported class names (and their local aliases).
1411+
// Classes have no `perry_fn_<src>__<Class>` symbol — method/constructor/
1412+
// static dispatch happens via separate tables. For these we still need
1413+
// the `__perry_extern_closure_*` global (other code may load it as a
1414+
// value), but the wrapper body must NOT call a missing function: emit
1415+
// a no-op that returns `undefined` so any indirect call through the
1416+
// closure header fails closed instead of failing at link time.
1417+
let mut imported_class_names: HashSet<String> = HashSet::new();
1418+
for ic in &opts.imported_classes {
1419+
imported_class_names.insert(ic.name.clone());
1420+
if let Some(alias) = &ic.local_alias {
1421+
imported_class_names.insert(alias.clone());
1422+
}
1423+
}
13841424
// Stable iteration order for deterministic IR output.
13851425
let mut imports: Vec<(&String, &String)> =
13861426
opts.import_function_prefixes.iter().collect();
13871427
imports.sort_by(|a, b| a.0.cmp(b.0));
13881428
for (name, source_prefix) in imports {
1429+
let is_class = imported_class_names.contains(name);
13891430
let wrapper_name =
13901431
format!("__perry_wrap_extern_{}__{}", source_prefix, name);
13911432
if !emitted_wrappers.insert(wrapper_name.clone()) {
13921433
continue;
13931434
}
1435+
if is_class {
1436+
// No-op wrapper + a closure header that points at it. The
1437+
// wrapper returns NaN-tagged `undefined` so any indirect call
1438+
// (`MyClass.somethingThatIsActuallyAFn()`) returns undefined.
1439+
// Match the regular wrapper's calling convention — `%this_closure`
1440+
// followed by 6 double params — so direct calls in the IR don't
1441+
// tear off into garbage stack slots.
1442+
let mut wrap_params: Vec<(LlvmType, String)> = Vec::with_capacity(7);
1443+
wrap_params.push((I64, "%this_closure".to_string()));
1444+
for i in 0..6 {
1445+
wrap_params.push((DOUBLE, format!("%a{}", i)));
1446+
}
1447+
let wf = llmod.define_function(&wrapper_name, DOUBLE, wrap_params);
1448+
wf.linkage = "internal".to_string();
1449+
let _ = wf.create_block("entry");
1450+
let blk = wf.block_mut(0).unwrap();
1451+
let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
1452+
blk.ret(DOUBLE, &undef);
1453+
let global_name =
1454+
format!("__perry_extern_closure_{}__{}", source_prefix, name);
1455+
let init = format!(
1456+
"{{ ptr @{}, i32 0, i32 1129074515 }}",
1457+
wrapper_name
1458+
);
1459+
llmod.add_internal_constant(&global_name, "{ ptr, i32, i32 }", &init);
1460+
continue;
1461+
}
13941462
let target_name = format!("perry_fn_{}__{}", source_prefix, name);
13951463
// Look up the param count from the import metadata. Fall back
13961464
// to 0 if missing — emits a no-arg wrapper, which is wrong

crates/perry-hir/src/destructuring.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,10 @@ pub(crate) fn lower_var_decl_with_destructuring(
13761376
}
13771377
_ => None,
13781378
};
1379+
// Variable-to-variable propagation for native instances
1380+
// (`let sock: Socket = plainSock`) is handled by the
1381+
// post-lowering cross-module pass; see
1382+
// `js_transform::scan_for_ident_init_propagation`.
13791383
if let Some(call_expr) = call_expr {
13801384
if let ast::Callee::Expr(callee_expr) = &call_expr.callee {
13811385
// Check direct function calls: const x = someFunc()

crates/perry-hir/src/js_transform.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,54 @@ pub fn fix_cross_module_native_instances(
976976
}
977977
}
978978

979+
// Variable-to-variable propagation: `let sock: Socket = plainSock` —
980+
// run a fixed-point scan so each rebind of an already-tracked native
981+
// instance keeps the dispatch information. Without this, a typed
982+
// ident-rebind drops the (module, class) tag and `sock.on(...)`
983+
// falls through to typed-interface dispatch on the small handle.
984+
{
985+
let mut changed = true;
986+
while changed {
987+
changed = false;
988+
// Init block
989+
for stmt in &module.init {
990+
if scan_for_ident_init_propagation(stmt, &mut local_native_instances, &mut local_id_native_instances) {
991+
changed = true;
992+
}
993+
}
994+
for func in &module.functions {
995+
for stmt in &func.body {
996+
if scan_for_ident_init_propagation(stmt, &mut local_native_instances, &mut local_id_native_instances) {
997+
changed = true;
998+
}
999+
}
1000+
}
1001+
for class in &module.classes {
1002+
if let Some(ctor) = &class.constructor {
1003+
for stmt in &ctor.body {
1004+
if scan_for_ident_init_propagation(stmt, &mut local_native_instances, &mut local_id_native_instances) {
1005+
changed = true;
1006+
}
1007+
}
1008+
}
1009+
for method in &class.methods {
1010+
for stmt in &method.body {
1011+
if scan_for_ident_init_propagation(stmt, &mut local_native_instances, &mut local_id_native_instances) {
1012+
changed = true;
1013+
}
1014+
}
1015+
}
1016+
for method in &class.static_methods {
1017+
for stmt in &method.body {
1018+
if scan_for_ident_init_propagation(stmt, &mut local_native_instances, &mut local_id_native_instances) {
1019+
changed = true;
1020+
}
1021+
}
1022+
}
1023+
}
1024+
}
1025+
}
1026+
9791027
if local_native_instances.is_empty() && local_id_native_instances.is_empty() {
9801028
return;
9811029
}
@@ -1093,6 +1141,104 @@ fn scan_for_native_func_returns(
10931141
}
10941142
}
10951143

1144+
/// Scan a statement (recursively) for `let x = y` patterns where `y` is
1145+
/// already a known native instance. When found, propagate the (module,
1146+
/// class) tag to `x` so its later `.on(...) / .write(...)` dispatches go
1147+
/// to the native runtime instead of the typed-interface fallback.
1148+
///
1149+
/// Returns `true` when at least one new propagation happened — the caller
1150+
/// fixes a point by re-running until stable.
1151+
fn scan_for_ident_init_propagation(
1152+
stmt: &Stmt,
1153+
local_native_instances: &mut HashMap<String, (String, String)>,
1154+
local_id_native_instances: &mut HashMap<perry_types::LocalId, (String, String)>,
1155+
) -> bool {
1156+
let mut changed = false;
1157+
match stmt {
1158+
Stmt::Let { id, name, init, .. } => {
1159+
if let Some(init_expr) = init {
1160+
if let Some((module, class)) = lookup_native_from_init_ident(init_expr, local_native_instances, local_id_native_instances) {
1161+
let info = (module, class);
1162+
if local_native_instances.insert(name.clone(), info.clone()).is_none() {
1163+
changed = true;
1164+
}
1165+
if local_id_native_instances.insert(*id, info).is_none() {
1166+
changed = true;
1167+
}
1168+
}
1169+
}
1170+
}
1171+
Stmt::If { then_branch, else_branch, .. } => {
1172+
for s in then_branch {
1173+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1174+
changed = true;
1175+
}
1176+
}
1177+
if let Some(else_stmts) = else_branch {
1178+
for s in else_stmts {
1179+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1180+
changed = true;
1181+
}
1182+
}
1183+
}
1184+
}
1185+
Stmt::While { body, .. } | Stmt::For { body, .. } => {
1186+
for s in body {
1187+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1188+
changed = true;
1189+
}
1190+
}
1191+
}
1192+
Stmt::Try { body, catch, finally } => {
1193+
for s in body {
1194+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1195+
changed = true;
1196+
}
1197+
}
1198+
if let Some(catch_block) = catch {
1199+
for s in &catch_block.body {
1200+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1201+
changed = true;
1202+
}
1203+
}
1204+
}
1205+
if let Some(finally_stmts) = finally {
1206+
for s in finally_stmts {
1207+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1208+
changed = true;
1209+
}
1210+
}
1211+
}
1212+
}
1213+
Stmt::Switch { cases, .. } => {
1214+
for case in cases {
1215+
for s in &case.body {
1216+
if scan_for_ident_init_propagation(s, local_native_instances, local_id_native_instances) {
1217+
changed = true;
1218+
}
1219+
}
1220+
}
1221+
}
1222+
_ => {}
1223+
}
1224+
changed
1225+
}
1226+
1227+
/// If the init expression resolves to a known native instance via a
1228+
/// LocalGet (HIR's representation of an ident reference), return its
1229+
/// (module, class). TS type casts are stripped at lowering time, so we
1230+
/// only need to inspect LocalGet here.
1231+
fn lookup_native_from_init_ident(
1232+
expr: &Expr,
1233+
_local_native_instances: &HashMap<String, (String, String)>,
1234+
local_id_native_instances: &HashMap<perry_types::LocalId, (String, String)>,
1235+
) -> Option<(String, String)> {
1236+
if let Expr::LocalGet(id) = expr {
1237+
return local_id_native_instances.get(id).cloned();
1238+
}
1239+
None
1240+
}
1241+
10961242
/// Walk an expression for nested closures and scan their bodies. Catches
10971243
/// `const sock = openSocket(...)` when wrapped in a closure passed to
10981244
/// `new Promise(...)`, `setTimeout(...)`, callback args, etc.

0 commit comments

Comments
 (0)