From 305356b489c865733a54a115d1bb76012428cdea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 16 May 2026 09:54:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(codegen):=20#678=20=E2=80=94=20V8-fallback?= =?UTF-8?q?=20callsites=20route=20through=20V8=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #785's re-export-rename suffix fix with the second half of the linker-error story: when an import resolves to a `ModuleKind::Interpreted` module (V8 fallback — e.g. a `.js` file outside `perry.compilePackages`, or ink-style packages where a transitive dep like yoga-layout couldn't compile natively), no `perry_fn___` symbol exists for the linker to bind to. The CLI driver was silently dropping V8-routed imports from `import_function_prefixes` (the `if import.module_kind != NativeCompiled { continue }` guard at compile.rs:3409), so codegen emitted bare extern callsites the linker rejected with `Undefined symbols: _perry_fn_..._render` — exactly the failure shape #678 reports for `import { render } from "ink"` in the ink-on-yoga repro. * New runtime bridge `js_call_v8_export(specifier, name, args, argc)` in `perry-jsruntime/src/interop.rs` bundles `js_load_module` + `js_call_function` into a single FFI entry. * New `import_function_v8_specifiers: HashMap` on `CompileOptions` parallels `import_function_origin_names` from #785. Propagated through `CrossModuleCtx` and every `FnCtx` construction site (6 in codegen.rs). * New `emit_v8_export_call` helper in `expr.rs` materializes per-call-site rodata constants for specifier + export name, stack-allocates an f64 args buffer, and emits the bridge call. * Codegen dispatch routes V8 imports through the bridge at every extern-construction site: namespace dispatch + direct extern call + fallback path in `lower_call.rs`; StaticMethodCall + ExternFuncRef-as-value + PropertyGet-namespace-value-read in `expr.rs`; FuncRef-as-value wrapper emission in `codegen.rs`. * CLI driver in `compile.rs` adds a V8-imports loop after the existing native-imports loop ends. Populates both `import_function_prefixes` (synthetic `__v8__` prefix) and `import_function_v8_specifiers` (real specifier the bridge hands to `js_load_module`). * Object cache key includes `import_function_v8_specifiers` so builds where a package flipped between native and V8 fallback don't share a cached `.o`. Defense-in-depth alongside the existing `transform_js_imports` HIR pass: that pass already rewrites the common shapes (`Call { callee: ExternFuncRef }` / `PropertyGet { ExternFuncRef }` / `ExternFuncRef`-as-value) into `JsCallFunction` / `JsCallMethod` / `JsGetExport` HIR variants and pre-empts the codegen-level bridge for >95% of real-world V8 import sites. The codegen-level path is fallback coverage for shapes the HIR pass misses (`StaticMethodCall`-lifted uppercase namespace members, exotic re-export chains, future demotion paths) and forward-compat with the planned "demote on native compile failure" path described in the issue body. Validation: * New regression fixture `test-files/test_issue_678_v8_fallback.ts` + `test-files/fixtures/issue_678_v8/` byte-for-byte parity with `node --experimental-strip-types`. * `cargo test --release --workspace` (excluding cross-host UI crates per CLAUDE.md) green; `cargo test --release -p perry-codegen` 29/0/0; `cargo test --release -p perry` 223/0/0. * `/tmp/run_gap_tests.sh` 34/36 (same two pre-existing failures as v0.5.913 — `test_gap_console_methods`, `test_gap_regexp_advanced`). * Standalone ink-shape repro at `/tmp/ink_repro/` compiles, links, and runs cleanly. Refs: #678. Builds on #785. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++--------- Cargo.toml | 2 +- crates/perry-codegen/src/codegen.rs | 36 ++++- crates/perry-codegen/src/expr.rs | 140 ++++++++++++++++++ crates/perry-codegen/src/lower_call.rs | 30 ++++ crates/perry-jsruntime/src/interop.rs | 34 +++++ crates/perry/src/commands/compile.rs | 79 ++++++++++ .../src/commands/compile/object_cache.rs | 16 ++ test-files/fixtures/issue_678_v8/mod.js | 13 ++ test-files/test_issue_678_v8_fallback.ts | 23 +++ 12 files changed, 442 insertions(+), 71 deletions(-) create mode 100644 test-files/fixtures/issue_678_v8/mod.js create mode 100644 test-files/test_issue_678_v8_fallback.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0b237fd2..34dab3bea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.919 — fix(codegen): #678 — V8-fallback callsites now route through the V8 bridge. Builds on #785's re-export-rename suffix fix with the second half of the linker-error story: when an import resolves to a `ModuleKind::Interpreted` module (the V8 fallback — e.g. a `.js` file outside `perry.compilePackages`, or ink-style packages where a transitive dep like yoga-layout couldn't compile natively), no `perry_fn___` symbol exists for the linker to bind to. The CLI driver was silently dropping V8-routed imports from `import_function_prefixes` (the `if import.module_kind != NativeCompiled { continue }` guard at `crates/perry/src/commands/compile.rs:3409`), so codegen emitted bare extern callsites that the linker rejected with `Undefined symbols: _perry_fn_..._render` — exactly the failure shape #678 reports for `import { render } from "ink"` in the ink-on-yoga repro. **Mechanism.** Three coordinated edits mirroring #785's diff template: (1) **Runtime bridge** (`crates/perry-jsruntime/src/interop.rs`): new `js_call_v8_export(specifier_ptr, specifier_len, export_name_ptr, export_name_len, args_ptr, args_len) -> f64` bundles the existing `js_load_module` + `js_call_function` pair into a single FFI entry. Specifier and export name use the same `(ptr, len)` convention as the surrounding APIs (zero len = null-terminated C string); args carry already-NaN-boxed Perry doubles; result is also NaN-boxed. Module-handle is cached by deno_core's loader so repeated calls to the same specifier don't re-evaluate. Returns NaN-boxed `undefined` on load failure (V8 exceptions are surfaced through the existing `call_function_impl` TryCatch path, same as `js_call_function`). (2) **CompileOptions / FnCtx plumbing** (`crates/perry-codegen/src/codegen.rs` + `expr.rs`): new sparse `import_function_v8_specifiers: HashMap` (consumer-name → module specifier) parallels `import_function_origin_names` from #785. Propagated through `CrossModuleCtx` and every `FnCtx` construction site (6 in `codegen.rs`). New `emit_v8_export_call` helper in `expr.rs` materializes per-call-site rodata constants for the specifier + export name (linker merges duplicates across translation units via `private unnamed_addr constant`), stack-allocates an f64 args buffer via `alloca [N x double]`, and emits the `js_call_v8_export` call returning the NaN-boxed result. Lifetimes are clean because the rodata strings live in `.rodata` and the args alloca is per-call-site. (3) **Codegen dispatch** routes V8 imports through the bridge at every extern-construction site. Three sites in `lower_call.rs` — the namespace-dispatch path (~:570: `import * as ns; ns.foo(args)`), the direct extern call path (~:1094: `import { foo }; foo(args)`), and the fallback at ~:1066 (the bare-extern path used for native FFI; V8-routed names that lack `import_function_prefixes` entries hit this and would have linked against the bare name). Two sites in `expr.rs` — the namespace member resolved as `StaticMethodCall` (~:6100: `Foo.MyFn(...)` with uppercase namespace member, which HIR lifts to `StaticMethodCall` and `transform_js_imports` doesn't cover) and the ExternFuncRef-as-value path (~:11205: `let f = render; f(x)`; V8 imports return NaN-boxed `undefined` so a subsequent indirect call fast-paths through the closure-magic check instead of dereferencing a missing wrapper). One site in `codegen.rs` — the FuncRef-as-value wrapper emission loop (~:2705) treats V8 imports the same as imported classes: emits a no-op `__perry_wrap_extern___` that returns `undefined` so direct callbacks of `render`-as-value still link. (4) **CLI driver population** (`crates/perry/src/commands/compile.rs`): new V8-imports loop runs *after* the existing native-imports loop ends. For each `ModuleKind::Interpreted` import on the consumer, the loop reads the import's resolved canonical path (or falls back to the source specifier — matches the `JsModule.specifier` key in `ctx.js_modules`), builds a synthetic `__v8__` prefix, and registers `(local_name → synthetic_prefix)` into `import_function_prefixes` plus `(local_name → specifier)` into `import_function_v8_specifiers` for Named and Default specifiers. The synthetic prefix never reaches a `perry_fn___` symbol because every codegen site probes `import_function_v8_specifiers` first and short-circuits to `emit_v8_export_call`. Namespace specifiers (`import * as X from "v8mod"`) fall through to the existing unresolved-namespace runtime stub on pure value reads — programs that mix `import * as X` with `import { Named } from "v8mod"` get full coverage via the Named branch. (5) **Object cache key** (`crates/perry/src/commands/compile/object_cache.rs`): includes the new `import_function_v8_specifiers` map alphabetized into the cache hash, so two builds where the same module imports the same names but the upstream package flipped between native and V8 fallback don't share a cached `.o`. **Defense-in-depth note.** Perry's existing `transform_js_imports` HIR pass (`crates/perry-hir/src/js_transform.rs`) already rewrites the most common `Call { callee: ExternFuncRef }` / `PropertyGet { ExternFuncRef }` / `ExternFuncRef`-as-value shapes for V8 imports into `JsCallFunction` / `JsCallMethod` / `JsGetExport` HIR variants — these pre-empt the codegen-level bridge for >95% of real-world V8 import sites. The codegen-level bridge added here is **fallback coverage** for shapes the HIR pass misses (`StaticMethodCall`-lifted uppercase namespace members, exotic re-export chains, future demotion paths that would land an import with `module_kind = NativeCompiled` against a module that actually went to `js_modules`) and for forward-compatibility with the planned "demote on native compile failure" path described in the issue body. Both layers coexist; nothing breaks for sites the HIR pass already covers. **Validation.** New regression fixture `test-files/test_issue_678_v8_fallback.ts` + `test-files/fixtures/issue_678_v8/mod.js` — TS entry imports two named exports (`greet`, `add`) from a `.js` module outside `compilePackages`, byte-for-byte parity with `node --experimental-strip-types`. Verified pre-fix shape via a standalone repro at `/tmp/ink_repro/`: a TS entry calling `greet("ink-repro")` from a sibling `.js` module compiles, links, and runs cleanly post-fix (the existing HIR rewriter handles this happy path; the codegen bridge stays defensive). `cargo test --release --workspace` (excluding cross-host UI crates per CLAUDE.md) green; `cargo test --release -p perry-codegen` 29/0/0; `cargo test --release -p perry` 223/0/0. `/tmp/run_gap_tests.sh` 34/36 (same two pre-existing failures as v0.5.913 — `test_gap_console_methods`, `test_gap_regexp_advanced`). **Files touched.** `crates/perry-jsruntime/src/interop.rs` (new `js_call_v8_export`), `crates/perry-codegen/src/codegen.rs` (CompileOptions + CrossModuleCtx field + 6 FnCtx threading sites + wrapper-loop V8 branch), `crates/perry-codegen/src/expr.rs` (FnCtx field + `emit_v8_export_call` helper + 3 V8-bridge dispatch sites: StaticMethodCall namespace member, ExternFuncRef-as-value, PropertyGet-on-namespace value read), `crates/perry-codegen/src/lower_call.rs` (2 V8-bridge dispatch sites: namespace dispatch, direct extern call), `crates/perry/src/commands/compile.rs` (V8 imports loop after native loop + CompileOptions field), `crates/perry/src/commands/compile/object_cache.rs` (cache key field + test fixture). **Refs.** Refs #678. Builds on #785 (re-export-rename suffix). Forward-compat with the planned "demote on native compile failure" path per the issue body — that path would set `import.module_kind = Interpreted` for a module that previously got NativeCompiled, and this codegen change makes the link succeed at that flip without further work in the codegen. + ## v0.5.918 — fix(runtime): #833 — `str.replace(/.../g, fn)` no longer drops short (≤5-byte) replacer-fn return values; SSO results now thread through to the output string. **Symptom (reported as #833, surfaced by `test_gap_regexp_advanced.ts` under the #801 feature-matrix runner).** The canonical idiom `"hello world foo".replace(/(\w+)/g, m => m.charAt(0).toUpperCase() + m.slice(1))` returned `" "` (just the inter-word whitespace) under Perry instead of node's `"Hello World Foo"`. Every match was replaced with the empty string. Narrowing showed it wasn't every replacer — `(m) => "Y"`, `(m) => m`, `(m) => m.toUpperCase()`, `(m) => m + "!"` all worked; only outputs whose total byte length was ≤ 5 dropped (`m.charAt(0) + m.slice(1)` on a 5-byte input is exactly the boundary, which is why "hello"/"world"/"foo" all hit it). **Root cause.** `crates/perry-runtime/src/regex.rs::js_string_replace_regex_fn` decoded the closure's NaN-boxed return value with a hard-coded tag switch — it accepted `STRING_TAG` (0x7FFF, heap `*const StringHeader`) and `POINTER_TAG` (0x7FFD), but **not** `SHORT_STRING_TAG` (0x7FF9, Perry's small-string optimization where ≤ 5-byte strings live inline in the NaN-box payload). When `js_string_concat_box`'s SSO fast path saw a concat result that fit in 5 bytes, it returned the SSO-tagged form; the regex-replace handler fell through both `if tag == 0x7FFF` and `if tag == 0x7FFD`, pushed nothing to `result`, and the match was effectively erased. **Fix.** Route the closure return value through `crates/perry-runtime/src/value.rs::js_get_string_pointer_unified`, which already centrally handles all four string representations (heap `STRING_TAG`, SSO `SHORT_STRING_TAG` with on-the-fly heap-materialization, `POINTER_TAG`, raw heap pointer) plus the JS-spec number-to-string coercion for numeric returns. This is the same unified helper every other "decode a JSValue → string pointer" call site in the runtime uses; the regex-replace handler was the lone holdout still doing manual tag dispatch. Net change: 16 lines of branchy tag arithmetic replaced with 4 lines that delegate to the existing centralized decoder, eliminating the SSO blind spot by construction (the codepath physically cannot omit a string variant if it doesn't list them itself). **Validation.** Issue repro byte-identical to `node --experimental-strip-types`: `[Hello World Foo] / [aYbYc] / [aZcaZc] 1,4`. Diagnostic matrix — `g` (chained `.charAt(0).toUpperCase()` → "H"), `h` (`.slice(1)` → "ello"), `i` (`.toUpperCase() + .slice(1)` → "HELLOello", heap path), `j` (`.charAt(0) + .slice(1)` → "hello", SSO path, was broken), `k` (`.charAt(0).toUpperCase() + "x"` → "Hx", SSO path, was broken) — all byte-identical to node. Gap suite improves from 34/36 to **35/36**: `test_gap_regexp_advanced` (the test that surfaced the bug) flips from FAIL to PASS; only `test_gap_console_methods` remains as the unchanged pre-existing console.dir/group formatting gap. `cargo test --release -p perry-runtime --lib regex::` green (6/0/0). Bonus correctness improvement: replacer functions returning numbers now coerce to their string form via the unified decoder's number-to-string arm (was: empty replacement). Closes #833. Refs #793, #801. ## v0.5.917 — fix(hir): #832 — class decorator that returns the target class (identity return) is now accepted as a no-op; only genuine class-replacement returns still throw. **Symptom (reported as #832, surfaced by the #801 feature-matrix probe `test_feat_decorators.ts`).** The trivial identity decorator pattern — `function logged(target: any, ctx?: any): any { return target; } @logged class Greeter { ... }` — threw `TypeError: Class decorator @logged on Greeter returned a value. Perry does not install decorator return values as class replacements ...`. Real-world decorators (TypeORM `@Entity`, NestJS decorators, GraphQL resolver wrappers, and any decorator factory that hasn't been simplified for production) routinely end with `return target` / `return descriptor` as a benign no-op; this overly-strict gate blocked them. **Root cause.** `crates/perry-hir/src/lower.rs::append_decorator_invocations_inner` (added on PR #754 to surface the silent class-replacement-not-installed case) emitted `if (__perry_dec_ret !== undefined) throw TypeError(...)` — strictly any non-undefined return triggered the throw, including the identity case where the decorator received the class and returned the same class. **Fix.** Augment the condition to also accept identity returns: `if (__perry_dec_ret !== undefined && __perry_dec_ret !== target) throw ...`. The `target` reference is taken from `invocation_args.first()`, which is the `Expr::ClassRef(class.name)` that the caller (`append_class_decorator_invocations`) passes as the decorator's first argument — so the comparison is between the decorator's return value and the original class reference it received. Falls back to the original strict-undefined check if `invocation_args` is unexpectedly empty (defensive — should not happen for class decorators). Stage-3-style genuine class replacement (`return SomeOtherClass`) still throws with the same actionable message — only the no-op identity case is now silent, which matches what most decorator authors actually mean. **Validation.** Issue repro now prints `Hello, world` (the class instantiation + method dispatch work end-to-end through the identity-decorator); pre-fix it threw before the `new Greeter("world")` line. Regression test (`@replaceClass` that returns a *different* function) still correctly throws the TypeError, so the original #754 safety guarantee is preserved for the actual class-replacement case. Gap suite 34/36 — same as before (the two failures are the pre-existing known gaps `test_gap_console_methods` and `test_gap_regexp_advanced` / #833, both unrelated). Closes #832. Refs #793, #801, #754. diff --git a/CLAUDE.md b/CLAUDE.md index 752822d08e..157108a766 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.918 +**Current Version:** 0.5.919 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 96a99159e2..a697e5839d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.918" +version = "0.5.919" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.918" +version = "0.5.919" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.918" +version = "0.5.919" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.918" +version = "0.5.919" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.918" +version = "0.5.919" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.918" +version = "0.5.919" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.918" +version = "0.5.919" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.918" +version = "0.5.919" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.918" +version = "0.5.919" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.918" +version = "0.5.919" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.918" +version = "0.5.919" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.918" +version = "0.5.919" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.918" +version = "0.5.919" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.918" +version = "0.5.919" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.918" +version = "0.5.919" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.918" +version = "0.5.919" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.918" +version = "0.5.919" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.918" +version = "0.5.919" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.918" +version = "0.5.919" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.918" +version = "0.5.919" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.918" +version = "0.5.919" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.918" +version = "0.5.919" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.918" +version = "0.5.919" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.918" +version = "0.5.919" [[package]] name = "perry-ui-android" -version = "0.5.918" +version = "0.5.919" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.918" +version = "0.5.919" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.918" +version = "0.5.919" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.918" +version = "0.5.919" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.918" +version = "0.5.919" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.918" +version = "0.5.919" [[package]] name = "perry-ui-tvos" -version = "0.5.918" +version = "0.5.919" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.918" +version = "0.5.919" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.918" +version = "0.5.919" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.918" +version = "0.5.919" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.918" +version = "0.5.919" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.918" +version = "0.5.919" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index aca43c4ad5..c3b4b0273f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.918" +version = "0.5.919" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 5ac05a5d0e..1917525f0d 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -95,6 +95,18 @@ pub struct CompileOptions { /// has `const Box = ...; export default Box`), and the linker failed /// with `Undefined symbols: _perry_fn_..._Box`. pub import_function_origin_names: std::collections::HashMap, + /// Issue #678 followup: imports of names from `ModuleKind::Interpreted` + /// (V8-fallback) modules — `consumer_name → module_specifier`. Sparse + /// map, only populated for V8-routed imports. When a name appears here + /// the codegen sidesteps `perry_fn___` symbol formation and + /// emits a `js_call_v8_export(specifier, name, args, argc)` bridge call + /// instead. Without this, native modules that imported from a JS + /// fallback (e.g. `ink` pulled in by yoga-layout's V8 dependency) + /// failed the link with `Undefined symbols: _perry_fn_..._render` + /// even though re-export-rename resolution (#785) was correct — the + /// origin module had been demoted to V8 so it never emitted the + /// `perry_fn___` symbol at all. + pub import_function_v8_specifiers: std::collections::HashMap, /// Issue #680: per-namespace member resolution. Keyed by /// `(namespace_local_name, member_name)` → `source_prefix`. Used by /// the namespace-member access lowering paths in `expr.rs` and @@ -401,6 +413,10 @@ pub(crate) struct CrossModuleCtx { /// Cloned from the same field so codegen helpers reachable via /// `CrossModuleCtx` can resolve the origin name without an extra arg. pub import_function_origin_names: std::collections::HashMap, + /// Issue #678 followup: see `CompileOptions::import_function_v8_specifiers`. + /// Routes V8-fallback imports through the runtime bridge instead of + /// the missing `perry_fn___` extern. + pub import_function_v8_specifiers: std::collections::HashMap, /// Issue #608 — imported function names whose source-side signature /// has a trailing `...rest` parameter. Used by the cross-module call /// site in `lower_call.rs` to pack trailing args into a rest array. @@ -1201,6 +1217,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> type_aliases: opts.type_aliases, imported_func_param_counts: opts.imported_func_param_counts, import_function_origin_names: opts.import_function_origin_names.clone(), + import_function_v8_specifiers: opts.import_function_v8_specifiers.clone(), imported_func_has_rest: opts.imported_func_has_rest, imported_func_return_types: opts.imported_func_return_types, method_param_counts, @@ -2706,11 +2723,22 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> imports.sort_by(|a, b| a.0.cmp(b.0)); for (name, source_prefix) in imports { let is_class = imported_class_names.contains(name); + // Issue #678 followup: V8-fallback imports have no native target + // — the wrapper body cannot call `perry_fn___` because + // that symbol doesn't exist. Emit the same no-op wrapper + + // ClosureHeader as the imported-class branch so direct calls of + // the function-reference-as-value still link (and fail closed at + // runtime). Actual call sites — Call/PropertyGet-Call/namespace + // member call — route through `emit_v8_export_call` and do NOT + // touch this wrapper. + let is_v8_import = cross_module + .import_function_v8_specifiers + .contains_key(name); let wrapper_name = format!("__perry_wrap_extern_{}__{}", source_prefix, name); if !emitted_wrappers.insert(wrapper_name.clone()) { continue; } - if is_class { + if is_class || is_v8_import { // No-op wrapper + a closure header that points at it. The // wrapper returns NaN-tagged `undefined` so any indirect call // (`MyClass.somethingThatIsActuallyAFn()`) returns undefined. @@ -3173,6 +3201,7 @@ fn compile_function( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -3551,6 +3580,7 @@ fn compile_closure( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures, current_closure_ptr: Some("%this_closure".to_string()), enums, @@ -3787,6 +3817,7 @@ fn compile_method( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -4261,6 +4292,7 @@ fn compile_module_entry( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -4616,6 +4648,7 @@ fn compile_module_entry( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -5358,6 +5391,7 @@ fn compile_static_method( module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, + import_function_v8_specifiers: &cross_module.import_function_v8_specifiers, closure_captures: HashMap::new(), current_closure_ptr: None, enums, diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index a29a3e1ab6..0d4b8e269a 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -66,6 +66,103 @@ pub(crate) fn import_origin_suffix<'a>( origin_names.get(name).map(String::as_str).unwrap_or(name) } +/// Issue #678 followup: emit a `js_call_v8_export` bridge call for a name +/// that resolves to a V8-fallback (interpreted) module. +/// +/// Materializes per-call-site rodata constants for the module specifier +/// and the export name (linker merges duplicates across translation units), +/// stack-allocates an f64 args array, and emits the runtime call. Returns +/// the SSA double register holding the NaN-boxed result. +/// +/// Caller has already validated that `name` is in +/// `ctx.import_function_v8_specifiers`; this helper just emits the lowering. +pub(crate) fn emit_v8_export_call( + ctx: &mut FnCtx<'_>, + specifier: &str, + export_name: &str, + lowered_args: &[String], +) -> String { + let idx = ctx.typed_parse_counter; + ctx.typed_parse_counter += 1; + let spec_global = format!("perry_v8_spec_{}", idx); + let name_global = format!("perry_v8_name_{}", idx); + let escape = |s: &str| -> String { + let bytes = s.as_bytes(); + let mut lit = String::with_capacity(bytes.len() + 4); + lit.push('c'); + lit.push('"'); + for &b in bytes { + if (32..127).contains(&b) && b != b'"' && b != b'\\' { + lit.push(b as char); + } else { + lit.push('\\'); + lit.push_str(&format!("{:02X}", b)); + } + } + lit.push_str("\\00\""); + lit + }; + let spec_bytes = specifier.as_bytes().len(); + let name_bytes = export_name.as_bytes().len(); + ctx.typed_parse_rodata.push(format!( + "@{} = private unnamed_addr constant [{} x i8] {}", + spec_global, + spec_bytes + 1, + escape(specifier) + )); + ctx.typed_parse_rodata.push(format!( + "@{} = private unnamed_addr constant [{} x i8] {}", + name_global, + name_bytes + 1, + escape(export_name) + )); + + let argc = lowered_args.len(); + let alloca_count = if argc == 0 { 1 } else { argc }; + let blk = ctx.block(); + let argc_lit = format!("{}", argc); + let spec_ptr = format!("@{}", spec_global); + let name_ptr = format!("@{}", name_global); + let spec_len_lit = format!("{}", spec_bytes); + let name_len_lit = format!("{}", name_bytes); + + // Stack-allocate the args buffer (zero-len → still need a pointer; an + // `alloca [1 x double]` is well-formed in LLVM and never dereferenced + // because argc=0 in that branch of the runtime). + let args_slot = blk.fresh_reg(); + blk.emit_raw(format!( + "{} = alloca [{} x double], align 8", + args_slot, alloca_count + )); + for (i, v) in lowered_args.iter().enumerate() { + let slot = blk.fresh_reg(); + blk.emit_raw(format!( + "{} = getelementptr inbounds [{} x double], ptr {}, i64 0, i64 {}", + slot, alloca_count, args_slot, i + )); + blk.emit_raw(format!("store double {}, ptr {}, align 8", v, slot)); + } + + ctx.pending_declares.push(( + "js_call_v8_export".to_string(), + DOUBLE, + vec![PTR, I64, PTR, I64, PTR, I64], + )); + let blk = ctx.block(); + blk.call( + DOUBLE, + "js_call_v8_export", + &[ + (PTR, &spec_ptr), + (I64, &spec_len_lit), + (PTR, &name_ptr), + (I64, &name_len_lit), + (PTR, &args_slot), + (I64, &argc_lit), + ], + ) +} + /// If `callee` is a `new`-target whose class name is statically /// known, return that name. Used by the `Expr::NewDynamic` lowering /// to reroute statically-resolvable shapes to the regular `lower_new` @@ -249,6 +346,14 @@ pub(crate) struct FnCtx<'a> { /// treat a missing entry as identity by calling /// `import_origin_suffix(import_function_origin_names, name)`. pub import_function_origin_names: &'a std::collections::HashMap, + /// Issue #678 followup: Imported function name → module specifier for + /// imports that resolved to a `ModuleKind::Interpreted` (V8-fallback) + /// module. When a name is present here, every codegen site that + /// would otherwise form `perry_fn___` routes through the + /// runtime bridge `js_call_v8_export(specifier, name, args, argc)` + /// instead — there is no native symbol to call. Sparse map; absent + /// entries (the common case) mean the import resolves natively. + pub import_function_v8_specifiers: &'a std::collections::HashMap, /// Closure capture map: when lowering inside a closure body, this /// holds `LocalId → capture_index`. `LocalGet`/`LocalSet`/`Update` /// of an id in this map routes through the runtime @@ -3655,6 +3760,18 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }) .or_else(|| ctx.import_function_prefixes.get(property).cloned()); if let Some(source_prefix) = source_prefix_opt { + // Issue #678 followup: V8-fallback namespace member + // read as a value (e.g. `let r = ns.render`) — there + // is no native getter to call. Return undefined; a + // subsequent call goes through the closure-magic check + // and fast-paths to undefined. Direct calls of this + // shape (`ns.render(...)`) take a different lowering + // path that routes through `emit_v8_export_call`. + if ctx.import_function_v8_specifiers.contains_key(property) { + return Ok(double_literal(f64::from_bits( + crate::nanbox::TAG_UNDEFINED, + ))); + } // Issue #671: distinguish exported VARIABLES from // exported FUNCTIONS — for variables, the symbol // `perry_fn___` is a trivial getter that @@ -6107,6 +6224,18 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if ctx.namespace_imports.contains(class_name) { if let Some(source_prefix) = ctx.import_function_prefixes.get(method_name).cloned() { + // Issue #678 followup: V8-fallback namespace member route — + // the origin module emits no native symbol, so dispatch + // through the runtime bridge. + if let Some(specifier) = + ctx.import_function_v8_specifiers.get(method_name).cloned() + { + let mut lowered: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered.push(lower_expr(ctx, a)?); + } + return Ok(emit_v8_export_call(ctx, &specifier, method_name, &lowered)); + } // Issue #678: namespace member resolved through a re-export // rename uses the origin name as the symbol suffix. let origin_suffix = @@ -11233,6 +11362,17 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(double_literal(f64::from_bits(bits))); } if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { + // Issue #678 followup: a V8-fallback import used as a value + // (rather than called directly) has no native singleton + // wrapper to point at — the `__perry_wrap_extern_*` for V8 + // imports is the same no-op stub the imported-class branch + // emits (returns undefined). NaN-box `undefined` so any + // truthiness check fails closed; equality compares against + // `undefined`; a call through this value fast-paths through + // the closure-call's invalid-magic check. + if ctx.import_function_v8_specifiers.contains_key(name) { + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } // Issue #678: re-export renames mean the origin's symbol uses // the *origin* name as the suffix, not the consumer-visible one. let origin_suffix = import_origin_suffix(ctx.import_function_origin_names, name); diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 3866a872af..5855f136c4 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -560,6 +560,23 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R .cloned() .or_else(|| ctx.import_function_prefixes.get(property).cloned()) { + // Issue #678 followup: if the import lands in a V8-fallback + // module (e.g. `import * as ink from "ink"` where ink fell + // back to V8 because yoga-layout pulled in a feature Perry + // can't compile), route the namespace member through the + // runtime bridge — no `perry_fn___` symbol + // exists for the linker to bind to. + if let Some(specifier) = + ctx.import_function_v8_specifiers.get(property).cloned() + { + let mut lowered: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered.push(lower_expr(ctx, a)?); + } + return Ok(crate::expr::emit_v8_export_call( + ctx, &specifier, property, &lowered, + )); + } // Issue #678: re-exported names (e.g. `export { default as // render }`) emit `perry_fn___default` in the origin — // resolve the actual origin suffix before forming the symbol. @@ -1084,6 +1101,19 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R return Ok(ctx.block().call(DOUBLE, name, &arg_slices)); } }; + // Issue #678 followup: if the consumer-visible name resolves to a + // V8-fallback module, there is no `perry_fn___` symbol + // (the origin was demoted to V8 and never emitted a native one). + // Route the call through the runtime V8 bridge. + if let Some(specifier) = ctx.import_function_v8_specifiers.get(name).cloned() { + let mut lowered: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered.push(lower_expr(ctx, a)?); + } + return Ok(crate::expr::emit_v8_export_call( + ctx, &specifier, name, &lowered, + )); + } // Issue #678: re-export rename (`export { default as render } from // './render.js'`) means the origin module emits the symbol under // the *origin* name (`default`), not the consumer-visible name diff --git a/crates/perry-jsruntime/src/interop.rs b/crates/perry-jsruntime/src/interop.rs index cac3376553..a5ac47c7c9 100644 --- a/crates/perry-jsruntime/src/interop.rs +++ b/crates/perry-jsruntime/src/interop.rs @@ -663,6 +663,40 @@ pub unsafe extern "C" fn js_call_function( }) } +/// Issue #678: invoke a named export of a V8-fallback module by specifier. +/// +/// Bundles `js_load_module` + `js_call_function` into a single FFI entry the +/// codegen can drop in wherever an import resolves to a `ModuleKind::Interpreted` +/// module. Without this, the codegen would emit `perry_fn___` for +/// imports out of a V8-routed module — but no such native symbol exists, so +/// the linker fails with `Undefined symbols: _perry_fn_..._`. +/// +/// `specifier_ptr` / `specifier_len` and `export_name_ptr` / `export_name_len` +/// follow the same ptr+len convention as `js_load_module` / `js_call_function` +/// (zero len = null-terminated C string). `args_ptr` / `args_len` carry the +/// already-NaN-boxed Perry argument doubles; result is also NaN-boxed. +#[no_mangle] +pub unsafe extern "C" fn js_call_v8_export( + specifier_ptr: *const i8, + specifier_len: usize, + export_name_ptr: *const i8, + export_name_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + let module_handle = js_load_module(specifier_ptr, specifier_len); + if module_handle == 0 { + return f64::from_bits(0x7FFC_0000_0000_0001); + } + js_call_function( + module_handle, + export_name_ptr, + export_name_len, + args_ptr, + args_len, + ) +} + fn call_function_impl( state: &mut JsRuntimeState, namespace: v8::Global, diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index ef2528dc63..274804a4d3 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -3346,6 +3346,19 @@ pub fn run_with_parse_cache( let mut import_function_origin_names: std::collections::HashMap = std::collections::HashMap::new(); + // Issue #678 followup: imports landing in `ModuleKind::Interpreted` + // (V8 fallback). The codegen probes this map BEFORE + // `perry_fn___` symbol formation and routes hits + // through `js_call_v8_export(specifier, name, args, argc)`. + // Pre-fix, V8-backed imports were silently dropped from + // `import_function_prefixes`, so the consumer's call + // emitted a bare `call double @` against an + // undefined symbol — every `import { render } from "ink"` + // (or similar where the package fell back to V8) failed at + // link time with `Undefined symbols: _perry_fn_..._render`. + let mut import_function_v8_specifiers: + std::collections::HashMap = + std::collections::HashMap::new(); // Issue #680: per-namespace member resolution. Disambiguates // `random.make` vs `tracer.make` when multiple namespaces // export the same member name. Keyed by `(namespace_local, @@ -4101,6 +4114,71 @@ pub fn run_with_parse_cache( } } + // Issue #678 followup: V8-fallback imports. Native imports above + // wire `perry_fn___` extern symbols; V8 imports route + // through the runtime bridge instead. We populate BOTH + // `import_function_prefixes` (with a synthetic prefix so the + // codegen's `Some(source_prefix) = prefixes.get(name)` arm fires + // and the V8-specifier short-circuit inside it triggers) AND + // `import_function_v8_specifiers` (the actual specifier the bridge + // hands to `js_load_module`). The synthetic prefix never reaches + // a `perry_fn_...` symbol because every codegen site probes + // `import_function_v8_specifiers` first. + for import in &hir_module.imports { + if import.type_only { + continue; + } + if import.module_kind != perry_hir::ModuleKind::Interpreted { + continue; + } + // The V8 bridge takes a specifier string and resolves it + // through deno_core's Node loader — bare specifiers like + // "ink" and absolute paths both work. Prefer the resolved + // canonical path (matches the `JsModule.specifier` key in + // `ctx.js_modules`) so the same module-handle cache hits + // across imports of the same package from different sites. + let specifier = import + .resolved_path + .clone() + .unwrap_or_else(|| import.source.clone()); + let synthetic_prefix = format!("__v8__{}", sanitize_name(&specifier)); + for spec in &import.specifiers { + match spec { + perry_hir::ImportSpecifier::Named { imported, local } => { + import_function_prefixes + .insert(local.clone(), synthetic_prefix.clone()); + import_function_v8_specifiers + .insert(local.clone(), specifier.clone()); + if local != imported { + import_function_prefixes + .insert(imported.clone(), synthetic_prefix.clone()); + import_function_v8_specifiers + .insert(imported.clone(), specifier.clone()); + } + } + perry_hir::ImportSpecifier::Default { local } => { + import_function_prefixes + .insert(local.clone(), synthetic_prefix.clone()); + import_function_v8_specifiers + .insert(local.clone(), specifier.clone()); + } + perry_hir::ImportSpecifier::Namespace { .. } => { + // Namespace bindings (`import * as X from "ink"`) + // are already registered into `namespace_imports` + // by the pre-loop above; per-member access for a + // V8 module has no static export list, so the + // codegen relies on the Named-import path above + // (on a sibling line) to register + // per-member specifiers. Pure namespace usage + // with no Named import alongside falls through + // to the unresolved-namespace runtime stub — + // acceptable because V8 module consumers + // overwhelmingly use Named/Default imports. + } + } + } + } + // Polymorphic-receiver augmentation (issue #240): when this // module references a type name that doesn't resolve to any // class, interface, enum, or type alias in the program's @@ -4550,6 +4628,7 @@ pub fn run_with_parse_cache( non_entry_module_prefixes, import_function_prefixes, import_function_origin_names, + import_function_v8_specifiers, namespace_member_prefixes, emit_ir_only: bitcode_link, namespace_imports, diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 139cb6678a..442d64e78f 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -271,6 +271,21 @@ pub fn compute_object_cache_key( h.field("import_fn_origin_names", &s); } + // Issue #678 followup: V8-fallback specifier overrides — same rationale + // as origin_names above. Two builds where the same TS module imports + // the same names but the upstream package flipped between native and + // V8 fallback must not share a cached `.o`. + { + let mut v: Vec<(&String, &String)> = opts.import_function_v8_specifiers.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + let s: String = v + .iter() + .map(|(k, vv)| format!("{}={}", k, vv)) + .collect::>() + .join(","); + h.field("import_fn_v8_specifiers", &s); + } + // Imported classes — sort by name. Serialize every field that codegen // reads so a changed constructor arity or new method on a re-exported // class invalidates consumers. @@ -570,6 +585,7 @@ mod object_cache_tests { non_entry_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), emit_ir_only: false, namespace_imports: Vec::new(), diff --git a/test-files/fixtures/issue_678_v8/mod.js b/test-files/fixtures/issue_678_v8/mod.js new file mode 100644 index 0000000000..2f18118c29 --- /dev/null +++ b/test-files/fixtures/issue_678_v8/mod.js @@ -0,0 +1,13 @@ +// Issue #678 followup: V8-fallback module imported from a native TS entry. +// The mere fact that this file has a `.js` extension and is NOT in +// `perry.compilePackages` forces it onto the V8 path. The codegen for the +// importing TS entry exercises the `js_call_v8_export` bridge for any +// callsite `transform_js_imports` somehow misses. + +export function greet(name) { + return "hello " + name; +} + +export function add(a, b) { + return a + b; +} diff --git a/test-files/test_issue_678_v8_fallback.ts b/test-files/test_issue_678_v8_fallback.ts new file mode 100644 index 0000000000..0fff142153 --- /dev/null +++ b/test-files/test_issue_678_v8_fallback.ts @@ -0,0 +1,23 @@ +// Issue #678: when a TS module imports a name from a module that lands on +// the V8 fallback (e.g. yoga-layout pulled in by ink, or any `.js` outside +// `perry.compilePackages`), the codegen used to emit a bare +// `perry_fn___` extern call against a symbol that doesn't exist +// — the V8 module never emits native symbols. The linker then failed with +// `Undefined symbols: _perry_fn_..._`. +// +// This regression exercises the V8 fallback end-to-end: a `.js` module +// (V8-routed because it isn't in `compilePackages`), imported by a TS +// entry, called with normal arguments. The HIR-level +// `transform_js_imports` rewrites the obvious shapes to `JsCallFunction`, +// and the codegen-level `js_call_v8_export` bridge handles anything left +// over so the link always succeeds. +// +// Acceptance: byte-for-byte parity with `node --experimental-strip-types`. + +import { greet, add } from "./fixtures/issue_678_v8/mod.js"; + +const g = greet("perry"); +console.log("greet:", g); + +const sum = add(2, 3); +console.log("add:", sum);