Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions crates/perry-hir/src/lower/expr_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 37 additions & 6 deletions crates/perry-hir/tests/node_named_export_hygiene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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}"
);
}
96 changes: 96 additions & 0 deletions crates/perry/tests/issue_4873_message_channel_global_new.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
21 changes: 21 additions & 0 deletions test-files/test_message_channel_global.ts
Original file line number Diff line number Diff line change
@@ -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);