diff --git a/.github/workflows/wasi-check.yml b/.github/workflows/wasi-check.yml new file mode 100644 index 00000000000..8acea25c8b8 --- /dev/null +++ b/.github/workflows/wasi-check.yml @@ -0,0 +1,70 @@ +name: WASI Check + +# Keeps `perry-runtime` building for the standalone WASI target +# (wasm32-wasip2). Tracking issue #11375; this gate is phase 2a (#11377). +# The check itself is `scripts/wasi_check.sh` — run it locally the same way. +# +# Checked against CLAUDE.md's "four ways a gate can be unable to fail": +# +# 1. no `continue-on-error`, no `|| true`: the script runs under +# `set -euo pipefail`, so a cargo failure is the job's failure. +# 2. NOT a required context, and not part of test.yml's `pr-gate` or +# `full-suite-gate`, by design of the change that adds it: a new gate has +# never been green, and WASI must not block unrelated PRs or releases +# while the target is being brought up. Promotion is a maintainer action +# after it has been green on `main`. +# 3. `concurrency` below cancels `pull_request` runs only; main-line runs are +# keyed on `github.run_id` (#7966) so they queue instead of superseding. +# 4. The subject is asserted live: the script fails unless cargo reports a +# perry-runtime artifact under the wasm32-wasip2 target directory, so a +# green run cannot be a host build or a silently skipped check. + +on: + pull_request: + # Opt-in via the `run-extended-tests` label, like the other satellite + # gates (docs/src/testing/ci-tiers.md), and only when something that can + # break the WASI build changed. + types: [opened, synchronize, reopened, labeled] + paths: + - "crates/perry-runtime/**" + - "crates/perry-dispatch/**" + - "Cargo.lock" + - "rust-toolchain.toml" + - "scripts/wasi_check.sh" + - ".github/workflows/wasi-check.yml" + # Main-line arm: one daily sweep, not one run per merge — see + # docs/src/testing/ci-gate-scheduling.md before adding `push: branches: [main]`. + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: wasi-check-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + wasi-check: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-extended-tests') + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install Rust toolchain with the WASI target + run: rustup toolchain install nightly-2026-08-20 --profile minimal --target wasm32-wasip2 + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: "${{ runner.os }}-wasi-check" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: perry-runtime builds for wasm32-wasip2 + run: ./scripts/wasi_check.sh diff --git a/changelog.d/11390-wasi-runtime-build.md b/changelog.d/11390-wasi-runtime-build.md new file mode 100644 index 00000000000..b3227b609ae --- /dev/null +++ b/changelog.d/11390-wasi-runtime-build.md @@ -0,0 +1,24 @@ +`perry-runtime` builds for the standalone WASI target, `wasm32-wasip2` (phase +2a of #11375, #11377). Every platform-specific path has a WASI branch, and on +every other target the code is unchanged. On WASI: + +- `child_process` fails through its ordinary spawn-error path (WASI has no + process spawning). +- Local time is UTC. +- `os.hostname()` is `"localhost"`. +- Native plugins fail to load with a clear message. +- `fs.symlink` uses WASI's own call. +- `parallelMap`/`parallelFilter` run sequentially. +- `perry/thread` `spawn()` rejects with "not supported on WASI yet". + +Timers ride the existing wasm32 park, which on single-threaded WASI is a timed +sleep. The exception trampoline `perry_sjlj.c` is not built for WASI until the +wasm EH link lands (#11378/#11379). `scripts/wasi_check.sh` checks the core +runtime and every default feature except the named WASI exclusions: + +- `bun-cli-utils`: zstd needs wasi-sdk; +- `mod-dgram`: turnloop; +- `alloc-mimalloc`: 64-bit only. + +The new `wasi-check` workflow runs it daily and on labelled PRs; it is +intentionally not a required context yet. diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 41809cca480..3069191eb4a 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -370,7 +370,6 @@ ryu.workspace = true ryu-js = "1.0.3" simdutf8 = "0.1.5" perry-base64.workspace = true -hostname = { version = "0.4", optional = true } dirs = { workspace = true, optional = true } # #6559 `dyn-eval`: reuse the compiler's own SWC-based parser for runtime- # generated `new Function` bodies (no second parser). perry-parser depends @@ -542,3 +541,8 @@ notify.workspace = true # are wired per agent (P3/P4). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] turnloop.workspace = true + +# `os.hostname()` under `full`. WASI has no hostname (the crate refuses the +# target), so `js_os_hostname` answers "localhost" there (#11377). +[target.'cfg(not(target_os = "wasi"))'.dependencies] +hostname = { version = "0.4", optional = true } diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs index 40baa369a89..74fe51afce8 100644 --- a/crates/perry-runtime/build.rs +++ b/crates/perry-runtime/build.rs @@ -568,13 +568,19 @@ fn main() { // miscompiled under LLVM's one-return assumption (stack-slot // coloring across the call). See the header comment in the C file. println!("cargo:rerun-if-changed=src/ffi/perry_sjlj.c"); - cc::Build::new() - .file("src/ffi/perry_sjlj.c") - // The trampoline is never unwound through (a raise targeting a - // generated frame is always innermost-above it), but keep CFI so - // debuggers and the unwind-table self-check can walk past it. - .flag_if_supported("-fasynchronous-unwind-tables") - .compile("perry_sjlj"); + // WASI (#11377): setjmp/longjmp on wasm needs the exception-handling + // proposal and wasi-libc's `libsetjmp`, which the `--target wasi` link + // wires up (#11378/#11379). Until then the trampoline is not built there, + // so a WASI link fails loudly on `perry_sjlj_try` rather than silently. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("wasi") { + cc::Build::new() + .file("src/ffi/perry_sjlj.c") + // The trampoline is never unwound through (a raise targeting a + // generated frame is always innermost-above it), but keep CFI so + // debuggers and the unwind-table self-check can walk past it. + .flag_if_supported("-fasynchronous-unwind-tables") + .compile("perry_sjlj"); + } println!( "cargo:rustc-env=PERRY_RUNTIME_TARGET={}", std::env::var("TARGET").expect("TARGET not set by Cargo") diff --git a/crates/perry-runtime/src/child_process/exec.rs b/crates/perry-runtime/src/child_process/exec.rs index 7ed352e59e5..96f8e15768a 100644 --- a/crates/perry-runtime/src/child_process/exec.rs +++ b/crates/perry-runtime/src/child_process/exec.rs @@ -60,6 +60,14 @@ pub extern "C" fn js_child_process_exec_sync( c.arg("/C").arg(&cmd_str); c }; + // WASI has no process spawning (#11377): std's `spawn` returns + // `Unsupported`, which surfaces through the ordinary spawn-error path. + #[cfg(target_os = "wasi")] + let mut command = { + let mut c = Command::new("/bin/sh"); + c.arg("-c").arg(&cmd_str); + c + }; cp_apply_options(&mut command, opts_val); let run_options = cp_read_sync_stdio_run_options(opts_val); @@ -296,6 +304,14 @@ pub extern "C" fn js_child_process_exec(cmd_ptr: *const StringHeader, arg1: f64, c.arg("/C").arg(&cmd_str); c }; + // WASI has no process spawning (#11377): std's `spawn` returns + // `Unsupported`, which surfaces through the ordinary spawn-error path. + #[cfg(target_os = "wasi")] + let mut command = { + let mut c = Command::new("/bin/sh"); + c.arg("-c").arg(&cmd_str); + c + }; cp_apply_options(&mut command, arg1); let run_options = cp_read_async_run_options(arg1); @@ -519,6 +535,14 @@ extern "C" fn cp_promisified_exec(_closure: *const ClosureHeader, cmd_val: f64, c.arg("/C").arg(&cmd); c }; + // WASI has no process spawning (#11377): std's `spawn` returns + // `Unsupported`, which surfaces through the ordinary spawn-error path. + #[cfg(target_os = "wasi")] + let mut command = { + let mut c = Command::new("/bin/sh"); + c.arg("-c").arg(&cmd); + c + }; cp_apply_options(&mut command, opts); cp_promisified_run(command, cmd, None, opts) } diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index a87108bf811..0301d5436f4 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -56,11 +56,13 @@ use stdin::CpStdin; /// call sites above). mod streams; pub(super) use stdin::CP_STDIN_HIGH_WATER_MARK; +pub(crate) use streams::cp_release_loop_streams; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use streams::on_stream_completion; use streams::{ cp_pipe_from_child_stderr, cp_pipe_from_child_stdout, cp_pipe_from_file, cp_spawn_reader, CpPipe, }; -pub(crate) use streams::{cp_release_loop_streams, on_stream_completion}; /// Monotonic registry key for live children. static CP_NEXT_LIVE_ID: AtomicU64 = AtomicU64::new(1); @@ -1366,7 +1368,9 @@ pub(crate) fn cp_reactor_pump() { // turnloop P2: a child's pipes are loop operations now, so their bytes // exist only once the loop has been turned. A caller that drives this pump // without parking — the `await` poll loop, and the lifecycle tests below — - // would otherwise spin against a queue nothing can fill. + // would otherwise spin against a queue nothing can fill. (wasm32 has no + // turnloop, so nothing is adopted and there is nothing to drain.) + #[cfg(not(target_arch = "wasm32"))] crate::turnloop_proc::drain_pending(); cp_reactor_pump_inner(); CP_PUMPING.with(|p| p.set(false)); diff --git a/crates/perry-runtime/src/child_process/reactor/streams.rs b/crates/perry-runtime/src/child_process/reactor/streams.rs index a2038ae7aa8..1998859d1f1 100644 --- a/crates/perry-runtime/src/child_process/reactor/streams.rs +++ b/crates/perry-runtime/src/child_process/reactor/streams.rs @@ -21,6 +21,10 @@ pub(super) enum CpPipe { Fd(std::os::fd::OwnedFd), #[cfg(windows)] Handle(std::os::windows::io::OwnedHandle), + /// WASI (#11377) cannot spawn a child, so no pipe ever reaches the loop; + /// the variant only keeps the reader plumbing well-typed. + #[cfg(target_os = "wasi")] + Reader(CpReader), } impl CpPipe { @@ -31,9 +35,12 @@ impl CpPipe { CpPipe::Fd(fd) => Box::new(std::fs::File::from(fd)) as CpReader, #[cfg(windows)] CpPipe::Handle(h) => Box::new(std::fs::File::from(h)) as CpReader, + #[cfg(target_os = "wasi")] + CpPipe::Reader(r) => r, } } + #[cfg(not(target_arch = "wasm32"))] fn into_transport(self) -> crate::turnloop_proc::adopt::Transport { match self { #[cfg(unix)] @@ -56,6 +63,10 @@ pub(super) fn cp_pipe_from_child_stdout(pipe: std::process::ChildStdout) -> CpPi { CpPipe::Handle(std::os::windows::io::OwnedHandle::from(pipe)) } + #[cfg(target_os = "wasi")] + { + CpPipe::Reader(Box::new(pipe)) + } } pub(super) fn cp_pipe_from_child_stderr(pipe: std::process::ChildStderr) -> CpPipe { @@ -67,6 +78,10 @@ pub(super) fn cp_pipe_from_child_stderr(pipe: std::process::ChildStderr) -> CpPi { CpPipe::Handle(std::os::windows::io::OwnedHandle::from(pipe)) } + #[cfg(target_os = "wasi")] + { + CpPipe::Reader(Box::new(pipe)) + } } pub(super) fn cp_pipe_from_file(file: std::fs::File) -> CpPipe { @@ -78,6 +93,10 @@ pub(super) fn cp_pipe_from_file(file: std::fs::File) -> CpPipe { { CpPipe::Handle(std::os::windows::io::OwnedHandle::from(file)) } + #[cfg(target_os = "wasi")] + { + CpPipe::Reader(Box::new(file)) + } } /// Stream a child's readable pipe into the event queue until EOF. @@ -95,6 +114,8 @@ pub(super) fn cp_pipe_from_file(file: std::fs::File) -> CpPipe { /// fallback reconstructs the reader from it rather than from a copy: there /// is exactly one owner at every instant. pub(super) fn cp_spawn_reader(handle: u64, pipe: CpPipe, fd: usize) { + // wasm32 has no turnloop (same gate as `crate::turnloop_proc`). + #[cfg(not(target_arch = "wasm32"))] if crate::turnloop_proc::available() { let transport = pipe.into_transport(); match crate::turnloop_proc::adopt_stream( @@ -149,6 +170,7 @@ fn cp_spawn_reader_thread(handle: u64, mut pipe: R, fd /// Remember which loop entry carries a child's stream, so it can be closed at /// EOF and at teardown. +#[cfg(not(target_arch = "wasm32"))] fn cp_record_loop_stream(handle: u64, fd: usize, id: u64) { if let Some(lc) = cp_live_lock().as_mut().and_then(|map| map.get_mut(&handle)) { lc.loop_streams.push((fd, id)); @@ -159,6 +181,7 @@ fn cp_record_loop_stream(handle: u64, fd: usize, id: u64) { } } +#[cfg(not(target_arch = "wasm32"))] fn cp_take_loop_stream(handle: u64, fd: usize) -> Option { let mut guard = cp_live_lock(); let lc = guard.as_mut()?.get_mut(&handle)?; @@ -172,6 +195,7 @@ fn cp_take_loop_stream(handle: u64, fd: usize) -> Option { /// become [`CpEvent::Data`], and EOF *or any read failure* becomes /// [`CpEvent::Eof`] — the thread's `Ok(0) | Err(_)` arm made no distinction /// either, and Node does not surface a read error on a child's stdout. +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn on_stream_completion( handle: u64, fd: usize, @@ -213,7 +237,11 @@ pub(crate) fn cp_release_loop_streams(handle: u64) { None => Vec::new(), } }; + // wasm32 never adopts a stream (no turnloop), so `ids` is always empty. + #[cfg(not(target_arch = "wasm32"))] for id in ids { crate::turnloop_proc::close(id); } + #[cfg(target_arch = "wasm32")] + debug_assert!(ids.is_empty()); } diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index dee8a589946..f71ac25c86f 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -244,6 +244,14 @@ fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i6 } } +/// WASI (#11377) has no time-zone database (wasi-libc's `localtime` is UTC), +/// so local time is UTC with a zero offset. +#[cfg(target_os = "wasi")] +fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i64) { + let (y, m, d, h, mi, s) = timestamp_to_components(secs); + (y, m, d, h, mi, s, 0) +} + #[cfg(windows)] fn timestamp_to_local_components(secs: i64) -> (i32, u32, u32, u32, u32, u32, i64) { unsafe { diff --git a/crates/perry-runtime/src/eh.rs b/crates/perry-runtime/src/eh.rs index 438333b04cc..b2a73267267 100644 --- a/crates/perry-runtime/src/eh.rs +++ b/crates/perry-runtime/src/eh.rs @@ -138,6 +138,10 @@ const UNWIND_DATA_REG: (c_int, c_int) = (0, 1); // RAX, RDX const UNWIND_DATA_REG: (c_int, c_int) = (0, 1); // R0/X0, R1/X1 #[cfg(target_arch = "x86")] const UNWIND_DATA_REG: (c_int, c_int) = (0, 2); // EAX, EDX + // WASI (#11377): clang's `__builtin_eh_return_data_regno` is 0/1 on + // WebAssembly, the pair wasm libunwind's `_Unwind_SetGR` understands. +#[cfg(all(target_arch = "wasm32", target_os = "wasi"))] +const UNWIND_DATA_REG: (c_int, c_int) = (0, 1); /// `PERRYJS\0` — vendor-tagged exception class. The personality is /// class-agnostic (every Perry landing pad is a catch-all), but the tag keeps @@ -241,16 +245,22 @@ pub unsafe extern "C" fn perry_eh_personality( let mut before: c_int = 0; let ip = _Unwind_GetIPInfo(context, &mut before); let region = _Unwind_GetRegionStart(context); - let mut info: libc::Dl_info = std::mem::zeroed(); - let name = if libc::dladdr(region as *const libc::c_void, &mut info) != 0 - && !info.dli_sname.is_null() - { - std::ffi::CStr::from_ptr(info.dli_sname) - .to_string_lossy() - .into_owned() - } else { - String::from("?") + #[cfg(not(target_os = "wasi"))] + let name = { + let mut info: libc::Dl_info = std::mem::zeroed(); + if libc::dladdr(region as *const libc::c_void, &mut info) != 0 + && !info.dli_sname.is_null() + { + std::ffi::CStr::from_ptr(info.dli_sname) + .to_string_lossy() + .into_owned() + } else { + String::from("?") + } }; + // WASI has no `dladdr` (#11377). + #[cfg(target_os = "wasi")] + let name = String::from("?"); eprintln!( "[perry-eh] personality actions={:#x} region={:#x} ({name}) ip=+{:#x} lpad={:?}", actions, diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index 91cf60bb170..97dcc063022 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -1809,6 +1809,8 @@ pub(crate) unsafe fn js_fs_symlink_result(target_value: f64, path_value: f64) -> let res = std::os::unix::fs::symlink(&target, &path); #[cfg(windows)] let res = std::os::windows::fs::symlink_file(&target, &path); + #[cfg(target_os = "wasi")] + let res = std::os::wasi::fs::symlink_path(&target, &path); match res { Ok(()) => Ok(()), Err(err) => Err(build_fs_error_value_with_dest( diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 9f93d1868ad..79dd6f9753e 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -11,6 +11,9 @@ #![recursion_limit = "256"] // Anchors are `#[used(compiler)]`: retained by rustc, not ld64 dead-strip roots. #![feature(used_with_arg)] +// WASI (#11377): `std::os::wasi::fs::symlink_path` for `fs.symlink`. +#![cfg_attr(target_os = "wasi", feature(wasi_ext))] +#![cfg_attr(all(target_os = "wasi", target_env = "p2"), feature(wasip2))] /// Issue #62: route every Rust heap allocation through mimalloc instead of /// the system `malloc`. `gc_malloc`, arena block allocation, Vec/HashMap diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index 8d4d0901ff4..45c2b4b9e0d 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -74,6 +74,12 @@ fn current_os_thread_id() -> u64 { } u64::from(unsafe { GetCurrentThreadId() }) } + // WASI preview 2 is single-threaded (#11377): the one thread is the main + // thread. Non-zero, because `MAIN_OS_THREAD_ID == 0` means "not recorded". + #[cfg(target_os = "wasi")] + { + 1 + } } static MAIN_OS_THREAD_ID: AtomicU64 = AtomicU64::new(0); diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 892e608fcc5..0526509d43a 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -140,7 +140,7 @@ pub extern "C" fn js_os_machine() -> *mut StringHeader { /// Get the hostname of the operating system #[no_mangle] pub extern "C" fn js_os_hostname() -> *mut StringHeader { - #[cfg(feature = "full")] + #[cfg(all(feature = "full", not(target_os = "wasi")))] { match hostname::get() { Ok(hostname) => { @@ -154,7 +154,8 @@ pub extern "C" fn js_os_hostname() -> *mut StringHeader { } } } - #[cfg(not(feature = "full"))] + // WASI has no hostname (#11377). + #[cfg(not(all(feature = "full", not(target_os = "wasi"))))] { let default = "localhost"; js_string_from_bytes(default.as_ptr(), default.len() as u32) diff --git a/crates/perry-runtime/src/plugin.rs b/crates/perry-runtime/src/plugin.rs index b5347846d9d..b9f810664bf 100644 --- a/crates/perry-runtime/src/plugin.rs +++ b/crates/perry-runtime/src/plugin.rs @@ -147,6 +147,29 @@ fn last_load_error(path: &str) -> String { format!("LoadLibraryW failed for {}: Win32 error {}", path, err) } +// WASI (#11377) has no dynamic loading: every load fails through the ordinary +// "load failed" path, so the other helpers are never reached with a handle. +#[cfg(target_os = "wasi")] +type RawLibHandle = *mut libc::c_void; + +#[cfg(target_os = "wasi")] +unsafe fn open_library(_path: &str) -> Option { + None +} + +#[cfg(target_os = "wasi")] +unsafe fn lookup_symbol(_handle: RawLibHandle, _name: &str) -> Option<*mut libc::c_void> { + None +} + +#[cfg(target_os = "wasi")] +unsafe fn close_library(_handle: RawLibHandle) {} + +#[cfg(target_os = "wasi")] +fn last_load_error(path: &str) -> String { + format!("cannot load native plugin {path}: WASI has no dynamic libraries") +} + struct PluginMetadata { name: String, version: String, diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 105c276fb14..68c8416a78a 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -1068,9 +1068,15 @@ unsafe fn parallel_map_impl(array_val: f64, closure_val: f64) -> i64 { let closure_ptr_raw = closure as i64; // ── 2. Determine thread count ──────────────────────────────────── + #[cfg(not(target_os = "wasi"))] let num_threads = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4); + // WASI preview 2 is single-threaded (#11377): always take the sequential + // path below (`available_parallelism` errors there, so the `4` fallback + // would otherwise try to spawn). + #[cfg(target_os = "wasi")] + let num_threads = 1; // Don't spawn more threads than elements let num_threads = num_threads.min(len); @@ -1336,10 +1342,14 @@ unsafe fn parallel_filter_impl(array_val: f64, closure_val: f64) -> i64 { // are behind us; no further GC points before the derefs below. let closure = closure_handle.get_raw_const_ptr::(); + #[cfg(not(target_os = "wasi"))] let num_threads = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) .min(len); + // WASI is single-threaded (#11377); see `parallel_map_impl`. + #[cfg(target_os = "wasi")] + let num_threads = 1; // Fast path: single thread for small arrays if num_threads <= 1 { @@ -1556,7 +1566,19 @@ pub extern "C" fn js_thread_spawn(closure_val: f64) -> f64 { f64::from_bits(POINTER_TAG | (promise as u64 & POINTER_MASK)) } +#[cfg_attr(target_os = "wasi", allow(unreachable_code, unused_variables))] unsafe fn spawn_impl(closure_val: f64) -> *mut crate::promise::Promise { + // WASI preview 2 has no threads (#11377). Running the worker body inline + // is not faithful — it claims and retires its own agent — so until a + // main-thread `spawn` lands with the WASI event loop, reject clearly + // instead of aborting on the failed `std::thread::spawn`. + #[cfg(target_os = "wasi")] + { + let msg = "perry/thread spawn() is not supported on WASI yet (#11377)"; + let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_error_new_with_message(s); + return crate::promise::js_promise_rejected(crate::value::js_nanbox_pointer(err as i64)); + } // ── 0. Extract closure pointer and func_ptr ────────────────────── let closure_bits = closure_val.to_bits(); let closure = (closure_bits & POINTER_MASK) as *const ClosureHeader; diff --git a/scripts/wasi_check.sh b/scripts/wasi_check.sh new file mode 100755 index 00000000000..bfef544cf89 --- /dev/null +++ b/scripts/wasi_check.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# `perry-runtime` must keep building for the standalone WASI target +# (wasm32-wasip2; tracking issue #11375, phase 2a #11377). Runs locally and in +# `.github/workflows/wasi-check.yml`: +# +# rustup target add wasm32-wasip2 +# ./scripts/wasi_check.sh +# +# Two builds: +# 1. `--no-default-features` — the core runtime. +# 2. Every DEFAULT feature except the ones WASI cannot build yet, listed in +# WASI_EXCLUDED with the reason. The list is derived from `cargo metadata` +# rather than restated here, so a new default feature is checked the day +# it lands instead of silently drifting out of coverage. +# +# Liveness: a green run must mean perry-runtime was actually checked FOR +# WASI, so each build asserts cargo reported a wasm32-wasip2 perry-runtime +# artifact. + +set -euo pipefail + +TARGET=wasm32-wasip2 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +# Default features WASI cannot build yet — each one must name its blocker. +WASI_EXCLUDED=( + # zstd-sys compiles C; needs the wasi-sdk sysroot that the `--target wasi` + # link brings (#11379). + bun-cli-utils + # dgram is built on turnloop, which is not compiled for wasm32; WASI sockets + # arrive with the WASI event-loop backend (phase 2b, #11377). + mod-dgram + # mimalloc's dependency is 64-bit-only (`cfg(target_pointer_width = "64")`), + # so on wasm32 this feature enables nothing — excluded to say so explicitly. + alloc-mimalloc +) + +features="$( + cargo metadata --format-version 1 --no-deps --locked | + python3 -c ' +import json, sys +excluded = set(sys.argv[1:]) +meta = json.load(sys.stdin) +pkg = next(p for p in meta["packages"] if p["name"] == "perry-runtime") +defaults = pkg["features"]["default"] +stale = excluded - set(defaults) +if stale: + sys.exit(f"WASI_EXCLUDED names features that are no longer default: {sorted(stale)}") +print(",".join(f for f in defaults if f not in excluded)) +' "${WASI_EXCLUDED[@]}" +)" + +# `cargo check` with JSON artifact messages; fails unless cargo reports the +# perry-runtime lib artifact built (or fresh) under the WASI target directory. +# Reads cargo's own report rather than guessing its on-disk layout, which +# moves between cargo versions. +check_for_wasi() { + cargo check --locked -p perry-runtime --target "$TARGET" \ + --message-format=json-render-diagnostics "$@" | + python3 -c ' +import json, sys +target = sys.argv[1] +live = False +for line in sys.stdin: + try: + msg = json.loads(line) + except ValueError: + continue + if (msg.get("reason") == "compiler-artifact" + and msg["target"]["name"] == "perry_runtime" + and any(f"/{target}/" in f for f in msg.get("filenames", []))): + live = True +if not live: + sys.exit(f"::error::cargo reported no {target} perry-runtime artifact; the check did not run for WASI") +' "$TARGET" +} + +echo "== perry-runtime, $TARGET, --no-default-features" +check_for_wasi --no-default-features + +echo "== perry-runtime, $TARGET, default features minus: ${WASI_EXCLUDED[*]}" +echo " features: $features" +check_for_wasi --no-default-features --features "$features" + +echo "OK: perry-runtime builds for $TARGET"