Skip to content
Closed
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
3 changes: 3 additions & 0 deletions changelog.d/10236-worker-path-await-helper.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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__<key>`), a native
/// builtin (`__native_mod__<name>`), or a compiled module (`<prefix>__init` +
Expand Down Expand Up @@ -488,6 +491,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
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)));
Expand Down
131 changes: 131 additions & 0 deletions crates/perry-codegen/src/expr/worker_new.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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))
})
}
129 changes: 114 additions & 15 deletions crates/perry-hir/src/dynamic_import/worker_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type Paths = Result<PathValues, String>;
#[derive(Clone)]
struct PathValues {
paths: Vec<String>,
// 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,
}

Expand Down Expand Up @@ -92,6 +94,7 @@ impl<V: Borrow<Expr>> 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) => {
Expand Down Expand Up @@ -295,33 +298,27 @@ impl<V: Borrow<Expr>> 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
.functions
.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| {
Expand All @@ -335,9 +332,6 @@ impl<V: Borrow<Expr>> 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();
Expand All @@ -351,7 +345,16 @@ impl<V: Borrow<Expr>> 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);
Expand All @@ -362,6 +365,102 @@ impl<V: Borrow<Expr>> 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<bool, String> {
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> {
Expand Down
Loading
Loading