From 6b036b633f96b524ce191838b4ce70ba6feddfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 10 Jun 2026 08:33:12 +0200 Subject: [PATCH] fix(hir): route global new MessageChannel()/BroadcastChannel() to always-linked runtime constructors (#4873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The *global* constructor forms — bare `new MessageChannel()` with no worker_threads import, and `new globalThis.MessageChannel()` — were lowered to a worker_threads NativeMethodCall, which codegen maps to the stdlib-only `js_worker_threads_message_channel_new`. That symbol is `#[used]`-anchored inside `node:worker_threads`, so any binary that never imports the module failed to link (`Undefined symbols: _js_worker_threads_message_channel_new`). React's scheduler runs exactly this shape at module init (`typeof MessageChannel !== "undefined"` then `new MessageChannel()`), killing every React/react-reconciler/ink app (#348). Lower the global forms as `Expr::New` instead, so codegen's `lower_builtin_new` emits the perry-runtime `js_message_channel_new` / `js_broadcast_channel_new` — always linked. Those runtime globals delegate to the full worker_threads factory whenever the stdlib has registered it (paired-port delivery, receiveMessageOnPort, onmessage all verified working through the global form), and fall back to the Web-shaped stub otherwise. The genuinely-imported forms (`import { MessageChannel } from "node:worker_threads"`) keep their stdlib routing — the import anchors the symbol in the link. Validated against Node byte-for-byte: standalone link + run, the scheduler-shaped onmessage flush, globalThis member form, BroadcastChannel, and the #3157 imported-form gap test (no regression). --- crates/perry-hir/src/lower/expr_new.rs | 34 ++++++- .../tests/node_named_export_hygiene.rs | 43 +++++++-- .../issue_4873_message_channel_global_new.rs | 96 +++++++++++++++++++ test-files/test_message_channel_global.ts | 21 ++++ 4 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 crates/perry/tests/issue_4873_message_channel_global_new.rs create mode 100644 test-files/test_message_channel_global.ts diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 0c4b41f9fc..f053505ac8 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -402,8 +402,22 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R if let Some(class_name) = global_member_constructor_name(ctx, obj_name, prop_ident.sym.as_ref()) { + // #4873: the *global* `new globalThis.MessageChannel()` / + // `BroadcastChannel` forms must lower as `Expr::New` so codegen + // emits the always-linked runtime constructors + // (`js_message_channel_new` / `js_broadcast_channel_new`, + // perry-runtime). Routing them to the worker_threads + // NativeMethodCall left an undefined + // `js_worker_threads_message_channel_new` symbol in binaries + // that never import `node:worker_threads`. The runtime global + // delegates to the full worker_threads factory whenever the + // stdlib has registered it, so no behavior is lost. if is_worker_messaging_constructor_name(class_name) { - return lower_worker_messaging_new(ctx, class_name, new_expr.args.as_deref()); + return Ok(Expr::New { + class_name: class_name.to_string(), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + type_args: Vec::new(), + }); } if let Some(expr) = lower_url_encoding_constructor(ctx, class_name, new_expr.args.as_deref())? @@ -900,10 +914,26 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R return lower_worker_messaging_new(ctx, &class_name, new_expr.args.as_deref()); } + // #4873: bare `new MessageChannel()` / `new BroadcastChannel()` + // with NO worker_threads import is the *global* constructor form + // (React's scheduler feature-detects exactly this way). Lower as + // `Expr::New` so codegen's `lower_builtin_new` emits the + // always-linked `js_message_channel_new` / + // `js_broadcast_channel_new` (perry-runtime). The previous + // worker_threads NativeMethodCall routing referenced the + // stdlib-only `js_worker_threads_*_new` symbols, which fail to + // link unless something else pulls in `node:worker_threads`. The + // runtime globals delegate to the registered worker_threads + // factories when the stdlib is present, so ports stay fully + // functional in graphs that have it. if is_worker_messaging_constructor_name(&class_name) && ctx.lookup_local(&class_name).is_none() { - return lower_worker_messaging_new(ctx, &class_name, new_expr.args.as_deref()); + return Ok(Expr::New { + class_name: class_name.to_string(), + args: lower_optional_args(ctx, new_expr.args.as_deref())?, + type_args: Vec::new(), + }); } let inspector_session_module = ctx.lookup_native_module(&class_name).and_then( diff --git a/crates/perry-hir/tests/node_named_export_hygiene.rs b/crates/perry-hir/tests/node_named_export_hygiene.rs index f799b5cad2..6fb0a2ae29 100644 --- a/crates/perry-hir/tests/node_named_export_hygiene.rs +++ b/crates/perry-hir/tests/node_named_export_hygiene.rs @@ -221,7 +221,13 @@ fn worker_threads_post_message_call_keeps_property_call_shape() { } #[test] -fn global_worker_messaging_constructors_lower_to_worker_threads() { +fn global_worker_messaging_constructors_lower_to_builtin_new() { + // #4873: the *global* constructor forms (no worker_threads import) must + // lower as `Expr::New` so codegen emits the always-linked runtime + // constructors (`js_message_channel_new` / `js_broadcast_channel_new`). + // Routing them to the worker_threads NativeMethodCall left an undefined + // `js_worker_threads_message_channel_new` symbol in binaries that never + // import `node:worker_threads` (React's scheduler hit this at init). let module = lower_result( r#" const a = new BroadcastChannel("a"); @@ -234,12 +240,37 @@ fn global_worker_messaging_constructors_lower_to_worker_threads() { .expect("global messaging constructors should lower"); let debug = format!("{module:#?}"); - let broadcast_count = debug.matches("method: \"BroadcastChannel\"").count(); - let message_channel_count = debug.matches("method: \"MessageChannel\"").count(); + assert!( + !debug.contains("module: \"worker_threads\""), + "global messaging constructors must not route to worker_threads-only symbols: {debug}" + ); + let broadcast_count = debug.matches("class_name: \"BroadcastChannel\"").count(); + let message_channel_count = debug.matches("class_name: \"MessageChannel\"").count(); + assert!( + broadcast_count >= 2 && message_channel_count >= 2, + "global messaging constructors should lower as Expr::New on the builtin class: {debug}" + ); +} + +#[test] +fn imported_worker_messaging_constructors_keep_worker_threads_routing() { + // The genuinely-imported forms keep the stdlib NativeMethodCall routing — + // the import anchors `js_worker_threads_*_new` in the link. + let module = lower_result( + r#" + import { MessageChannel, BroadcastChannel } from "node:worker_threads"; + const a = new MessageChannel(); + const b = new BroadcastChannel("b"); + console.log(a, b); + "#, + ) + .expect("imported messaging constructors should lower"); + + let debug = format!("{module:#?}"); assert!( debug.contains("module: \"worker_threads\"") - && broadcast_count >= 2 - && message_channel_count >= 2, - "global messaging constructors should route through worker_threads native constructors: {debug}" + && debug.contains("method: \"MessageChannel\"") + && debug.contains("method: \"BroadcastChannel\""), + "imported messaging constructors should route through worker_threads native constructors: {debug}" ); } diff --git a/crates/perry/tests/issue_4873_message_channel_global_new.rs b/crates/perry/tests/issue_4873_message_channel_global_new.rs new file mode 100644 index 0000000000..597f175d1c --- /dev/null +++ b/crates/perry/tests/issue_4873_message_channel_global_new.rs @@ -0,0 +1,96 @@ +//! Regression test for #4873: bare `new MessageChannel()` as a *global* +//! constructor must (a) link standalone — previously it emitted a call to the +//! stdlib-only `js_worker_threads_message_channel_new`, leaving an undefined +//! symbol unless something else in the graph imported `node:worker_threads` — +//! and (b) produce a real `{ port1, port2 }` object. React's scheduler runs +//! exactly this shape at module init (`typeof MessageChannel !== "undefined"` +//! then `new MessageChannel()`), so every React/ink app died here. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed (link error = #4873 regression)\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Standalone graph — no `worker_threads` import anywhere. Must link and the +/// channel must be a real object with port objects (Node prints `object +/// object object`). +#[test] +fn bare_new_message_channel_links_standalone() { + let stdout = compile_and_run( + r#" +const c = new MessageChannel(); +console.log(typeof c, typeof c.port1, typeof c.port2); +if (typeof MessageChannel !== "undefined") { + const channel = new MessageChannel(); + const port = channel.port2; + console.log("scheduler-branch", typeof port.postMessage); +} +const g = new globalThis.MessageChannel(); +console.log("globalThis-form", typeof g.port1, typeof g.port2); +const bc = new BroadcastChannel("chan"); +console.log("broadcast", typeof bc, bc.name); +"#, + ); + assert_eq!( + stdout, + "object object object\nscheduler-branch function\nglobalThis-form object object\nbroadcast object chan\n", + "global MessageChannel/BroadcastChannel `new` must produce real objects" + ); +} + +/// Graph that *does* import `node:worker_threads`: the global constructor +/// must delegate to the registered worker_threads factory, so paired-port +/// message delivery (`receiveMessageOnPort`) works on a channel created via +/// the bare global form. +#[test] +fn bare_new_message_channel_delegates_to_worker_threads() { + let stdout = compile_and_run( + r#" +import { receiveMessageOnPort } from "node:worker_threads"; +const c = new MessageChannel(); +c.port1.postMessage({ n: 7 }); +const received = receiveMessageOnPort(c.port2); +console.log(JSON.stringify(received)); +c.port1.close(); +c.port2.close(); +"#, + ); + assert_eq!( + stdout, "{\"message\":{\"n\":7}}\n", + "global-form channel must use the real worker_threads ports when the module is linked" + ); +} diff --git a/test-files/test_message_channel_global.ts b/test-files/test_message_channel_global.ts new file mode 100644 index 0000000000..34c28e00c5 --- /dev/null +++ b/test-files/test_message_channel_global.ts @@ -0,0 +1,21 @@ +// #4873: bare `new MessageChannel()` as a global constructor must link +// standalone (no worker_threads import anywhere in the graph) and produce a +// real { port1, port2 } object — React's scheduler feature-detects exactly +// this way at module init. +const c = new MessageChannel(); +console.log(typeof c, typeof c.port1, typeof c.port2); + +// The scheduler-shaped feature detection branch. +if (typeof MessageChannel !== "undefined") { + const channel = new MessageChannel(); + const port = channel.port2; + console.log("scheduler-branch", typeof port.postMessage); +} + +// globalThis member form. +const g = new globalThis.MessageChannel(); +console.log("globalThis-form", typeof g.port1, typeof g.port2); + +// BroadcastChannel rides the same lowering. +const bc = new BroadcastChannel("chan"); +console.log("broadcast", typeof bc, bc.name);