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
70 changes: 70 additions & 0 deletions .github/workflows/wasi-check.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
proggeramlug marked this conversation as resolved.
Resolved
# 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
24 changes: 24 additions & 0 deletions changelog.d/11390-wasi-runtime-build.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
20 changes: 13 additions & 7 deletions crates/perry-runtime/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions crates/perry-runtime/src/child_process/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 6 additions & 2 deletions crates/perry-runtime/src/child_process/reactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
28 changes: 28 additions & 0 deletions crates/perry-runtime/src/child_process/reactor/streams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)]
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -149,6 +170,7 @@ fn cp_spawn_reader_thread<R: Read + Send + 'static>(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));
Expand All @@ -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<u64> {
let mut guard = cp_live_lock();
let lc = guard.as_mut()?.get_mut(&handle)?;
Expand All @@ -172,6 +195,7 @@ fn cp_take_loop_stream(handle: u64, fd: usize) -> Option<u64> {
/// 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,
Expand Down Expand Up @@ -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());
}
8 changes: 8 additions & 0 deletions crates/perry-runtime/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 19 additions & 9 deletions crates/perry-runtime/src/eh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/fs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading