From 1e5b48d83aa2738d5271a2ce37df93649ebfd66c Mon Sep 17 00:00:00 2001 From: Perry Windows Plugin Contributor Date: Sat, 20 Jun 2026 01:34:46 -0500 Subject: [PATCH 1/2] Add plugin ABI shims & Windows link/export fixes Emit a plugin ABI shim when building a dylib and ensure host runtime symbols and CRT are exported/resolved on Windows. - crates/perry-codegen: move detection of exported `activate`/`deactivate` earlier and emit a plugin ABI shim for dylib entry modules. The shim provides `perry_plugin_abi_version` and `plugin_activate`/`plugin_deactivate` wrappers that unbox the NaN-boxed API handle and call the user-provided functions (or return failure if absent). - crates/perry/src/commands/compile.rs: when linking MSVC DLLs, add `/defaultlib:libcmt` so the static C runtime is pulled in and CRT symbols referenced by the auto-generated DllMain are resolved (prevents ERROR_DLL_INIT_FAILED at LoadLibrary time). - crates/perry/src/commands/compile/link/build_and_run.rs: when generating the host .def file for the plugin host, run `llvm-nm` over the runtime and stdlib .lib archives to enumerate all `js_*`, `perry_*`, and `PERRY_*` symbols and write them to the .def. This ensures the plugin host exports the full symbol surface that plugins will resolve via GetProcAddress/dlsym (avoids many unresolved symbols causing DLL init failures). - crates/perry/src/commands/compile/link/mod.rs: update the PLUGIN_HOST_SYMBOLS list to reflect current host API names (rename/replace several entries, add new convenience names, and explicitly include `PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` so it's always exported even if not picked up by nm heuristics). Overall: these changes fix plugin load/link failures on Windows by exporting required runtime symbols and ensuring the plugin ABI entrypoints exist and match the host's expectations. --- crates/perry-codegen/src/codegen/entry.rs | 136 +++++++++--------- crates/perry/src/commands/compile.rs | 10 ++ .../commands/compile/link/build_and_run.rs | 72 ++++++++++ crates/perry/src/commands/compile/link/mod.rs | 19 ++- 4 files changed, 162 insertions(+), 75 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 656d2e6f0b..88554397f9 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -682,6 +682,14 @@ pub(super) fn compile_module_entry( let pending = std::mem::take(&mut ctx.pending_declares); let buffer_alias_used = ctx.buffer_data_slots.len() as u32; let native_rep_records = std::mem::take(&mut ctx.native_rep_records); + let has_plugin_activate = hir + .exported_functions + .iter() + .any(|(name, _)| name == "activate"); + let has_plugin_deactivate = hir + .exported_functions + .iter() + .any(|(name, _)| name == "deactivate"); drop(ctx); llmod.ic_counter = ic_end; llmod.buffer_alias_counter += buffer_alias_used; @@ -698,6 +706,66 @@ pub(super) fn compile_module_entry( for raw in &typed_parse_rodata { llmod.add_raw_global(raw.clone()); } + + // Plugin ABI shim — only emitted when the entry module is being + // built as a dylib (perry compile --output-type dylib). The host's + // `loadPlugin` calls `GetProcAddress(handle, "plugin_activate")` + // (Windows) / `dlsym(handle, "plugin_activate")` (macOS/Linux) to + // find the entry, so every dylib must export that name (and + // `plugin_deactivate` if the user supplied one). The shim unwraps + // the NaN-boxed `api` handle, calls the user's `activate(api)` + // with the raw pointer, and returns 1 on success / 0 if the + // module doesn't export `activate` (host treats that as load + // failure). `perry_plugin_abi_version` is the version the runtime + // checks against the host's expected ABI before calling activate + // — bump when the shim contract changes. + if is_dylib { + use crate::codegen::helpers::scoped_fn_name; + use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_I64}; + + { + let abi_fn = llmod.define_function("perry_plugin_abi_version", I64, vec![]); + let _ = abi_fn.create_block("entry"); + let blk = abi_fn.block_mut(0).unwrap(); + blk.ret(I64, "2"); + } + + if has_plugin_activate { + let user_activate = scoped_fn_name(module_prefix, "activate"); + llmod.declare_function(&user_activate, DOUBLE, &[DOUBLE]); + let fn_def = llmod.define_function( + "plugin_activate", + I64, + vec![(I64, "%api_handle".to_string())], + ); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + let lower48 = blk.and(I64, "%api_handle", POINTER_MASK_I64); + let tagged = blk.or(I64, &lower48, POINTER_TAG_I64); + let boxed = blk.bitcast_i64_to_double(&tagged); + let _ = blk.call(DOUBLE, &user_activate, &[(DOUBLE, &boxed)]); + blk.ret(I64, "1"); + } else { + let fn_def = llmod.define_function( + "plugin_activate", + I64, + vec![(I64, "%_api_handle".to_string())], + ); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + blk.ret(I64, "0"); + } + + if has_plugin_deactivate { + let user_deactivate = scoped_fn_name(module_prefix, "deactivate"); + llmod.declare_function(&user_deactivate, DOUBLE, &[]); + let fn_def = llmod.define_function("plugin_deactivate", VOID, vec![]); + let _ = fn_def.create_block("entry"); + let blk = fn_def.block_mut(0).unwrap(); + blk.call_void(&user_deactivate, &[]); + blk.ret_void(); + } + } } else { // Issue #753: idempotent init guard. Every non-entry module gets // a one-byte `@__perry_init_done_` flag and a thin @@ -1017,14 +1085,6 @@ pub(super) fn compile_module_entry( let pending = std::mem::take(&mut ctx.pending_declares); let buffer_alias_used = ctx.buffer_data_slots.len() as u32; let native_rep_records = std::mem::take(&mut ctx.native_rep_records); - let has_plugin_activate = hir - .exported_functions - .iter() - .any(|(name, _)| name == "activate"); - let has_plugin_deactivate = hir - .exported_functions - .iter() - .any(|(name, _)| name == "deactivate"); drop(ctx); llmod.ic_counter = ic_end; llmod.buffer_alias_counter += buffer_alias_used; @@ -1032,66 +1092,6 @@ pub(super) fn compile_module_entry( for (name, ret, params) in pending { llmod.declare_function(&name, ret, ¶ms); } - - // Plugin ABI shim — only emitted when the entry module is being - // built as a dylib (perry compile --output-type dylib). The host's - // `loadPlugin` calls `GetProcAddress(handle, "plugin_activate")` - // (Windows) / `dlsym(handle, "plugin_activate")` (macOS/Linux) to - // find the entry, so every dylib must export that name (and - // `plugin_deactivate` if the user supplied one). The shim unwraps - // the NaN-boxed `api` handle, calls the user's `activate(api)` - // with the raw pointer, and returns 1 on success / 0 if the - // module doesn't export `activate` (host treats that as load - // failure). `perry_plugin_abi_version` is the version the runtime - // checks against the host's expected ABI before calling activate - // — bump when the shim contract changes. - if is_dylib { - use crate::codegen::helpers::scoped_fn_name; - use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_I64}; - - { - let abi_fn = llmod.define_function("perry_plugin_abi_version", I64, vec![]); - let _ = abi_fn.create_block("entry"); - let blk = abi_fn.block_mut(0).unwrap(); - blk.ret(I64, "2"); - } - - if has_plugin_activate { - let user_activate = scoped_fn_name(module_prefix, "activate"); - llmod.declare_function(&user_activate, DOUBLE, &[DOUBLE]); - let fn_def = llmod.define_function( - "plugin_activate", - I64, - vec![(I64, "api_handle".to_string())], - ); - let _ = fn_def.create_block("entry"); - let blk = fn_def.block_mut(0).unwrap(); - let lower48 = blk.and(I64, "api_handle", POINTER_MASK_I64); - let tagged = blk.or(I64, &lower48, POINTER_TAG_I64); - let boxed = blk.bitcast_i64_to_double(&tagged); - let _ = blk.call(DOUBLE, &user_activate, &[(DOUBLE, &boxed)]); - blk.ret(I64, "1"); - } else { - let fn_def = llmod.define_function( - "plugin_activate", - I64, - vec![(I64, "_api_handle".to_string())], - ); - let _ = fn_def.create_block("entry"); - let blk = fn_def.block_mut(0).unwrap(); - blk.ret(I64, "0"); - } - - if has_plugin_deactivate { - let user_deactivate = scoped_fn_name(module_prefix, "deactivate"); - llmod.declare_function(&user_deactivate, DOUBLE, &[]); - let fn_def = llmod.define_function("plugin_deactivate", VOID, vec![]); - let _ = fn_def.create_block("entry"); - let blk = fn_def.block_mut(0).unwrap(); - blk.call_void(&user_deactivate, &[]); - blk.ret_void(); - } - } for ic_name in &ic_globals { llmod.add_raw_global(format!( "@{} = private global [2 x i64] zeroinitializer", diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index b5ba469eed..8e26001768 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -5404,6 +5404,16 @@ pub fn run_with_parse_cache( if is_dylib_windows { // MSVC link.exe takes the output path as `/OUT:`, not `-o`. cmd.arg(format!("/OUT:{}", exe_path.display())); + // Pull in the MSVC static C runtime (libcmt) so the CRT + // auto-generated DllMain + `_fltused` etc. resolve. Without + // this, `LoadLibraryW` of the plugin DLL returns + // `ERROR_DLL_INIT_FAILED` (Win32 error 1114) because the + // plugin's auto-emitted `DllMain` references unresolved + // CRT symbols. `/FORCE:UNRESOLVED` lets the link succeed + // with those still-unresolved entries, but the loader + // fails DLL_PROCESS_ATTACH. Linking libcmt resolves + // everything in the plugin itself. + cmd.arg("/defaultlib:libcmt"); } else { cmd.arg("-o").arg(&exe_path); } diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index fd2495b030..751b0f6598 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -455,6 +455,78 @@ pub(crate) fn build_and_run_link( for sym in PLUGIN_HOST_SYMBOLS { let _ = writeln!(def_file, " {}", sym); } + // Enumerate every `js_*` / `perry_*` symbol from the auto-built + // runtime + stdlib .lib archives. Plugins reference these via + // `GetProcAddress` after `LoadLibraryW`, so the host must + // export them — listing only the static PLUGIN_HOST_SYMBOLS + // subset leaves hundreds of `js_gc_*`, `js_array_*`, + // `js_string_*` etc. unresolved at LoadLibrary time and the + // DLL fails to initialize (Win32 error 1114 = + // ERROR_DLL_INIT_FAILED). The `llvm-nm` enumeration + // guarantees the full surface; it costs ~5000 .def lines and + // a few hundred ms of `nm` time per link. + let mut libs: Vec = vec![runtime_lib.to_path_buf()]; + if let Some(ref s) = stdlib_lib { + libs.push(s.clone()); + } + if let Some(nm_path) = find_llvm_tool("llvm-nm") { + let mut all_syms: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for lib in &libs { + if !std::path::Path::new(lib).exists() { + continue; + } + if let Ok(out) = std::process::Command::new(&nm_path) + .args(["--extern-only", "--defined-only"]) + .arg(lib) + .output() + { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + // llvm-nm format: " " e.g. + // "00000000 T _js_array_alloc" + // "00000000 B PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" + // "00000000 D __imp_PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" + // We capture T (text), B (BSS), D (data), R (rodata) and + // skip the __imp_ PE-import stubs (the leading-underscore + // non-prefixed name is the actual export). + let mut parts = line.split_whitespace(); + let _addr = parts.next(); + let ty = parts.next(); + let name_field = parts.next(); + if let (Some(ty), Some(rest)) = (ty, name_field) { + if !matches!(ty, "T" | "B" | "D" | "R") { + continue; + } + let bare = rest.strip_prefix('_').unwrap_or(rest); + let export_name = if let Some(stripped) = + bare.strip_prefix("__imp_") + { + stripped + } else { + bare + }; + if export_name.starts_with("js_") + || export_name.starts_with("perry_") + || export_name.starts_with("PERRY_") + { + all_syms.insert(export_name.to_string()); + } + } + } + } + } + } + for s in &all_syms { + let _ = writeln!(def_file, " {}", s); + } + eprintln!( + "[perry] plugin-host .def: {} symbols ({} base + enumerated)", + all_syms.len() + PLUGIN_HOST_SYMBOLS.len(), + PLUGIN_HOST_SYMBOLS.len() + ); + } } cmd.arg(format!("/DEF:{}", def_path.display())); } else { diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 139d63aa1d..1433b620d2 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -93,10 +93,8 @@ pub(super) const PLUGIN_HOST_SYMBOLS: &[&str] = &[ "perry_debug_trace_init_done", "perry_init_guard_check_and_set", // Plugin manager (perry-plugin) - "perry_plugin_abi_version", "perry_plugin_load", "perry_plugin_unload", - "perry_plugin_lookup_symbol", "perry_plugin_emit_hook", "perry_plugin_emit_event", "perry_plugin_invoke_tool", @@ -112,14 +110,21 @@ pub(super) const PLUGIN_HOST_SYMBOLS: &[&str] = &[ "perry_plugin_list_plugins", "perry_plugin_list_hooks", "perry_plugin_list_tools", - "perry_plugin_plugin_count", + "perry_plugin_count", "perry_plugin_set_metadata", "perry_plugin_get_config", - "perry_plugin_subscribe_event", - "perry_plugin_unsubscribe_event", - "perry_plugin_emit_event_bus", + "perry_plugin_on", + "perry_plugin_off", + "perry_plugin_emit", + "perry_plugin_log", + "perry_plugin_set_config", + "perry_plugin_discover", "perry_plugin_init", - "perry_plugin_last_load_error", + // Process-wide class-field inline guard (perry-runtime/src/object/mod.rs). + // A `#[no_mangle] pub static`; not picked up by `llvm-nm` filter heuristics + // for every codebase, so list it explicitly so the plugin DLL can find it + // at LoadLibrary time. + "PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED", ]; #[derive(Debug, Clone, PartialEq, Eq)] From f6ca32d54b2a3062bc02fac35ac205d7148cf617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 23 Jun 2026 05:12:01 +0200 Subject: [PATCH 2/2] style: cargo fmt build_and_run.rs symbol-scan block (lint gate) --- .../commands/compile/link/build_and_run.rs | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 751b0f6598..2d4da2df98 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -483,39 +483,38 @@ pub(crate) fn build_and_run_link( { if out.status.success() { let text = String::from_utf8_lossy(&out.stdout); - for line in text.lines() { - // llvm-nm format: " " e.g. - // "00000000 T _js_array_alloc" - // "00000000 B PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" - // "00000000 D __imp_PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" - // We capture T (text), B (BSS), D (data), R (rodata) and - // skip the __imp_ PE-import stubs (the leading-underscore - // non-prefixed name is the actual export). - let mut parts = line.split_whitespace(); - let _addr = parts.next(); - let ty = parts.next(); - let name_field = parts.next(); - if let (Some(ty), Some(rest)) = (ty, name_field) { - if !matches!(ty, "T" | "B" | "D" | "R") { - continue; - } - let bare = rest.strip_prefix('_').unwrap_or(rest); - let export_name = if let Some(stripped) = - bare.strip_prefix("__imp_") - { - stripped - } else { - bare - }; - if export_name.starts_with("js_") - || export_name.starts_with("perry_") - || export_name.starts_with("PERRY_") - { - all_syms.insert(export_name.to_string()); + for line in text.lines() { + // llvm-nm format: " " e.g. + // "00000000 T _js_array_alloc" + // "00000000 B PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" + // "00000000 D __imp_PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED" + // We capture T (text), B (BSS), D (data), R (rodata) and + // skip the __imp_ PE-import stubs (the leading-underscore + // non-prefixed name is the actual export). + let mut parts = line.split_whitespace(); + let _addr = parts.next(); + let ty = parts.next(); + let name_field = parts.next(); + if let (Some(ty), Some(rest)) = (ty, name_field) { + if !matches!(ty, "T" | "B" | "D" | "R") { + continue; + } + let bare = rest.strip_prefix('_').unwrap_or(rest); + let export_name = + if let Some(stripped) = bare.strip_prefix("__imp_") { + stripped + } else { + bare + }; + if export_name.starts_with("js_") + || export_name.starts_with("perry_") + || export_name.starts_with("PERRY_") + { + all_syms.insert(export_name.to_string()); + } } } } - } } } for s in &all_syms {