Skip to content

When calling a function across modules, calling methods of the passed-in object instance within the function does not take effect. #392

Closed

Description

@codehz

What happened

As title

What you expected

it should works

Minimal reproduction

Multiple files:

perry-repro-ecs-direct.ts

import { processCommands } from "./perry-repro-modules/commands";
import { Changeset } from "./perry-repro-modules/shared";

const changeset = new Changeset();
processCommands([{ type: "set", componentType: 1, component: { x: 1 } }], changeset);

console.log("adds size", changeset.adds.size);
console.log("has component", changeset.adds.has(1));

perry-repro-modules/commands.ts:

export function processCommands(
  commands: { type: string; componentType: number; component: unknown }[],
  changeset: { set(componentType: number, component: unknown): void },
): void {
  for (const command of commands) {
    if (command.type === "set") {
      changeset.set(command.componentType, command.component);
    }
  }
}

perry-repro-modules/shared.ts:

export class Changeset {
  readonly adds = new Map<number, unknown>();

  set(componentType: number, component: unknown): void {
    this.adds.set(componentType, component);
  }
}

Command you ran:

perry perry-repro-ecs-direct.ts && ./perry-repro-ecs-direct

output:

adds size 0
has component false

expected:

adds size 1
has component true

Environment

  • Perry version:
  • Host OS:
  • Target:
  • Rust toolchain (if building from source):
  • Installed via:

Diagnostic output

Perry Doctor

Environment Checks
──────────────────
  ✓ perry version: 0.5.455
  ✓ update status: up to date
  ✓ clang (LLVM codegen): clang version 22.1.3
  ✓ system linker (cc): cc (GCC) 15.2.1 20260209
  ✓ runtime library: /home/codehz/Projects/perry/target/release/libperry_runtime.a
  ⚠ project config (perry.toml): not found - run: perry init

All critical checks passed with some warnings.

Anything else

Found a compile/link bug
if I rename those files as a.ts, b.ts, c.ts and rewrite a.ts to

import { processCommands } from "./b.ts";
import { Changeset } from "./c.ts";

const changeset = new Changeset();
processCommands([{ type: "set", componentType: 1, component: { x: 1 } }], changeset);

console.log("adds size", changeset.adds.size);
console.log("has component", changeset.adds.has(1));

I got a link error like

Collecting modules...
  Compile package: @codehz/pipeline
Found 3 module(s): 3 native, 0 JavaScript
Generating code...
Wrote object file: a_ts.o
Wrote object file: b_ts.o
Wrote object file: c_ts.o
Linking (runtime-only)...
/usr/bin/ld: a_ts.o: in function `main':
perry_llvm_19546_1777593693465021409.ll:(.text+0x71): undefined reference to `b_ts__init'
/usr/bin/ld: perry_llvm_19546_1777593693465021409.ll:(.text+0x76): undefined reference to `c_ts__init'
/usr/bin/ld: perry_llvm_19546_1777593693465021409.ll:(.text+0x10f): undefined reference to `c_ts__Changeset_constructor'
/usr/bin/ld: perry_llvm_19546_1777593693465021409.ll:(.text+0x255): undefined reference to `perry_fn_b_ts__processCommands'
collect2: 错误:ld 返回 1
Error: Linking failed

its seems to be cache poisoning... same content file hit same cache in .perry-cache

objdump --syms b_ts.o

b_ts.o:     文件格式 elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*	0000000000000000 perry_llvm_18891_1777593312870011022.ll
0000000000000000 l    d  .text	0000000000000000 .text
0000000000000000 l       .rodata.cst8	0000000000000000 .LCPI0_0
0000000000000008 l       .rodata.cst8	0000000000000000 .LCPI0_1
0000000000000000 l     O .rodata.cst4	0000000000000004 perry_null_guard_zero
0000000000000048 l     O .bss	0000000000000008 perry_repro_modules_commands_ts_.str.3.handle
0000000000000005 l     O .rodata.str1.1	0000000000000004 .Lperry_repro_modules_commands_ts_.str.1.bytes
0000000000000030 l     O .bss	0000000000000008 perry_repro_modules_commands_ts_.str.0.handle
0000000000000038 l     O .bss	0000000000000008 perry_repro_modules_commands_ts_.str.1.handle
0000000000000040 l     O .bss	0000000000000008 perry_repro_modules_commands_ts_.str.2.handle
0000000000000010 l       .rodata.cst8	0000000000000000 .LCPI1_0
0000000000000018 l       .rodata.cst8	0000000000000000 .LCPI1_1
0000000000000020 l       .rodata.cst8	0000000000000000 .LCPI2_0
0000000000000000 l     O .rodata.str1.1	0000000000000005 .Lperry_repro_modules_commands_ts_.str.0.bytes
0000000000000009 l     O .rodata.str1.1	000000000000000e .Lperry_repro_modules_commands_ts_.str.2.bytes
0000000000000017 l     O .rodata.str1.1	000000000000000a .Lperry_repro_modules_commands_ts_.str.3.bytes
0000000000000000 l    d  .bss	0000000000000000 .bss
0000000000000000 g     F .text	0000000000000353 perry_fn_perry_repro_modules_commands_ts__processCommands
0000000000000000         *UND*	0000000000000000 js_shadow_frame_push
0000000000000000         *UND*	0000000000000000 js_shadow_slot_set
0000000000000000         *UND*	0000000000000000 js_value_length_f64
0000000000000000         *UND*	0000000000000000 js_object_get_field_by_name_f64
0000000000000000         *UND*	0000000000000000 js_native_call_method
0000000000000000         *UND*	0000000000000000 js_array_get_f64
0000000000000000         *UND*	0000000000000000 js_object_get_field_ic_miss
0000000000000000         *UND*	0000000000000000 js_get_string_pointer_unified
0000000000000000         *UND*	0000000000000000 js_string_equals
0000000000000000         *UND*	0000000000000000 js_shadow_frame_pop
0000000000000360 g     F .text	0000000000000353 __perry_wrap_perry_fn_perry_repro_modules_commands_ts__processCommands
00000000000006c0 g     F .text	0000000000000009 __perry_wrap_perry_unknown_func
00000000000006d0 g     F .text	0000000000000005 perry_repro_modules_commands_ts__init
00000000000006e0 g     F .text	00000000000000b6 __perry_init_strings_perry_repro_modules_commands_ts
0000000000000000         *UND*	0000000000000000 js_string_from_bytes
0000000000000000         *UND*	0000000000000000 js_nanbox_string
0000000000000000         *UND*	0000000000000000 js_gc_register_global_root

Activity

  1. added
    bugConfirmed defect or regression
    on May 1, 2026
  2. proggeramlug commented on May 1, 2026

    @proggeramlug
    Contributor

    Fixed in v0.5.464 (commit 6dbf9db — auto-closed by the commit message's closes #392 reference).

    Root cause: same-module method calls dispatched correctly through the codegen's static idispatch tower (lower_call.rs enumerates ctx.classes to find implementors and emits a class_id check). Cross-module calls fell through to the runtime's js_native_call_method, which DOES support vtable dispatch via CLASS_VTABLE_REGISTRY — but the codegen NEVER emitted any js_register_class_method(...) calls. The FFI was declared but no caller invoked it; method registration was effectively dead code.

    Fix in crates/perry-codegen/src/codegen.rs::emit_string_pool: after the existing js_register_class_parent block, emit one js_register_class_method(class_id, name_ptr, name_len, func_ptr, param_count) call per (class, method) pair for every class DEFINED in the current module (skipping imported class stubs via method.body.is_empty()). Pre-intern method names into the string pool before emit_string_pool runs since it takes &strings.

    Verified: your literal 3-file repro now prints adds size 1 / has component true (was 0 / false). Regression test test-files/test_issue_392_cross_module_method_dispatch.ts matches node --experimental-strip-types byte-for-byte. Parity 190/190 — no regressions.

    Side benefit: this also fixes the more general class-method dispatch path for any code where the codegen of the calling site can't see the receiver's class definition (e.g. methods called through generic typed parameters, methods on objects from Record<string, MyClass> lookups, etc.).

  3. codehz commented on May 1, 2026

    @codehz
    Author

    @proggeramlug can you fix the cache issue? if two file has exact same content, it will cached but due to the path is different, the generated symbol is different which can cause unexpected problem

  4. codehz commented on May 1, 2026

    @codehz
    Author

    @proggeramlug also another example hit same bug (tested in perry 0.5.464)
    perry-min/buffer-min.ts

    export type Command =
      | { type: "set"; componentType: number; component: any }
      | { type: "delete"; componentType: number };
    
    export class CommandBufferMin {
      private commands: Command[] = [];
    
      constructor(private executeEntityCommands: (entityId: number, commands: Command[]) => void) {
        this.executeEntityCommands = executeEntityCommands;
      }
    
      set(entityId: number, componentType: number, component: any): void {
        this.commands.push({ type: "set", componentType, component });
      }
    }

    perry-min/changeset.ts

    export class Changeset {
      readonly adds = new Map<number, any>();
      readonly removes = new Set<number>();
    
      set(componentType: number, component: any): void {
        this.adds.set(componentType, component);
        this.removes.delete(componentType);
      }
    
      delete(componentType: number): void {
        this.removes.add(componentType);
        this.adds.delete(componentType);
      }
    }

    perry-min/process.ts

    import type { Command } from "./buffer-min";
    import type { Changeset } from "./changeset";
    
    function processSetCommand(componentType: number, component: any, changeset: Changeset): void {
      changeset.set(componentType, component);
    }
    
    export function processCommands(commands: Command[], changeset: Changeset): void {
      for (const command of commands) {
        if (command.type === "set") {
          processSetCommand(command.componentType, command.component, changeset);
        } else if (command.type === "delete") {
          changeset.delete(command.componentType);
        }
      }
    }

    perry-min/main-buffer-min.ts

    import "./buffer-min";
    import { Changeset } from "./changeset";
    import { processCommands } from "./process";
    
    const changeset = new Changeset();
    
    processCommands([{ type: "set", componentType: 1, component: { x: 1 } }], changeset);
    
    console.log("adds size", changeset.adds.size);
    console.log("has component", changeset.adds.has(1));

    command:

    perry compile perry-min/main-buffer-min.ts -o repro && timeout 10 ./repro
    
  5. added 2 commits that reference this issue on May 1, 2026
    1f0bbfc
    13ead1a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions