diff --git a/changelog.d/10236-worker-path-await-helper.md b/changelog.d/10236-worker-path-await-helper.md new file mode 100644 index 0000000000..dcbce9865b --- /dev/null +++ b/changelog.d/10236-worker-path-await-helper.md @@ -0,0 +1,3 @@ +Worker filename discovery now follows awaited synchronous or async helpers whose bodies contain const path bindings and if/return chains. It collects the union of returned paths without evaluating branch conditions, including opaque awaited filesystem probes, while retaining rejection of unsupported return values, mutation, generators, recursion, and bounded expansion limits. + +Missing worker candidates are skipped with a warning and aliases compile as a single worker entry. Multiple existing candidates dispatch using the runtime filename, including URL values; selecting a missing candidate throws instead of starting another worker. This supports OpenCode's TUI worker selector with its compile-time worker-path define and missing distribution-layout fallback. diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index f6e9c4edf8..e6c77523f6 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -17,6 +17,9 @@ use super::{ nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx, I18nLowerCtx, }; +#[path = "worker_new.rs"] +mod worker_new; + /// Build the namespace value for a resolved dynamic-import/require target prefix /// on the current block: a native submodule (`__node_submod__`), a native /// builtin (`__native_mod__`), or a compiled module (`__init` + @@ -488,6 +491,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { options, is_eval: _, } => { + if paths.len() > 1 { + return worker_new::lower_candidates(ctx, paths, filename, options.as_deref()); + } let _ = lower_expr(ctx, filename)?; if ctx.block().is_terminated() { return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); diff --git a/crates/perry-codegen/src/expr/worker_new.rs b/crates/perry-codegen/src/expr/worker_new.rs new file mode 100644 index 0000000000..e4e34b727d --- /dev/null +++ b/crates/perry-codegen/src/expr/worker_new.rs @@ -0,0 +1,131 @@ +//! Runtime selection among the entries discovered by a Worker path helper. +use anyhow::{bail, Result}; +use perry_hir::Expr; + +use crate::expr::FnCtx; +use crate::nanbox::{double_literal, POINTER_TAG_TOP16_I64}; +use crate::rooting::with_rooted_group; +use crate::types::{DOUBLE, I32, I64, PTR, VOID}; + +pub(super) fn lower_candidates( + ctx: &mut FnCtx<'_>, + paths: &[String], + filename: &Expr, + options: Option<&Expr>, +) -> Result { + let targets: Vec<_> = paths + .iter() + .filter_map(|path| ctx.dynamic_import_path_to_prefix.get(path).cloned()) + .collect(); + if targets + .iter() + .any(|target| target.starts_with("__node_submod__") || target.starts_with("__native_mod__")) + { + bail!("worker_threads Worker target must be a compiled source file"); + } + // The driver includes lexical absolute paths for URL values as well as + // import spellings. Sort to keep emitted IR stable. + let mut aliases: Vec<_> = ctx + .dynamic_import_path_to_prefix + .iter() + .filter(|(_, target)| targets.contains(target)) + .map(|(path, target)| (path.clone(), target.clone())) + .collect(); + aliases.sort(); + with_rooted_group(ctx, 2, |ctx, roots| { + let undefined = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let file = roots.lower(ctx, filename, true)?; + if ctx.block().is_terminated() { + return Ok(undefined); + } + let opts = roots.lower(ctx, options.unwrap_or(&Expr::Undefined), true)?; + if ctx.block().is_terminated() { + return Ok(undefined); + } + let file = roots.reread(ctx, file)?; + // The path grammar proves each value is a string or URL. Decode URL + // objects through fileURLToPath, so both encoded and unescaped hrefs + // (including spaces) compare as filesystem paths, without coercion. + let bits = ctx.block().bitcast_double_to_i64(&file); + let tag = ctx.block().lshr(I64, &bits, "48"); + let is_url = ctx.block().icmp_eq(I64, &tag, POINTER_TAG_TOP16_I64); + let normalized = ctx.block().alloca(DOUBLE); + let url_block = ctx.new_block("worker_url"); + let string_block = ctx.new_block("worker_string"); + let dispatch = ctx.new_block("worker_dispatch"); + let url_label = ctx.block_label(url_block); + let string_label = ctx.block_label(string_block); + let dispatch_label = ctx.block_label(dispatch); + ctx.block().cond_br(&is_url, &url_label, &string_label); + ctx.current_block = url_block; + let path = ctx.block().call( + DOUBLE, + "js_url_file_url_to_path", + &[(DOUBLE, &file), (DOUBLE, &undefined)], + ); + ctx.block().store(DOUBLE, &path, &normalized); + ctx.block().br(&dispatch_label); + ctx.current_block = string_block; + ctx.block().store(DOUBLE, &file, &normalized); + ctx.block().br(&dispatch_label); + ctx.current_block = dispatch; + let file = ctx.block().load(DOUBLE, &normalized); + let spec = ctx + .block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &file)]); + // Comparisons below do not allocate. Only the selected spawn can + // collect, after the last use of `spec`; options remain rooted. + let result = ctx.block().alloca(DOUBLE); + let join = ctx.new_block("worker_join"); + for (path, target) in &aliases { + let key = ctx.strings.intern(path); + let global = format!("@{}", ctx.strings.entry(key).handle_global); + let key = ctx.block().load(DOUBLE, &global); + let key = ctx + .block() + .call(I64, "js_get_string_pointer_unified", &[(DOUBLE, &key)]); + let eq = ctx + .block() + .call(I32, "js_string_equals", &[(I64, &spec), (I64, &key)]); + let matches = ctx.block().icmp_ne(I32, &eq, "0"); + let matched = ctx.new_block("worker_match"); + let next = ctx.new_block("worker_next"); + let matched_label = ctx.block_label(matched); + let next_label = ctx.block_label(next); + ctx.block().cond_br(&matches, &matched_label, &next_label); + ctx.current_block = matched; + // Every worker executes the unguarded body in its own thread. + let init = format!("{target}__init_body"); + ctx.pending_declares.push((init.clone(), VOID, vec![])); + let entry = ctx.block().ptrtoint(&format!("@{init}"), I64); + let options = roots.reread(ctx, opts)?; + let worker = ctx.block().call( + DOUBLE, + "js_worker_threads_worker_new", + &[(I64, &entry), (DOUBLE, &options)], + ); + let join_label = ctx.block_label(join); + ctx.block().store(DOUBLE, &worker, &result); + ctx.block().br(&join_label); + ctx.current_block = next; + } + let message = "worker_threads Worker filename did not match an existing compile-time-resolved worker entry"; + let message_id = ctx.strings.intern(message); + let entry = ctx.strings.entry(message_id); + let global = format!("@{}", entry.bytes_global); + let len = entry.byte_len.to_string(); + ctx.block().call_void( + "js_throw_error_with_code", + &[ + (PTR, &global), + (I64, &len), + (PTR, "null"), + (I64, "0"), + (I32, "0"), + ], + ); + ctx.block().unreachable(); + ctx.current_block = join; + Ok(ctx.block().load(DOUBLE, &result)) + }) +} diff --git a/crates/perry-hir/src/dynamic_import/worker_paths.rs b/crates/perry-hir/src/dynamic_import/worker_paths.rs index d6404ff84c..b661f8a7f3 100644 --- a/crates/perry-hir/src/dynamic_import/worker_paths.rs +++ b/crates/perry-hir/src/dynamic_import/worker_paths.rs @@ -15,6 +15,8 @@ type Paths = Result; #[derive(Clone)] struct PathValues { paths: Vec, + // True if ANY candidate is a URL. Mixed return unions may be consumed as + // Worker filenames, but must never be coerced to lexical relative strings. is_url: bool, } @@ -92,6 +94,7 @@ impl> WorkerPaths<'_, V> { fn resolve(&mut self, expr: &Expr, depth: usize) -> Paths { self.tick(depth)?; match expr { + Expr::Await(value) => self.resolve(value, depth + 1), Expr::String(value) => bounded(vec![value.clone()], &mut self.work), Expr::StringCoerce(value) => self.strings(value, depth + 1).map(PathValues::strings), Expr::LocalGet(id) => { @@ -295,7 +298,7 @@ impl> WorkerPaths<'_, V> { .ok_or("call target is mutable or is not a module-local helper")? .borrow(); } - let (id, params, body, asynchronous) = match target { + let (id, params, body, generator) = match target { Expr::FuncRef(id) => { let function = self .module @@ -303,25 +306,19 @@ impl> WorkerPaths<'_, V> { .iter() .find(|function| function.id == *id) .ok_or("call target is not a module-local helper")?; - ( - *id, - &function.params, - &function.body, - function.is_async || function.is_generator || function.was_plain_async, - ) + (*id, &function.params, &function.body, function.is_generator) } Expr::Closure { func_id, params, body, - is_async, is_generator, .. - } => (*func_id, params, body, *is_async || *is_generator), + } => (*func_id, params, body, *is_generator), _ => return Err("opaque call target is not a module-local helper".into()), }; - if asynchronous { - return Err("async and generator helpers are not static path helpers".into()); + if generator { + return Err("generator helpers are not static path helpers".into()); } if params.len() != args.len() || params.iter().any(|p| { @@ -335,9 +332,6 @@ impl> WorkerPaths<'_, V> { "helper requires an exact list of simple static string/URL arguments".into(), ); } - let [Stmt::Return(Some(value))] = body.as_slice() else { - return Err("helper body must contain only a single return (no effects, mutation or multiple returns)".into()); - }; // Resolve arguments before entering the callee so sibling/nested calls // such as identity(identity(path)) are not mistaken for recursion. let mut bindings = Vec::new(); @@ -351,7 +345,16 @@ impl> WorkerPaths<'_, V> { .into_iter() .map(|(id, paths)| (id, self.arguments.insert(id, paths))) .collect(); - let result = self.resolve(value, depth + 1); + let mut values = PathValues::strings(Vec::new()); + let result = self + .returns(body, &mut values, depth + 1) + .and_then(|returns| { + if returns { + Ok(values) + } else { + Err("helper may fall through without returning a path".into()) + } + }); for (id, previous) in saved { if let Some(previous) = previous { self.arguments.insert(id, previous); @@ -362,6 +365,102 @@ impl> WorkerPaths<'_, V> { self.calls.remove(&id); result } + + // Discover edges, not control flow: conditions can contain opaque calls + // (including awaited filesystem probes). Every returned value and every + // const initializer must still belong to the bounded static path grammar. + fn returns( + &mut self, + body: &[Stmt], + values: &mut PathValues, + depth: usize, + ) -> Result { + self.tick(depth)?; + let mut always_returns = false; + for stmt in body { + self.tick(depth)?; + match stmt { + Stmt::Return(Some(value)) => { + let returned = self.resolve(value, depth + 1)?; + values.is_url |= returned.is_url; + for path in returned.paths { + push_path(&mut values.paths, path, &mut self.work)?; + } + always_returns = true; + } + Stmt::Let { + id, + mutable: false, + init: Some(init), + .. + } if self.consts.contains_key(id) => { + self.resolve(init, depth + 1)?; + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.condition(condition, depth + 1)?; + let then_returns = self.returns(then_branch, values, depth + 1)?; + let else_returns = match else_branch { + Some(branch) => self.returns(branch, values, depth + 1)?, + None => false, + }; + always_returns |= then_returns && else_returns; + } + _ => return Err( + "helper body supports only const paths, if and return (no effects or mutation)" + .into(), + ), + } + } + Ok(always_returns) + } + + fn condition(&mut self, expr: &Expr, depth: usize) -> Result<(), String> { + self.tick(depth)?; + if matches!( + expr, + Expr::LocalSet(..) + | Expr::GlobalSet(..) + | Expr::Update { .. } + | Expr::PropertySet { .. } + | Expr::PropertyUpdate { .. } + | Expr::IndexSet { .. } + | Expr::IndexUpdate { .. } + | Expr::StaticFieldSet { .. } + | Expr::WithSet { .. } + | Expr::ClassStaticSymbolSet { .. } + | Expr::SuperPropertySet { .. } + | Expr::ObjectSuperPropertySet { .. } + | Expr::JsSetProperty { .. } + | Expr::PutValueSet { .. } + | Expr::ProxySet { .. } + | Expr::BufferIndexSet { .. } + | Expr::RegExpSetLastIndex { .. } + | Expr::ProcessSetTitle(..) + | Expr::UrlSetHref { .. } + | Expr::UrlSetPathname { .. } + | Expr::UrlSetSearch { .. } + | Expr::UrlSetHash { .. } + | Expr::UrlSetProtocol { .. } + | Expr::UrlSetHostname { .. } + | Expr::UrlSetPort { .. } + | Expr::UrlSetUsername { .. } + | Expr::UrlSetPassword { .. } + | Expr::Delete(..) + ) { + return Err("helper condition contains mutation".into()); + } + let mut result = Ok(()); + walk_expr_children(expr, &mut |child| { + if result.is_ok() { + result = self.condition(child, depth + 1); + } + }); + result + } } fn spend(work: &mut usize) -> Result<(), String> { diff --git a/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs index 972831e4d2..d4082e9b11 100644 --- a/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs +++ b/crates/perry-hir/src/dynamic_import/worker_paths/tests.rs @@ -93,11 +93,10 @@ fn unsafe_helpers_stay_unresolved_with_reasons() { for body in [ "console.log('effect'); return './worker.js';", "let x = './worker.js'; x = './other.js'; return x;", - "if (true) return './worker.js'; return './other.js';", ] { rejected( &format!("function entry() {{ {body} }} new Worker(entry());"), - "single return", + "no effects or mutation", ); } rejected( @@ -114,8 +113,8 @@ fn unsafe_helpers_stay_unresolved_with_reasons() { "exact list", ); rejected( - "const entry = async () => './worker.js'; new Worker(entry());", - "async", + "function* entry() { return './worker.js'; } new Worker(entry());", + "generator", ); rejected( "const entry = (x = './worker.js') => x; new Worker(entry());", @@ -214,3 +213,69 @@ fn branching_helper_expansion_has_a_shared_work_budget() { source.push_str("new Worker(h12());"); rejected(&source, "work limit"); } + +#[test] +fn awaited_helpers_and_if_return_unions() { + paths( + r#" + const WORKER_PATH = '/source/worker.ts'; + async function target() { + if (typeof WORKER_PATH !== 'undefined') return WORKER_PATH; + const dist = new URL('../x/worker.js', import.meta.url); + if (await exists(dist)) return dist; + return new URL('./worker.ts', import.meta.url); + } + const file = await target(); + new Worker(file); + "#, + &["/source/worker.ts", "../x/worker.js", "./worker.ts"], + ); + paths( + "async function entry() { return './worker.js'; } new Worker(await entry());", + &["./worker.js"], + ); + paths( + "const entry = async () => './worker.js'; new Worker(await entry());", + &["./worker.js"], + ); + paths("new Worker(await './worker.js');", &["./worker.js"]); + paths( + "function entry() { if (opaque()) return './a.js'; return './b.js'; } new Worker(entry());", + &["./a.js", "./b.js"], + ); + paths("function entry(path) { const file = path + '.js'; if (opaque()) { return file; } else { return './b.js'; } } new Worker(entry('./a'));", &["./a.js", "./b.js"]); +} + +#[test] +fn return_union_rejections_and_limits() { + rejected("async function entry() { if (await opaque()) return './a.js'; return opaque(); } new Worker(await entry());", "opaque call"); + rejected("async function entry() { if (true) return './a.js'; return await entry(); } new Worker(await entry());", "recursive"); + rejected( + "function entry() { if (opaque()) return './a.js'; } new Worker(entry());", + "fall through", + ); + rejected( + "function entry() { if (opaque()) return; return './a.js'; } new Worker(entry());", + "no effects or mutation", + ); + rejected( + "function entry() { const ignored = opaque(); return './a.js'; } new Worker(entry());", + "opaque call", + ); + rejected("function entry() { let path = './a.js'; if (path = './b.js') return './a.js'; return './b.js'; } new Worker(entry());", "no effects or mutation"); + rejected("function entry() { if (obj.x = true) return './a.js'; return './b.js'; } new Worker(entry());", "mutation"); + rejected("function entry() { const url = new URL('./a.js', import.meta.url); if (url.href = 'file:///b.js') return url; return './c.js'; } new Worker(entry());", "mutation"); + rejected("function entry() { if (opaque()) return './a.js'; return new URL('./b.js', import.meta.url); } new Worker('./prefix' + entry());", "URL string coercion"); + let branches = (0..=DYNAMIC_IMPORT_PATH_CAP) + .map(|n| format!("if (opaque()) return './w{n}.js';")) + .collect::(); + rejected( + &format!("function entry() {{ {branches} return './last.js'; }} new Worker(entry());"), + "candidate count", + ); + let branches = "if (opaque()) return './w.js';".repeat(WORK_LIMIT); + rejected( + &format!("function entry() {{ {branches} return './last.js'; }} new Worker(entry());"), + "work limit", + ); +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 67c7490997..026683081f 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -47,6 +47,7 @@ mod static_require_transform; mod tests; mod walk; mod wasm_asset; +mod worker; use binding_faithfulness::audit_native_binding_choice; pub(super) use discovery::is_nextjs_runtime_module; @@ -1008,7 +1009,7 @@ fn collect_module_one( )); return; } - if set.len() != 1 { + if eval_mode && set.len() != 1 { dyn_errors.push(format!( "worker_threads Worker in module {}: filename must resolve to exactly one path for now, got {}", module_name, @@ -1034,28 +1035,28 @@ fn collect_module_one( return; } } - } else if set[0].starts_with("file:") { - // Helper-returned URLs carry a URL spelling, while the - // module resolver (including --bunfs-root) consumes a - // filesystem spelling. Decode through the URL parser - // before recording both the import edge and Worker path. - match url::Url::parse(&set[0]) - .ok() - .and_then(|url| url.to_file_path().ok()) - { - Some(path) => set[0] = path.to_string_lossy().into_owned(), - None => { - dyn_errors.push(format!( - "worker_threads Worker in module {}: invalid file URL {:?}", - module_name, set[0] - )); + } + let imports = if eval_mode { + set.clone() + } else { + match worker::resolve_candidates( + &mut set, + entry_path, + &canonical, + &module_name, + ctx, + format, + ) { + Ok(imports) => imports, + Err(error) => { + dyn_errors.push(error); return; } } - } - for p in &set { - if !new_dyn_imports.contains(p) { - new_dyn_imports.push(p.clone()); + }; + for path in imports { + if !new_dyn_imports.contains(&path) { + new_dyn_imports.push(path); } } worker_path_sets.push(set); diff --git a/crates/perry/src/commands/compile/collect_modules/worker.rs b/crates/perry/src/commands/compile/collect_modules/worker.rs new file mode 100644 index 0000000000..6fd030050b --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/worker.rs @@ -0,0 +1,63 @@ +//! Resolve conservative Worker return unions before adding module edges. +use std::collections::HashSet; +use std::path::Path; + +use super::import_helpers::cached_resolve_import_with_lexical_base; +use super::{CompilationContext, OutputFormat}; + +pub(super) fn resolve_candidates( + paths: &mut Vec, + entry_path: &Path, + canonical: &Path, + module_name: &str, + ctx: &mut CompilationContext, + format: OutputFormat, +) -> Result, String> { + let mut imports = Vec::new(); + let mut entries = HashSet::new(); + for path in paths.iter_mut() { + if path.starts_with("file:") { + *path = url::Url::parse(path) + .ok() + .and_then(|url| url.to_file_path().ok()) + .ok_or_else(|| { + format!( + "worker_threads Worker in module {module_name}: invalid file URL {path:?}" + ) + })? + .to_string_lossy() + .into_owned(); + } + if let Some(resolved) = + cached_resolve_import_with_lexical_base(path, entry_path, canonical, ctx) + { + // Keep spelling aliases for runtime dispatch. Module discovery + // deduplicates these edges by canonical path, so each entry is + // compiled only once (e.g. a --define and a relative URL fallback). + if !imports.contains(path) { + imports.push(path.clone()); + } + if entries.insert(resolved.canonical_path.clone()) + && matches!(format, OutputFormat::Text) + { + eprintln!(" Worker entry: {}", resolved.canonical_path.display()); + } + } else if matches!(format, OutputFormat::Text) { + eprintln!( + " Warning: worker_threads Worker in module {module_name}: skipping candidate {path:?}: file not found" + ); + } + } + // Missing candidates never become import edges. Retain their spellings + // alongside valid ones so codegen cannot mistake a partial union for a + // proven single target: choosing a missing candidate must throw at runtime. + if imports.is_empty() { + paths.clear(); + if matches!(format, OutputFormat::Text) { + eprintln!( + " Warning: worker_threads Worker in module {module_name}: no existing candidates — this Worker will throw if constructed at runtime" + ); + } + } + Ok(imports) +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f57c5a8f96..5c5052e0b0 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2893,6 +2893,12 @@ pub fn run_with_parse_cache( HashMap::new(); for (path, hir_module) in &ctx.native_modules { let mut local_map: HashMap = HashMap::new(); + let mut worker_paths = HashSet::new(); + perry_hir::for_each_worker_new(hir_module, &mut |expr| { + if let perry_hir::Expr::WorkerNew { paths, .. } = expr { + worker_paths.extend(paths.iter().cloned()); + } + }); for import in &hir_module.imports { if !(import.is_dynamic || import.is_dynamic_target) { continue; @@ -2939,6 +2945,25 @@ pub fn run_with_parse_cache( None => continue, }; let target_prefix = sanitize_module_name(&target_name); + if worker_paths.contains(&import.source) { + // Preserve the lexical URL spelling, including .js -> .ts + // resolution and Bun virtual roots. Canonicalizing here would + // lose the href produced by new URL(path, import.meta.url). + let file_url = if Path::new(&import.source).is_absolute() { + url::Url::from_file_path(&import.source).ok() + } else { + url::Url::from_file_path(path) + .ok() + .and_then(|base| base.join(&import.source).ok()) + }; + if let Some(url) = file_url { + if let Ok(path) = url.to_file_path() { + local_map + .insert(path.to_string_lossy().into_owned(), target_prefix.clone()); + } + local_map.insert(url.to_string(), target_prefix.clone()); + } + } local_map.insert(import.source.clone(), target_prefix); } if !local_map.is_empty() { diff --git a/crates/perry/tests/issue_10236_worker_path_await_helper.rs b/crates/perry/tests/issue_10236_worker_path_await_helper.rs new file mode 100644 index 0000000000..f041c7e643 --- /dev/null +++ b/crates/perry/tests/issue_10236_worker_path_await_helper.rs @@ -0,0 +1,170 @@ +//! OpenCode's awaited TUI worker selector discovers a union of native entries. +use std::path::Path; +use std::process::{Command, Output}; + +fn diagnostics(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn compile(root: &Path, source: &str) -> String { + std::fs::write(root.join("main.ts"), source).unwrap(); + let worker = root.join("worker.ts"); + let define = format!( + "WORKER_PATH={}", + serde_json::to_string(&worker.to_string_lossy()).unwrap() + ); + let output = Command::new(env!("CARGO_BIN_EXE_perry")) + .current_dir(root) + .args([ + "compile", + "main.ts", + "-o", + "app", + "--platform", + "bun", + "--define", + ]) + .arg(define) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_NO_CACHE", "1") + .output() + .unwrap(); + let log = diagnostics(&output); + assert!(output.status.success(), "{log}"); + log +} + +fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("worker.ts"), "postMessage('ready');").unwrap(); + dir +} + +fn run(root: &Path, args: &[&str]) -> String { + let output = Command::new(root.join("app")) + .current_dir(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + output.status, + diagnostics(&output) + ); + String::from_utf8(output.stdout) + .unwrap() + .replace("\r\n", "\n") +} + +const START: &str = r#" +const worker = new Worker(file, { env: { WORKER_TEST: '10236' } }); +worker.onmessage = ({ data }) => { + console.log('reply', data); + worker.terminate().then(() => process.exit(0)); +}; +setTimeout(() => process.exit(2), 5000); +"#; + +#[test] +fn opencode_awaited_if_return_helper_skips_missing_and_deduplicates_entry() { + let dir = fixture(); + let source = format!( + r#" +import {{ existsSync as exists }} from 'node:fs'; +import {{ fileURLToPath }} from 'node:url'; +declare const WORKER_PATH: string; +async function target() {{ + if (typeof WORKER_PATH !== 'undefined') return WORKER_PATH; + const dist = new URL('../x/worker.js', import.meta.url); + if (await exists(fileURLToPath(dist))) return dist; + return new URL('./worker.ts', import.meta.url); +}} +const file = await target(); +{START} +"# + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("Worker path helper:"), "{log}"); + assert!(!log.contains("this Worker will throw"), "{log}"); + assert!( + log.contains("skipping candidate") && log.contains("../x/worker.js"), + "{log}" + ); + // Discovery can run twice when platform dependencies trigger recollection. + // The graph summary must still contain only main plus one worker. + assert!(log.contains("Found 2 module(s): 2 native"), "{log}"); + assert_eq!(run(dir.path(), &[]), "reply ready\n"); +} + +#[test] +fn sync_two_returns_dispatches_both_existing_entries_and_preserves_effects() { + let dir = fixture(); + std::fs::write(dir.path().join("other space.ts"), "postMessage('other');").unwrap(); + let source = format!( + r#" +function choose() {{ console.log('choose'); return process.argv.includes('--other'); }} +function target() {{ + if (choose()) return new URL('./other space.ts', import.meta.url); + return './worker.ts'; +}} +const file = target(); +{START} +"# + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("this Worker will throw"), "{log}"); + assert!(log.contains("Found 3 module(s): 3 native"), "{log}"); + assert_eq!(run(dir.path(), &[]), "choose\nreply ready\n"); + assert_eq!(run(dir.path(), &["--other"]), "choose\nreply other\n"); +} + +#[test] +fn await_literal_returning_async_helper() { + let dir = fixture(); + let source = format!( + "async function target() {{ return './worker.ts'; }} const file = await target(); {START}" + ); + let log = compile(dir.path(), &source); + assert!(!log.contains("Worker path helper:"), "{log}"); + assert_eq!(run(dir.path(), &[]), "reply ready\n"); +} + +#[test] +fn missing_runtime_selection_throws_instead_of_starting_another_candidate() { + let dir = fixture(); + let source = r#" +function target() { + if (process.argv.includes('--missing')) return './missing.ts'; + return './worker.ts'; +} +try { + new Worker(target()); + console.log('unexpected worker'); + process.exit(2); +} catch (error) { console.log('caught', error.message); } +"#; + let log = compile(dir.path(), source); + assert!(log.contains("skipping candidate"), "{log}"); + assert!(run(dir.path(), &["--missing"]) + .contains("did not match an existing compile-time-resolved worker entry")); +} + +#[test] +fn opaque_return_and_recursive_helper_keep_existing_warnings() { + for (helper, warning) in [ + ("async function target() { if (process.argv.length) return './worker.ts'; return opaque(); }", "opaque call target"), + ("async function target() { if (process.argv.length) return './worker.ts'; return await target(); }", "recursive helper call"), + ] { + let dir = fixture(); + let source = format!("const opaque: any = process.argv[0]; {helper} async function cold() {{ const file = await target(); new Worker(file); }} console.log('cold');"); + let log = compile(dir.path(), &source); + assert!(log.contains("Worker path helper:") && log.contains(warning), "{log}"); + assert!(log.contains("this Worker will throw"), "{log}"); + assert_eq!(run(dir.path(), &[]), "cold\n"); + } +}