diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 07715bc4..a2f75ded 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,7 +4,7 @@ description: Install system deps, Rust toolchain, WASI SDK, wasmtime, and config inputs: target: - description: Build target — "native" or "wasm32-wasip2" + description: 'Build target: "native", "wasm32-wasip2" or "wasm32-wasip3"' required: true runs: @@ -33,8 +33,17 @@ runs: shell: bash run: rustup target add wasm32-wasip2 + # The pinned toolchain ships no wasm32-wasip3 std, so the justfile builds that target with + # `cargo +nightly`. The pinned toolchain is still needed for the host-side test harnesses. + - name: Install nightly Rust toolchain with the wasm32-wasip3 target + if: inputs.target == 'wasm32-wasip3' + shell: bash + run: | + rustup toolchain install nightly --profile minimal --target wasm32-wasip3 + rustc +nightly --version + - name: Install WASI SDK - if: inputs.target == 'wasm32-wasip2' + if: inputs.target != 'native' shell: bash run: | sudo mkdir -p /opt/wasi-sdk @@ -43,7 +52,7 @@ runs: echo "WASI_SDK_PATH=/opt/wasi-sdk" >> "$GITHUB_ENV" - name: Install wasmtime - if: inputs.target == 'wasm32-wasip2' + if: inputs.target != 'native' shell: bash run: | curl -sSf https://wasmtime.dev/install.sh | bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f45d37e7..72000e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 - WASI_SDK_VERSION: "33" + WASI_SDK_VERSION: "34" RUSTC_WRAPPER: sccache CCACHE: sccache # Bump to purge the sccache GHA cache. @@ -27,7 +27,7 @@ env: jobs: # --------------------------------------------------------------------------- - # Cargo tests: {native, wasm32-wasip2} × {debug+debugmozjs, release} + # Cargo tests: {native, wasm32-wasip2, wasm32-wasip3} × {debug+debugmozjs, release} # The wasm recipes also run the serve end-to-end suite (`just test-serve-wasm`). # --------------------------------------------------------------------------- test: @@ -35,15 +35,19 @@ jobs: strategy: fail-fast: false matrix: - target: [native, wasm32-wasip2] + target: [native, wasm32-wasip2, wasm32-wasip3] profile: [debug, release] include: - { target: native, profile: debug, recipe: test } - { target: native, profile: release, recipe: test-release } - { target: wasm32-wasip2, profile: debug, recipe: test-wasm } - { target: wasm32-wasip2, profile: release, recipe: test-wasm-release } + - { target: wasm32-wasip3, profile: debug, recipe: test-wasm } + - { target: wasm32-wasip3, profile: release, recipe: test-wasm-release } runs-on: ubuntu-latest env: + # Only the wasm recipes read this. + WASM_TARGET: ${{ matrix.target }} WASMTIME_BACKTRACE_DETAILS: '1' RUST_BACKTRACE: 'full' steps: @@ -57,20 +61,21 @@ jobs: run: just ${{ matrix.recipe }} - name: Run example tests - if: matrix.target == 'wasm32-wasip2' + if: matrix.target != 'native' run: just ${{ matrix.recipe }} --examples # --------------------------------------------------------------------------- - # WPT suite: {native, wasm32-wasip2} + # WPT suite: {native, wasm32-wasip2, wasm32-wasip3} # --------------------------------------------------------------------------- wpt: name: WPT (${{ matrix.target }}) strategy: fail-fast: false matrix: - target: [native, wasm32-wasip2] + target: [native, wasm32-wasip2, wasm32-wasip3] runs-on: ubuntu-latest env: + WASM_TARGET: ${{ matrix.target }} WASMTIME_BACKTRACE_DETAILS: '1' RUST_BACKTRACE: 'full' steps: @@ -93,7 +98,7 @@ jobs: # The recipes build the runtime before running the harness. - name: Run WPT tests run: | - if [[ "${{ matrix.target }}" == "wasm32-wasip2" ]]; then + if [[ "${{ matrix.target }}" != "native" ]]; then just wpt-test-wasm -v else just wpt-test -v diff --git a/Cargo.lock b/Cargo.lock index 99ce5a9f..8f0c79a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ dependencies = [ "futures-lite", "js", "oxc_resolver", + "platform", "shlex 2.0.1", "tempfile", "tokio", @@ -1521,15 +1522,12 @@ dependencies = [ ] [[package]] -name = "libz-sys" -version = "1.1.29" +name = "libz-rs-sys" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "03dcace986b149f29509af6ca70e6182bccce916b644424ecf484faa8ddc899a" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "zlib-rs", ] [[package]] @@ -1570,13 +1568,13 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "macro-string" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -1747,7 +1745,7 @@ dependencies = [ "flate2", "icu_capi", "libc", - "libz-sys", + "libz-rs-sys", "mozjs_collator_glue", "mozjs_icu_provider_glue", "mozjs_locale_glue", @@ -1990,12 +1988,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.37" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -2926,12 +2924,6 @@ dependencies = [ "ryu", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "walkdir" version = "2.5.0" @@ -2959,9 +2951,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip3" -version = "0.8.0+wasi-0.3.0" +version = "0.9.0+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a999271e77c083825863fc85404197523b3f3cebe9b402aece658ebea90065" +checksum = "f1d5749fdf69bb9400562eba50f8f25f5cd800ef4b74300038b6f99ae7409674" dependencies = [ "bytes", "http", @@ -3027,9 +3019,9 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3" dependencies = [ "leb128fmt", "wasmparser", @@ -3037,9 +3029,9 @@ dependencies = [ [[package]] name = "wasm-metadata" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a11585adb92fe9b55ad1d760e8d8fb5d87e0d2e303cb8eed57f078d54293a2" +checksum = "4e7bf119eb01f4a246864c10bd87c8f26c566350abb25aa4d5273c415bbab338" dependencies = [ "anyhow", "indexmap", @@ -3062,9 +3054,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" dependencies = [ "bitflags", "hashbrown 0.17.1", @@ -3099,6 +3091,7 @@ dependencies = [ "encoding_rs", "js", "libstarling", + "platform", "url", ] @@ -3367,9 +3360,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "wit-bindgen" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfef9403167686bfe35ffa8787df4c0f50742e5b0f471c7fb156a6614193c5d" +checksum = "53cb4b5556c3a791e86838ea287782bdafa704d55b0e68b5b81a3a16b9ea5f4b" dependencies = [ "bitflags", "futures", @@ -3378,9 +3371,9 @@ dependencies = [ [[package]] name = "wit-bindgen-core" -version = "0.61.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87445680dfe6d6b5369e884bd63c03a3335268e855365b2fc3f7bcdce96a630e" +checksum = "72ad22a37ecbc0e1fdca34d2b670ba41368cb0920735643e00fdf39a16ee5431" dependencies = [ "anyhow", "heck", @@ -3389,15 +3382,15 @@ dependencies = [ [[package]] name = "wit-bindgen-rust" -version = "0.61.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f2fe494de0d898216caf0fd0ae76af75753fcb3ff4f465b46131537cf24cf8" +checksum = "84ba5213d4332c260a0d4e69e34b0f7e4b85ddae10b6ebfbac00d9c185ba628a" dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", - "syn 2.0.119", + "syn 3.0.5", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3405,25 +3398,25 @@ dependencies = [ [[package]] name = "wit-bindgen-rust-macro" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3521448f8b53272f9aa1caf134e8f008ce13a2b43b832672435c0c5328b01077" +checksum = "8f19d63da8e478a370ef8192d90ccb517965b7747d6b46cd2d831dd0ef4d2541" dependencies = [ "anyhow", "macro-string", "prettyplease", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wit-bindgen-core", "wit-bindgen-rust", ] [[package]] name = "wit-component" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "481b5c47b2ecce0389b5e08a05557d6a190c9cd761773b8880a8017ee04dc7ef" +checksum = "092f783dee3253265fcde131475055f8c275ac0176a9c0396db1dc7b5544bfb4" dependencies = [ "anyhow", "bitflags", @@ -3440,9 +3433,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4daaa3cd97ae49ecd0a99dc009d453f93e0f083dd3be38c0f24a83a93e37ac" +checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7" dependencies = [ "anyhow", "hashbrown 0.17.1", diff --git a/Cargo.toml b/Cargo.toml index de30da53..0105f038 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,8 +47,9 @@ starling-macro = { path = "crates/starling-macro" } tokio = { version = "1", features = ["rt", "time", "net", "io-util"] } url = "2.5.8" uuid = { version = "1.23", features = ["v4"] } -wasip3 = { version = "0.8.0", features = ["http-compat"] } -wit-bindgen = { version = "=0.61.0", features = ["macros"] } +wasip3 = { version = "0.9.0", features = ["http-compat"] } +# Must equal the `wit-bindgen` version `wasip3` depends on. See `starling/Cargo.toml`. +wit-bindgen = { version = "=0.62.0", features = ["macros"] } wpt-support = { path = "crates/wpt-support" } # bindgen = { version = "0.72", default-features = false, features = [ diff --git a/README.md b/README.md index afbe8727..d875eb65 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ JS modules, and functions and properties on the global object. StarlingMonkey runs `.js` and `.mjs` files as ES modules by default: ```bash -starling script.js +starlingmonkey script.js ``` ES module features work out of the box — `import`/`export`, strict mode, and @@ -56,19 +56,19 @@ console.log(greet("world")); ``` ```bash -starling main.js +starlingmonkey main.js ``` For quick one-liners, use `-e`: ```bash -starling -e 'console.log("hello")' +starlingmonkey -e 'console.log("hello")' ``` For legacy scripts that rely on sloppy mode or a global `this`: ```bash -starling --legacy-script old-code.js +starlingmonkey --legacy-script old-code.js ``` --- @@ -93,7 +93,7 @@ on both native and `wasm32-wasip2` targets. ## CLI Reference ``` -starling [OPTIONS] [SCRIPT_PATH] +starlingmonkey [OPTIONS] [SCRIPT_PATH] Arguments: [SCRIPT_PATH] Path to the entry JS/MJS file (default: ./index.js) @@ -617,7 +617,7 @@ for futures resolving to a value. - [Rust toolchain](./rust-toolchain.toml) - [just](https://github.com/casey/just) -- [WASI-SDK 33](https://github.com/WebAssembly/wasi-sdk/releases/tag/wasi-sdk-33) +- [WASI-SDK 34](https://github.com/WebAssembly/wasi-sdk/releases/tag/wasi-sdk-34) - [Node.js](https://nodejs.org/), to run the WPT harness Checking, building, and testing is done using a [`justfile`](justfile). @@ -645,6 +645,24 @@ just test-wasm # all Rust tests, on wasm32-wasip2 just check-wasm # fmt-check + clippy + wasm tests ``` +`WASM_TARGET` retargets those recipes and the wasm WPT recipes. It accepts `p2`, `p3`, or a +full triple, and defaults to `p2`: + +```bash +WASM_TARGET=p3 just test-wasm +WASM_TARGET=p3 just wpt-test-wasm +``` + +`rust-toolchain.toml` pins a toolchain that ships no wasm32-wasip3 std, so `p3` builds through +`cargo +nightly`, and `p3` needs wasi-sdk 34, the first with a wasm32-wasip3 sysroot. + +The package builds two targets. `cargo build` produces the native binary +`target/debug/starlingmonkey`. A wasm build produces the component +`target/wasm32-wasip2/debug/starling.wasm` from the crate's `cdylib` target. The binary is +not the wasm entry point, because a binary links `crt1-command.o`, which exports +`wasi:cli/run` itself and collides with the component's own export on wasm32-wasip3. It is +still built on wasm targets, where it does nothing. + --- ## Web Platform Tests (WPT) diff --git a/crates/builtins/cpp-builtins/Cargo.toml b/crates/builtins/cpp-builtins/Cargo.toml index 3cb0e5d9..3b4d5c1d 100644 --- a/crates/builtins/cpp-builtins/Cargo.toml +++ b/crates/builtins/cpp-builtins/Cargo.toml @@ -11,7 +11,13 @@ debugmozjs = [] [dependencies] js.workspace = true -mozjs = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main" } +# Feature-matched to the `js` crate: cargo unifies features across the graph, so leaving mozjs's +# defaults on here would pull `libz-sys` back in for every build. +mozjs = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main", default-features = false, features = [ + "jit", + "libz-rs", + "intl", +] } mozjs_sys = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main" } [build-dependencies] diff --git a/crates/builtins/web-globals/Cargo.toml b/crates/builtins/web-globals/Cargo.toml index b4f00536..ac69bc70 100644 --- a/crates/builtins/web-globals/Cargo.toml +++ b/crates/builtins/web-globals/Cargo.toml @@ -12,6 +12,7 @@ core-runtime.workspace = true data-url.workspace = true encoding_rs.workspace = true js.workspace = true +platform.workspace = true url.workspace = true [dev-dependencies] diff --git a/crates/builtins/web-globals/src/events/algorithms.rs b/crates/builtins/web-globals/src/events/algorithms.rs index b515a5ec..76e6374c 100644 --- a/crates/builtins/web-globals/src/events/algorithms.rs +++ b/crates/builtins/web-globals/src/events/algorithms.rs @@ -32,7 +32,7 @@ pub(crate) struct ListenerOptions<'s> { pub(crate) signal: Option>, } -thread_local! { +js::instance_local! { /// Monotonic source of event-listener ids. /// Would theoretically overflow after 2^64 listeners, but that's not a realistic concern. static NEXT_LISTENER_ID: Cell = const { Cell::new(0) }; diff --git a/crates/builtins/web-globals/src/performance.rs b/crates/builtins/web-globals/src/performance.rs index 177e40a9..873fdcc5 100644 --- a/crates/builtins/web-globals/src/performance.rs +++ b/crates/builtins/web-globals/src/performance.rs @@ -5,8 +5,6 @@ //! Implements [Performance] API from the High Resolution Time specification //! The time origin is the moment the runtime was initialized with the Performance Global -use std::time::Instant; - use core_runtime::{webidl_interface, webidl_methods}; use js::error::ExnThrown; use js::gc::scope::Scope; @@ -16,41 +14,59 @@ use crate::events::event_target::{EventTarget, EventTargetImpl}; #[derive(Clone, Copy)] struct TimeOriginSnapshot { - /// The monotonic clock instant when the time origin was captured. - instant: Instant, - /// ECMA-262 timestamp (ms since Unix epoch) at the time of the snapshot. - /// Used to compute `timeOrigin` that is stable against system clock changes. + /// The monotonic clock reading, in nanoseconds, when the time origin was captured. + monotonic_ns: u64, + /// ECMA-262 timestamp (ms since Unix epoch) for the same moment. Used to compute `timeOrigin` + /// that is stable against system clock changes. epoch_ms: f64, } -/// Held behind a lock rather than in a `OnceLock` because it has to be *replaceable*: a time -/// origin cannot survive a Wizer snapshot. Its monotonic instant belongs to the process that took -/// the snapshot, while a resumed instance starts a fresh monotonic clock — so the origin sits in -/// that instance's future, and `now()` saturates to zero for as long as it takes to catch up. -/// [`reset_time_origin`] re-establishes it on resume. +/// Held behind a lock rather than in a `OnceLock` because it has to be replaceable: an origin +/// captured before a Wizer snapshot holds the wall-clock time of the machine that took the +/// snapshot, which bears no relation to when the resumed instance runs. [`reset_time_origin`] +/// re-establishes that half on resume. The monotonic half stays as captured, since +/// [`platform::clock`](platform::clock) already puts a resumed instance's readings past it. static TIME_ORIGIN: std::sync::RwLock> = std::sync::RwLock::new(None); -fn capture_time_origin() -> TimeOriginSnapshot { - let instant = Instant::now(); - let epoch_ms = std::time::SystemTime::now() +/// The current wall-clock time as an ECMA-262 timestamp (ms since Unix epoch). +fn epoch_ms_now() -> f64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock is before Unix epoch") .as_secs_f64() - * 1_000.; - TimeOriginSnapshot { instant, epoch_ms } + * 1_000. +} + +fn capture_time_origin() -> TimeOriginSnapshot { + TimeOriginSnapshot { + monotonic_ns: platform::clock::monotonic_ns(), + epoch_ms: epoch_ms_now(), + } +} + +/// Milliseconds elapsed since `origin` was captured. +fn elapsed_ms(origin: &TimeOriginSnapshot) -> f64 { + platform::clock::monotonic_ns().saturating_sub(origin.monotonic_ns) as f64 / 1_000_000. } fn init_time_origin() { let _ = time_origin(); } -/// Re-establish the time origin at the current instant. +/// Re-anchor the time origin's wall clock to the instance running now. /// -/// For an embedder resuming a pre-initialized (Wizer) snapshot: the origin captured while the -/// snapshot was taken is meaningless in the resumed instance, so it is re-taken once execution -/// really begins. Idempotent, and a no-op for anyone who never snapshots. +/// A pre-initialized (Wizer) snapshot includes an origin captured while the snapshot was taken, so +/// `timeOrigin` would report the wall-clock time of whoever took it. The origin's monotonic half +/// stays valid, since a resumed instance reads the clock through an offset past everything the +/// snapshot holds. Only the wall clock is re-read, from the elapsed time that monotonic half +/// gives, so `timeOrigin + performance.now()` is the current time and `now()` counts on across the +/// snapshot rather than restarting from zero. A no-op for anyone who never snapshots. pub fn reset_time_origin() { - *TIME_ORIGIN.write().expect("time origin lock poisoned") = Some(capture_time_origin()); + let mut origin = TIME_ORIGIN.write().expect("time origin lock poisoned"); + match origin.as_mut() { + Some(snapshot) => snapshot.epoch_ms = epoch_ms_now() - elapsed_ms(snapshot), + None => *origin = Some(capture_time_origin()), + } } fn time_origin() -> TimeOriginSnapshot { @@ -70,7 +86,7 @@ pub struct Performance { } pub fn now() -> f64 { - time_origin().instant.elapsed().as_secs_f64() * 1_000. + elapsed_ms(&time_origin()) } #[webidl_methods] diff --git a/crates/builtins/web-globals/src/signals/algorithms.rs b/crates/builtins/web-globals/src/signals/algorithms.rs index 167f2fa6..9374f487 100644 --- a/crates/builtins/web-globals/src/signals/algorithms.rs +++ b/crates/builtins/web-globals/src/signals/algorithms.rs @@ -2,7 +2,8 @@ //! Abort signal algorithms from . -use std::time::{Duration, Instant}; +use platform::clock::Instant; +use std::time::Duration; use core_runtime::event_loop::{with_active_event_loop, Task, TaskId}; use js::conversion::ToJSVal; diff --git a/crates/builtins/web-globals/src/worker_location.rs b/crates/builtins/web-globals/src/worker_location.rs index 42a31777..e1a0ce3f 100644 --- a/crates/builtins/web-globals/src/worker_location.rs +++ b/crates/builtins/web-globals/src/worker_location.rs @@ -16,8 +16,9 @@ use js::error::TypeError; use js::gc::scope::Scope; use js::Object; -thread_local! { - /// The URL backing `globalThis.location`. +js::instance_local! { + /// The URL backing `globalThis.location`. Set once before the runtime is initialized and read + /// by every accessor after, so it is scoped to the runtime rather than to the call that set it. static LOCATION_URL: RefCell> = const { RefCell::new(None) }; } diff --git a/crates/core-runtime/Cargo.toml b/crates/core-runtime/Cargo.toml index ed502285..0de47f8f 100644 --- a/crates/core-runtime/Cargo.toml +++ b/crates/core-runtime/Cargo.toml @@ -17,6 +17,7 @@ event-listener.workspace = true futures-lite = "2" js.workspace = true oxc_resolver.workspace = true +platform.workspace = true shlex = "2" tempfile = "3" diff --git a/crates/core-runtime/src/config.rs b/crates/core-runtime/src/config.rs index 50b90524..0b5e94a0 100644 --- a/crates/core-runtime/src/config.rs +++ b/crates/core-runtime/src/config.rs @@ -470,7 +470,7 @@ impl Default for RuntimeConfig { // origin/mode enforcement) rather than HTTP correctness rules. In non-browser // contexts these policies are usually unwanted, so they're disabled by default. -thread_local! { +js::instance_local! { static ENFORCE_REQUEST_RESTRICTIONS: Cell = const { Cell::new(false) }; } diff --git a/crates/core-runtime/src/event_loop.rs b/crates/core-runtime/src/event_loop.rs index 2ca70b1c..cd6b9670 100644 --- a/crates/core-runtime/src/event_loop.rs +++ b/crates/core-runtime/src/event_loop.rs @@ -52,10 +52,11 @@ pub mod interest; pub mod timer; +use platform::clock::Instant; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; -use std::time::{Duration, Instant}; +use std::time::Duration; use event_listener::{Event, EventListener}; use js::error::ExnThrown; @@ -70,7 +71,7 @@ pub use interest::{InterestHandle, InterestTracker}; // Current event loop pointer // --------------------------------------------------------------------------- -thread_local! { +js::instance_local! { /// Pointer to the active [`EventLoop`], set by [`with_event_loop`] for the /// duration of its closure. `JSNative` callbacks (`setTimeout` etc.) have /// no other way to reach Rust state, so they read this pointer via @@ -228,6 +229,9 @@ struct TaskEntry { ready: bool, /// For timer tasks: the `Instant` at which this task becomes ready. /// `None` for non-timer tasks. + /// + /// The reading comes from `platform::clock`, not `std::time`, so a deadline + /// taken before a Wizer snapshot is already past when the instance resumes. deadline: Option, } diff --git a/crates/core-runtime/src/event_loop/timer.rs b/crates/core-runtime/src/event_loop/timer.rs index fc7632e3..fa67ef44 100644 --- a/crates/core-runtime/src/event_loop/timer.rs +++ b/crates/core-runtime/src/event_loop/timer.rs @@ -12,7 +12,8 @@ //! `clearTimeout`, and `clearInterval` to a global object. These functions //! interact with the event loop via [`with_active_event_loop`]. -use std::time::{Duration, Instant}; +use platform::clock::Instant; +use std::time::Duration; use js::conversion::ToJSVal; use js::error::{throw_error, ExnThrown}; @@ -37,7 +38,7 @@ enum TimerHandler { Code(String), } -thread_local! { +js::instance_local! { /// The HTML "timer nesting level" of the timer task currently running on this /// thread (0 outside timer tasks). The timer initialization steps read it to /// clamp deeply nested zero-delay timers, and each fired timer's handler runs diff --git a/crates/core-runtime/src/runtime.rs b/crates/core-runtime/src/runtime.rs index ac9c555d..f49a6859 100644 --- a/crates/core-runtime/src/runtime.rs +++ b/crates/core-runtime/src/runtime.rs @@ -35,7 +35,7 @@ use js::{ /// Registered initializers are called during `Runtime::new_global()`. type GlobalInitFn = for<'a> fn(&'a Scope<'a>, Object<'a>); -thread_local! { +js::instance_local! { /// The runtime whose SpiderMonkey callbacks are installed on this thread. /// /// A raw pointer, so this key has no destructor and takes no part in @@ -57,7 +57,7 @@ pub(crate) fn current<'a>() -> Option<&'a Runtime> { (!rt.is_null()).then(|| unsafe { &*rt }) } -thread_local! { +js::instance_local! { static GLOBAL_INITIALIZERS: RefCell> = const { RefCell::new(Vec::new()) }; } @@ -81,7 +81,7 @@ pub fn register_global_initializer(init: GlobalInitFn) { /// Callback type for re-establishing state that cannot survive a snapshot. type ResumeFixupFn = fn(); -thread_local! { +js::instance_local! { static RESUME_FIXUPS: RefCell> = const { RefCell::new(Vec::new()) }; } diff --git a/crates/core-runtime/tests/event_loop.rs b/crates/core-runtime/tests/event_loop.rs index 4a6dc3da..7b64375a 100644 --- a/crates/core-runtime/tests/event_loop.rs +++ b/crates/core-runtime/tests/event_loop.rs @@ -14,9 +14,10 @@ use js::gc::scope::Scope; use js::native::JSTracer; use js::error::ExnThrown; +use platform::clock::Instant; use std::cell::Cell; use std::rc::Rc; -use std::time::{Duration, Instant}; +use std::time::Duration; /// Helper: create a tokio current-thread runtime with timer support and /// block on `run_to_completion` using `tokio::time::sleep`. diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index b53d93af..775c2276 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -22,7 +22,14 @@ workspace = true [dependencies] indexmap = "2.13" -mozjs = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main" } +# `libz-rs` (pure Rust) rather than mozjs's default `libz-sys`, whose C zlib has no +# wasm32-wasip3 build. `jit` and `intl` are mozjs defaults that `default-features = false` would +# otherwise drop, and dropping `intl` would take ICU out of every build. +mozjs = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main", default-features = false, features = [ + "jit", + "libz-rs", + "intl", +] } mozjs_sys = { git = "https://github.com/tschneidereit/mozjs.git", branch = "main" } num-traits = "0.2" starling-macro.workspace = true diff --git a/crates/js/src/class.rs b/crates/js/src/class.rs index 7e6ff871..9ce1b55e 100644 --- a/crates/js/src/class.rs +++ b/crates/js/src/class.rs @@ -11,8 +11,10 @@ use std::any::TypeId; #[cfg(debug_assertions)] use std::cell::Cell; use std::cell::RefCell; +use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use std::ffi::{c_void, CStr, CString}; +use std::hash::BuildHasherDefault; use std::marker::PhantomData; use std::os::raw::c_char; use std::ptr::{self, NonNull}; @@ -945,13 +947,45 @@ unsafe fn get_or_init_class_registry(global: *mut JSObject) -> &'static mut Clas let registry = Box::into_raw(Box::new(ClassRegistry::new())); let pv = value::from_private(registry as *const c_void); crate::object::set_reserved_slot(global, CLASS_REGISTRY_SLOT, &pv); - LIVE_REGISTRIES.with(|regs| regs.borrow_mut().push(registry)); + live_registries(|regs| regs.borrow_mut().push(registry)); &mut *registry } -thread_local! { +/// This module's share of the crate's thread-local state. See [`crate::tls`]. +pub(crate) struct ClassTls { /// All live `ClassRegistry` boxes on this thread, traced by [`trace_class_registries`]. - static LIVE_REGISTRIES: RefCell> = const { RefCell::new(Vec::new()) }; + live_registries: RefCell>, + /// Child-to-parent derivations, keyed by the child's runtime `JSClass` tag. + inheritance: RefCell>, + /// Per-`ClassDef` property and method tables, built once each. + spec_tables: RefCell>, +} + +/// A map over keys this crate generates itself, so it needs no random seeding. +/// `BuildHasherDefault` is const-constructible, which keeps [`crate::tls`]'s key +/// `const`-initialized. +type TagMap = HashMap>; + +impl ClassTls { + pub(crate) const fn new() -> Self { + Self { + live_registries: RefCell::new(Vec::new()), + inheritance: RefCell::new(HashMap::with_hasher(BuildHasherDefault::new())), + spec_tables: RefCell::new(HashMap::with_hasher(BuildHasherDefault::new())), + } + } +} + +fn live_registries(f: impl FnOnce(&RefCell>) -> R) -> R { + crate::tls::with(|tls| f(&tls.class.live_registries)) +} + +fn inheritance_registry(f: impl FnOnce(&RefCell>) -> R) -> R { + crate::tls::with(|tls| f(&tls.class.inheritance)) +} + +fn spec_tables_map(f: impl FnOnce(&RefCell>) -> R) -> R { + crate::tls::with(|tls| f(&tls.class.spec_tables)) } /// Trace each global's class registry, rooting prototype and shared-function objects. @@ -961,7 +995,7 @@ thread_local! { /// /// `trc` must be a valid `JSTracer` provided by SpiderMonkey's GC. pub(crate) unsafe extern "C" fn trace_class_registries(trc: *mut JSTracer, _data: *mut c_void) { - LIVE_REGISTRIES.with(|regs| { + live_registries(|regs| { // `as_ptr` bypasses RefCell borrow tracking: GC tracing pauses JS execution, so a borrow // held by interrupted code isn't actively used while the list is read here. let regs = &*regs.as_ptr(); @@ -1022,7 +1056,7 @@ unsafe extern "C" fn finalize_starling_global(gc: *mut GCContext, obj: *mut JSOb (glue.finalize)(gc, obj); } // Unregister from the tracer's list before freeing. - LIVE_REGISTRIES.with(|regs| regs.borrow_mut().retain(|&r| r != ptr)); + live_registries(|regs| regs.borrow_mut().retain(|&r| r != ptr)); drop(Box::from_raw(ptr)); // Clear the slot so we don't double-free. let undef = value::undefined(); @@ -1118,7 +1152,7 @@ struct InheritanceInfo { } pub(crate) fn inherits_from(concrete_tag: usize, target_tag: usize) -> bool { - INHERITANCE_REGISTRY.with(|reg| { + inheritance_registry(|reg| { let map = reg.borrow(); map.get(&concrete_tag) .is_some_and(|info| info.ancestors.contains(&target_tag)) @@ -1127,10 +1161,6 @@ pub(crate) fn inherits_from(concrete_tag: usize, target_tag: usize) -> bool { // Registry mapping child type tag → parent info. // Thread-local because the SpiderMonkey runtime is single-threaded. -thread_local! { - static INHERITANCE_REGISTRY: RefCell> = RefCell::new(HashMap::new()); -} - /// Register a child→parent derivation in the inheritance registry, keyed by the /// child's runtime `JSClass` tag. /// @@ -1143,7 +1173,7 @@ fn register_parent_info_raw( accessor: unsafe fn(*const c_void) -> *const c_void, accessor_mut: unsafe fn(*mut c_void) -> *mut c_void, ) { - INHERITANCE_REGISTRY.with(|reg| { + inheritance_registry(|reg| { let mut map = reg.borrow_mut(); // Build the ancestor set: parent + parent's ancestors (if any). @@ -1271,7 +1301,7 @@ pub unsafe fn get_private_or_ancestor<'a, T: ClassDef>(obj: *mut JSObject) -> Op // Walk the parent chain let data_ptr = get_raw_private(obj)?; - INHERITANCE_REGISTRY.with(|reg| { + inheritance_registry(|reg| { let map = reg.borrow(); let mut current_tag = concrete_tag; let mut current_ptr = data_ptr; @@ -1310,7 +1340,7 @@ pub unsafe fn get_private_or_ancestor_mut<'a, T: ClassDef>( } let data_ptr = get_raw_private(obj)? as *mut c_void; - INHERITANCE_REGISTRY.with(|reg| { + inheritance_registry(|reg| { let map = reg.borrow(); let mut current_tag = concrete_tag; let mut current_ptr = data_ptr; @@ -1668,12 +1698,8 @@ struct SpecTables { static_properties: *const JSPropertySpec, } -thread_local! { - static SPEC_TABLES: RefCell> = RefCell::new(HashMap::new()); -} - fn spec_tables_for() -> SpecTables { - SPEC_TABLES.with(|tables| { + spec_tables_map(|tables| { if let Some(t) = tables.borrow().get(&TypeId::of::()) { return *t; } diff --git a/crates/js/src/gc/pool.rs b/crates/js/src/gc/pool.rs index 3536c142..7f85c735 100644 --- a/crates/js/src/gc/pool.rs +++ b/crates/js/src/gc/pool.rs @@ -298,8 +298,21 @@ pub struct HandlePool { active_head: Cell<*mut ScopeAlloc>, } -thread_local! { - static HANDLE_POOL: RefCell>> = const { RefCell::new(None) }; +/// This module's share of the crate's thread-local state. See [`crate::tls`]. +pub(crate) struct PoolTls { + handle_pool: RefCell>>, +} + +impl PoolTls { + pub(crate) const fn new() -> Self { + Self { + handle_pool: RefCell::new(None), + } + } +} + +fn handle_pool(f: impl FnOnce(&RefCell>>) -> R) -> R { + crate::tls::with(|tls| f(&tls.pool.handle_pool)) } /// Create the handle pool for scope-based rooting. @@ -314,7 +327,7 @@ pub(crate) fn init_pool(cx: &mut JSContext) { pool_rooter.add_to_root_stack(cx.raw_cx()); } - HANDLE_POOL.with(|hp| { + handle_pool(|hp| { let mut borrow = hp.borrow_mut(); assert!( borrow.is_none(), @@ -334,7 +347,7 @@ pub(crate) fn init_pool(cx: &mut JSContext) { /// /// Panics if no pool has been initialized on this thread. pub(crate) fn current_pool() -> *const HandlePool { - HANDLE_POOL.with(|hp| { + handle_pool(|hp| { let borrow = hp.borrow(); let rooter = borrow .as_ref() @@ -425,7 +438,7 @@ unsafe impl CustomTrace for HandlePool { pub type PoolRooter = CustomAutoRooter; pub(crate) fn shutdown() { - HANDLE_POOL.with(|hp| { + handle_pool(|hp| { let mut borrow = hp.borrow_mut(); // Clear the pool rooter from the autoGCRooters stack. // SAFETY: This reverses the add_to_root_stack call in init_pool(). diff --git a/crates/js/src/instance_local.rs b/crates/js/src/instance_local.rs new file mode 100644 index 00000000..f33a8f76 --- /dev/null +++ b/crates/js/src/instance_local.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +//! State scoped to the runtime that owns it: one wasm instance, or one native thread. +//! +//! Under the component model a task is a thread, and calling an async export +//! spawns one, so on WASIp3 every export call into an instance starts with fresh +//! thread-local storage. State a runtime keeps between calls cannot live in a +//! `thread_local!` there. Linear memory does persist across calls, so a `static` +//! holds. +//! +//! Native targets keep `thread_local!`, so runtimes on separate threads stay +//! independent of each other. + +/// A `static` standing in for a `thread_local!` on wasm targets. +/// +/// The value is built on first access, as a `thread_local!` builds its own. It is +/// never dropped: an instance has no equivalent of a thread exit. +#[cfg(target_family = "wasm")] +pub struct InstanceLocal { + cell: std::cell::OnceCell, + init: fn() -> T, +} + +#[cfg(target_family = "wasm")] +// SAFETY: a wasm instance runs guest code on one thread. Concurrent export calls +// interleave at await points rather than running in parallel, which is the access +// pattern several requests already have against a thread-local in one instance on +// wasm32-wasip2. +unsafe impl Sync for InstanceLocal {} + +#[cfg(target_family = "wasm")] +impl InstanceLocal { + pub const fn new(init: fn() -> T) -> Self { + Self { + cell: std::cell::OnceCell::new(), + init, + } + } + + /// Run `f` against the value, matching `LocalKey::with`. + pub fn with(&'static self, f: impl FnOnce(&T) -> R) -> R { + f(self.cell.get_or_init(self.init)) + } + + /// Matching `LocalKey::try_with`, which fails only once a thread has destroyed + /// its copy. Nothing destroys this one, so `f` always runs. + pub fn try_with( + &'static self, + f: impl FnOnce(&T) -> R, + ) -> Result { + Ok(self.with(f)) + } +} + +/// The `Cell` conveniences `LocalKey` offers, so call sites read the same either way. +#[cfg(target_family = "wasm")] +impl InstanceLocal> { + pub fn set(&'static self, value: T) { + self.with(|cell| cell.set(value)); + } + + pub fn replace(&'static self, value: T) -> T { + self.with(|cell| cell.replace(value)) + } + + pub fn take(&'static self) -> T + where + T: Default, + { + self.with(std::cell::Cell::take) + } +} + +#[cfg(target_family = "wasm")] +impl InstanceLocal> { + pub fn get(&'static self) -> T { + self.with(std::cell::Cell::get) + } +} + +/// The `RefCell` conveniences `LocalKey` offers. +#[cfg(target_family = "wasm")] +impl InstanceLocal> { + pub fn with_borrow(&'static self, f: impl FnOnce(&T) -> R) -> R { + self.with(|cell| f(&cell.borrow())) + } + + pub fn with_borrow_mut(&'static self, f: impl FnOnce(&mut T) -> R) -> R { + self.with(|cell| f(&mut cell.borrow_mut())) + } +} + +/// Declare state scoped to one runtime. +/// +/// Takes the shape of a `thread_local!`, with or without a `const` initializer, and +/// expands to a `thread_local!` on native targets and an [`InstanceLocal`] static on +/// wasm ones. Call sites use `.with(|value| ...)` either way. +#[macro_export] +macro_rules! instance_local { + ($( + $(#[$attr:meta])* + $vis:vis static $name:ident: $ty:ty = const $init:block; + )*) => {$( + #[cfg(target_family = "wasm")] + $(#[$attr])* + $vis static $name: $crate::instance_local::InstanceLocal<$ty> = + $crate::instance_local::InstanceLocal::new(|| $init); + + #[cfg(not(target_family = "wasm"))] + ::std::thread_local! { + $(#[$attr])* + $vis static $name: $ty = const $init; + } + )*}; + + ($( + $(#[$attr:meta])* + $vis:vis static $name:ident: $ty:ty = $init:expr; + )*) => {$( + #[cfg(target_family = "wasm")] + $(#[$attr])* + $vis static $name: $crate::instance_local::InstanceLocal<$ty> = + $crate::instance_local::InstanceLocal::new(|| $init); + + #[cfg(not(target_family = "wasm"))] + ::std::thread_local! { + $(#[$attr])* + $vis static $name: $ty = $init; + } + )*}; +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index dc7148fc..8cd8e387 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -110,6 +110,7 @@ pub mod exception; pub mod function; pub mod gc; pub mod id; +pub mod instance_local; pub mod ionmonkey; pub mod iteration; pub mod jobs; @@ -126,6 +127,7 @@ pub mod stack; pub mod string; pub mod structured_clone; pub mod symbol; +mod tls; pub mod try_catch; pub mod typedarray; pub mod value; @@ -149,6 +151,24 @@ pub mod engine { pub use mozjs::rust::{JSEngine, JSEngineHandle, RealmOptions, Runtime as MozJSRuntime}; } +/// The monotonic clock the engine reads. +/// +/// Only on wasm, where the clock is scoped to the instance and an instance resuming from a Wizer +/// snapshot has to be given the reading the snapshot ended at. +#[cfg(target_arch = "wasm32")] +pub mod clock { + /// Add `nanoseconds` to every monotonic clock reading the engine takes afterwards. + /// + /// A resumed instance reads the clock from zero again, while the timestamps the engine kept + /// across the snapshot (GC phase times, the last collection's end) were taken before it and so + /// sit in the resumed instance's future. Pass the reading taken when the snapshot was made, so + /// that no reading afterwards precedes them. Calls accumulate. + pub fn advance_monotonic_clock(nanoseconds: u64) { + // SAFETY: the glue function adds to a process-wide counter and touches nothing else. + unsafe { mozjs::glue::AdvanceMonotonicClock(nanoseconds) } + } +} + /// Context and native callback types. /// /// Used by code that implements `JSNative` callbacks, GC trace hooks, diff --git a/crates/js/src/module.rs b/crates/js/src/module.rs index 3622851a..b4ffbf36 100644 --- a/crates/js/src/module.rs +++ b/crates/js/src/module.rs @@ -194,15 +194,24 @@ pub unsafe fn set_module_load_hook( mozjs::jsapi::SetModuleLoadHook(rt, hook); } -thread_local! { +/// This module's share of the crate's thread-local state. See [`crate::tls`]. +pub(crate) struct ModuleTls { /// Outcome of the innermost in-flight [`load_requested_modules`] call: /// `None` until one of its callbacks fires, `Some(Ok(()))` once the graph /// has loaded, `Some(Err(ExnThrown))` once it has failed. - static LOAD_OUTCOME: Cell>> = const { Cell::new(None) }; + load_outcome: Cell>>, +} + +impl ModuleTls { + pub(crate) const fn new() -> Self { + Self { + load_outcome: Cell::new(None), + } + } } fn load_outcome(f: impl FnOnce(&Cell>>) -> R) -> R { - LOAD_OUTCOME.with(f) + crate::tls::with(|tls| f(&tls.module.load_outcome)) } unsafe extern "C" fn load_resolved( diff --git a/crates/js/src/promise.rs b/crates/js/src/promise.rs index 1e753e72..4868917e 100644 --- a/crates/js/src/promise.rs +++ b/crates/js/src/promise.rs @@ -365,8 +365,8 @@ impl<'s> Stack<'s, Promise> { pub fn spawn(&self, js_promise: PromiseFuture) { let heap_promise = RootedHeap::new(*self); - let owner = CURRENT_FUTURE_OWNER.with(|owner_cell| owner_cell.get()); - PENDING_FUTURES.with(|f| { + let owner = current_future_owner(|owner_cell| owner_cell.get()); + pending_futures(|f| { f.borrow_mut() .push((owner, heap_promise, js_promise.future)); }); @@ -598,23 +598,47 @@ impl PromiseFuture { } } -thread_local! { - // Crown: `PendingPromise` is self-rooting via `RootedHeap`, so we - // don't need to root the Vec itself. - #[crate::allow_unrooted_interior] - static PENDING_FUTURES: RefCell> = RefCell::new(Vec::new()); -} - -thread_local! { +/// This module's share of the crate's thread-local state. See [`crate::tls`]. +/// +/// Crown: `PendingPromise` is self-rooting via `RootedHeap`, so neither `Vec` +/// needs rooting. +#[crate::allow_unrooted_interior] +pub(crate) struct PromiseTls { + /// Futures waiting for an event loop to adopt them. + pending: RefCell>, + /// The futures the event loop is currently polling. + active: RefCell>, /// The event-loop id that owns futures spawned now (set by the event loop in `with_event_loop`). /// 0 when no loop is active. - static CURRENT_FUTURE_OWNER: Cell = const { Cell::new(0) }; + owner: Cell, +} + +impl PromiseTls { + pub(crate) const fn new() -> Self { + Self { + pending: RefCell::new(Vec::new()), + active: RefCell::new(Vec::new()), + owner: Cell::new(0), + } + } +} + +fn pending_futures(f: impl FnOnce(&RefCell>) -> R) -> R { + crate::tls::with(|tls| f(&tls.promise.pending)) +} + +fn active_futures(f: impl FnOnce(&RefCell>) -> R) -> R { + crate::tls::with(|tls| f(&tls.promise.active)) +} + +fn current_future_owner(f: impl FnOnce(&Cell) -> R) -> R { + crate::tls::with(|tls| f(&tls.promise.owner)) } /// Set the owning event-loop id for futures spawned from now on, returning the previous owner so the /// caller can restore it when the loop's scope ends. The event loop calls this in `with_event_loop`. pub fn set_current_future_owner(owner: u64) -> u64 { - CURRENT_FUTURE_OWNER.with(|owner_cell| owner_cell.replace(owner)) + current_future_owner(|owner_cell| owner_cell.replace(owner)) } /// Take all pending promise futures, returning them for execution. @@ -622,13 +646,7 @@ pub fn set_current_future_owner(owner: u64) -> u64 { /// This drains the internal queue into the active set managed by /// [`drive_pending_futures`]; it is not normally called directly. fn take_pending_futures() -> Vec { - PENDING_FUTURES.with(|f| std::mem::take(&mut *f.borrow_mut())) -} - -thread_local! { - // The futures currently being polled by the event loop. Like `PENDING_FUTURES`, - // each entry self-roots its promise via `RootedHeap`. - static ACTIVE_FUTURES: RefCell> = const { RefCell::new(Vec::new()) }; + pending_futures(|f| std::mem::take(&mut *f.borrow_mut())) } /// Cancel and drop the pending future settling `promise`, if one is queued. @@ -646,8 +664,8 @@ pub fn cancel_pending_future(promise: Stack) -> bool { !matches }); }; - PENDING_FUTURES.with(&mut drop_matching); - ACTIVE_FUTURES.with(&mut drop_matching); + pending_futures(&mut drop_matching); + active_futures(&mut drop_matching); removed } @@ -664,8 +682,8 @@ pub fn cancel_pending_futures_for(owner: u64) { .borrow_mut() .retain(|(future_owner, _, _)| *future_owner != owner && *future_owner != 0); }; - PENDING_FUTURES.with(drop_owned); - ACTIVE_FUTURES.with(drop_owned); + pending_futures(drop_owned); + active_futures(drop_owned); } /// Whether any async-promise future owned by `owner` (or unowned) is pending (spawned but not yet @@ -675,8 +693,8 @@ pub fn cancel_pending_futures_for(owner: u64) { /// even with no tasks or timers — without being held alive by another request's loop's futures. pub fn has_pending_futures(owner: u64) -> bool { let owned_by_caller = |future: &PendingPromise| future.0 == owner || future.0 == 0; - ACTIVE_FUTURES.with(|a| a.borrow().iter().any(owned_by_caller)) - || PENDING_FUTURES.with(|f| f.borrow().iter().any(owned_by_caller)) + active_futures(|a| a.borrow().iter().any(owned_by_caller)) + || pending_futures(|f| f.borrow().iter().any(owned_by_caller)) } /// A promise object whose future completed, paired with its outcome — returned by @@ -700,7 +718,7 @@ pub fn poll_pending_futures( ) -> Vec { // Take the active set out of the thread-local (so a future's poll can't re-borrow it) and adopt // any newly spawned futures. - let mut futures = ACTIVE_FUTURES.with(|a| std::mem::take(&mut *a.borrow_mut())); + let mut futures = active_futures(|a| std::mem::take(&mut *a.borrow_mut())); futures.append(&mut take_pending_futures()); let mut still_pending: Vec = Vec::with_capacity(futures.len()); @@ -718,8 +736,8 @@ pub fn poll_pending_futures( } // Restore the still-pending futures. Anything spawned during the polls above landed in - // `PENDING_FUTURES` and is adopted on the next call. - ACTIVE_FUTURES.with(|a| a.borrow_mut().append(&mut still_pending)); + // `pending` and is adopted on the next call. + active_futures(|a| a.borrow_mut().append(&mut still_pending)); completed } diff --git a/crates/js/src/tls.rs b/crates/js/src/tls.rs new file mode 100644 index 00000000..76a347a7 --- /dev/null +++ b/crates/js/src/tls.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +//! This crate's thread-local state, held in a single key. +//! +//! Each module that needs thread-local state owns a struct of it here and +//! reaches it through [`with`]. One key for the crate means one destructor, so +//! the fields below drop in the order they are declared and anything reasoning +//! about teardown order has one `js` entry to consider rather than eight. +//! +//! Every field keeps its own `RefCell` or `Cell`, so a borrow covers one piece +//! of state rather than all of it. + +use crate::class::ClassTls; +use crate::gc::pool::PoolTls; +use crate::module::ModuleTls; +use crate::promise::PromiseTls; + +/// The whole crate's thread-local state. +/// +/// The key's destructor drops these fields in declaration order, so ordering +/// requirements are easy to express and reason about. +#[crate::allow_unrooted_interior] +pub(crate) struct JsTls { + pub(crate) promise: PromiseTls, + pub(crate) class: ClassTls, + pub(crate) pool: PoolTls, + pub(crate) module: ModuleTls, +} + +crate::instance_local! { + static JS_TLS: JsTls = const { + JsTls { + promise: PromiseTls::new(), + class: ClassTls::new(), + pool: PoolTls::new(), + module: ModuleTls::new(), + } + }; +} + +/// Run `f` against this thread's state. +pub(crate) fn with(f: impl FnOnce(&JsTls) -> R) -> R { + JS_TLS.with(f) +} diff --git a/crates/libstarling/src/serve_wasm.rs b/crates/libstarling/src/serve_wasm.rs index 1c57e707..dc590645 100644 --- a/crates/libstarling/src/serve_wasm.rs +++ b/crates/libstarling/src/serve_wasm.rs @@ -25,7 +25,7 @@ use std::rc::Rc; use std::sync::atomic::AtomicU64; use wasip3::http::types::{ErrorCode, Request as WasiRequest, Response as WasiResponse}; -thread_local! { +js::instance_local! { /// The JS runtime and its raw context, created once. The single global's realm is entered for /// the whole process rather than per request: a per-request `default_global()` would enter /// via a `JSAutoRealm`, and those drop non-LIFO under request interleaving, restoring the @@ -42,7 +42,7 @@ thread_local! { /// Signalled on every [`STARTUP`] transition a waiting request cares about: driven to /// completion, or handed back by a driver that was cancelled. Every write of `Startup` other /// than `Driving` must notify, or a waiter in [`ensure_started`] sleeps through it. - static STARTUP_CHANGED: event_listener::Event = event_listener::Event::new(); + static STARTUP_CHANGED: event_listener::Event = const { event_listener::Event::new() }; /// Set while a Wizer snapshot is being taken, so it is set in the snapshot and the resumed /// instance can tell it came from one. See [`fix_up_after_resume`]. @@ -64,6 +64,18 @@ enum Startup { Done, } +/// Store the runtime this instance serves from, and hold a reference for the life of the instance. +/// +/// The held reference is never released, so the runtime is never dropped. Nothing here tears an +/// instance down except thread-local destruction, and `Runtime::drop` reaches thread-locals in the +/// `js` crate (the scope handle pool among them) that the same destruction may already have run, +/// which traps. Dropping it would reclaim nothing either, since the instance's memory goes away +/// whole. `serve_native` runs the real `Drop`. +fn install_runtime(pair: (Rc, *mut js::native::RawJSContext, ServeTimeouts)) { + std::mem::forget(Rc::clone(&pair.0)); + RUNTIME.with(|cell| *cell.borrow_mut() = Some(pair)); +} + /// Get the runtime and its context, creating them on first use: run the content script /// (registering `fetch` handlers) and enter the global realm persistently. Synchronous (no /// `await`), so concurrent first requests can't race into two runtimes. @@ -91,10 +103,20 @@ fn runtime() -> Result<(Rc, *mut js::native::RawJSContext, ServeTimeout let raw_cx = unsafe { scope.raw_cx_no_gc() }; std::mem::forget(scope); let pair = (runtime, raw_cx, timeouts); - RUNTIME.with(|cell| *cell.borrow_mut() = Some(pair.clone())); + install_runtime(pair.clone()); Ok(pair) } +/// Record that this instance's state is being captured in a Wizer snapshot. The resumed +/// instance's first request runs the resume fixups (see [`fix_up_after_resume`]), which advance +/// its clocks by the monotonic clock reading taken here. Every caller is a Wizer entry point, and +/// calls this last, since a reading taken afterwards would sit past the recorded one and so still +/// be in the resumed instance's future. +pub fn mark_resumed_from_snapshot() { + RESUMED_FROM_SNAPSHOT.with(|resumed| resumed.set(true)); + platform::clock::record_snapshot_reading(); +} + /// Initializes the runtime until it's ready for Wizer snapshotting. /// /// This entails initializing the JS runtime, registering builtins, running the top-level script to @@ -106,7 +128,7 @@ pub async fn pre_initialize() -> Result<(), String> { STARTUP.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), Startup::Driving)) else { // Nothing to evaluate: `runtime` was already stood up, so this is a second call. - RESUMED_FROM_SNAPSHOT.with(|resumed| resumed.set(true)); + mark_resumed_from_snapshot(); return Ok(()); }; drive_startup(raw_cx, invocation.state_mut().event_loop(), &evaluation).await; @@ -146,8 +168,13 @@ fn evaluated_without_listener( } /// Some state, such as process time origins, needs fixing up after snapshot resumption. +/// +/// The monotonic clock comes first: its offset puts the timestamps the snapshot holds in the +/// resumed instance's past, and both the engine's timing and every fixup below read the clock +/// through it. fn fix_up_after_resume() { if RESUMED_FROM_SNAPSHOT.with(|resumed| resumed.replace(false)) { + js::clock::advance_monotonic_clock(platform::clock::resume_from_snapshot()); core_runtime::runtime::run_resume_fixups(); } } diff --git a/crates/platform/src/clock.rs b/crates/platform/src/clock.rs new file mode 100644 index 00000000..c3029289 --- /dev/null +++ b/crates/platform/src/clock.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +//! The monotonic clock, with offsets applied where needed, such as when resuming from a Wizer +//! snapshot. +//! +//! On wasm the monotonic clock is scoped to the instance: it starts again at zero when an instance +//! resumes from a snapshot, while readings taken before the snapshot are still in the heap, in the +//! instance's future. [`record_snapshot_reading`] captures the clock as the snapshot is made and +//! [`resume_from_snapshot`] adds that reading to every reading afterwards, so a resumed instance +//! continues the timeline the snapshot ended on instead of restarting it. +//! +//! SpiderMonkey keeps the same kind of state (GC phase timestamps, the last collection's end) and +//! reads its own copy of the same host clock, so an embedder passes the value +//! [`resume_from_snapshot`] returns on to the engine as well. + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Added to every reading returned by [`monotonic_ns`]. +static OFFSET_NS: AtomicU64 = AtomicU64::new(0); + +/// The reading [`record_snapshot_reading`] took, carried into the snapshot for +/// [`resume_from_snapshot`] to apply. Zero for an instance that was never snapshotted. +static SNAPSHOT_NS: AtomicU64 = AtomicU64::new(0); + +#[cfg(target_arch = "wasm32")] +fn host_monotonic_ns() -> u64 { + wasip3::clocks::monotonic_clock::now() +} + +#[cfg(not(target_arch = "wasm32"))] +fn host_monotonic_ns() -> u64 { + /// The origin of the readings below. Native processes are not snapshotted, so any fixed point + /// in the process will do, and the first reading is the earliest one available. + static ORIGIN: std::sync::LazyLock = + std::sync::LazyLock::new(std::time::Instant::now); + ORIGIN.elapsed().as_nanos() as u64 +} + +/// The current monotonic clock reading, in nanoseconds, continuous across a snapshot. +/// +/// The origin is unspecified: only differences between readings are meaningful. +pub fn monotonic_ns() -> u64 { + OFFSET_NS.load(Ordering::Relaxed) + host_monotonic_ns() +} + +/// Record where the clock stood as a snapshot was made. +/// +/// Must be the last thing an instance being snapshotted does, since a reading taken afterwards +/// would sit past the recorded one and so still be in a resumed instance's future. +pub fn record_snapshot_reading() { + SNAPSHOT_NS.store(host_monotonic_ns(), Ordering::Relaxed); +} + +/// Advance the clock past everything the snapshot holds, and return the nanoseconds added. +/// +/// Called on the first work a resumed instance does. The caller passes the return value to every +/// other clock the resumed instance has, above all SpiderMonkey's. Returns zero, and does nothing, +/// for an instance that did not resume from a snapshot, and for every call after the first. +pub fn resume_from_snapshot() -> u64 { + let nanoseconds = SNAPSHOT_NS.swap(0, Ordering::Relaxed); + OFFSET_NS.fetch_add(nanoseconds, Ordering::Relaxed); + nanoseconds +} + +/// A monotonic clock reading, for deadlines that outlive a Wizer snapshot. +/// +/// `std::time::Instant` reads the host clock directly, so a deadline taken before a snapshot sits +/// in the resumed instance's future by however long the snapshotted instance ran. This reads +/// [`monotonic_ns`] instead, whose offset makes a resumed instance continue the timeline the +/// snapshot ended on, so such a deadline is already past when the instance resumes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Instant(u64); + +impl Instant { + /// The current reading. + pub fn now() -> Self { + Self(monotonic_ns()) + } + + /// The time from `earlier` to this reading, or zero if `earlier` is the later of the two. + pub fn saturating_duration_since(&self, earlier: Self) -> std::time::Duration { + std::time::Duration::from_nanos(self.0.saturating_sub(earlier.0)) + } +} + +impl std::ops::Add for Instant { + type Output = Self; + + /// Saturates at the largest representable reading, which no clock reaches. + fn add(self, duration: std::time::Duration) -> Self { + Self( + self.0 + .saturating_add(duration.as_nanos().min(u64::MAX as u128) as u64), + ) + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index ef32ae01..e626f505 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -7,9 +7,11 @@ //! platform-agnostic parts in one module and its backends in `_native` //! / `_wasm` modules, selected with `#[cfg(target_arch)]`. The first //! capability is HTTP ([`http`]), used by `fetch`: native uses an async HTTP -//! client, wasm uses the WASIp3 outgoing-handler. Future host capabilities -//! (sockets, filesystem, …) follow the same shape. +//! client, wasm uses the WASIp3 outgoing-handler. The other is the monotonic +//! clock ([`clock`]). Future host capabilities (sockets, filesystem, …) follow +//! the same shape. +pub mod clock; pub mod http; #[cfg(not(target_arch = "wasm32"))] mod http_native; diff --git a/crates/platform/tests/clock.rs b/crates/platform/tests/clock.rs new file mode 100644 index 00000000..deb8e314 --- /dev/null +++ b/crates/platform/tests/clock.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +//! The monotonic clock's snapshot offset. +//! +//! These run natively, where the clock never restarts, so they cover the offset arithmetic rather +//! than a real resume: [`record_snapshot_reading`] and [`resume_from_snapshot`] move the clock +//! forward by the recorded reading exactly once. + +use platform::clock::{monotonic_ns, record_snapshot_reading, resume_from_snapshot, Instant}; +use std::time::Duration; + +#[test] +fn resuming_advances_the_clock_by_the_recorded_reading() { + let before = monotonic_ns(); + record_snapshot_reading(); + let added = resume_from_snapshot(); + assert!( + added > 0, + "the recorded reading should be past the clock's origin" + ); + assert!( + monotonic_ns() >= before + added, + "readings after the resume should sit past the snapshot's" + ); + + // The reading is consumed by the first resume, so a second one is a no-op. Every later call + // in a resumed instance takes this path. + assert_eq!(resume_from_snapshot(), 0); +} + +#[test] +fn instant_arithmetic_saturates() { + let now = Instant::now(); + assert_eq!( + now.saturating_duration_since(now + Duration::from_secs(1)), + Duration::ZERO, + "a later reading is not in the past" + ); + assert_eq!( + (now + Duration::from_millis(250)).saturating_duration_since(now), + Duration::from_millis(250) + ); + assert!( + now + Duration::MAX > now, + "adding saturates rather than wrapping" + ); +} diff --git a/crates/serve-test-support/tests/serve_wasm_e2e.rs b/crates/serve-test-support/tests/serve_wasm_e2e.rs index d7345008..08556fb3 100644 --- a/crates/serve-test-support/tests/serve_wasm_e2e.rs +++ b/crates/serve-test-support/tests/serve_wasm_e2e.rs @@ -1000,13 +1000,6 @@ fn a_snapshotted_instance_keeps_its_clock_and_global_across_requests() { let Some(server) = Serve::new(18420) .reusing_one_instance() .wizen() - // A resumed snapshot includes SpiderMonkey's GC statistics, whose phase timestamps are - // readings of the monotonic clock the snapshotting process had. `wasmtime serve` starts - // that clock again from zero, so those readings sit in the resumed instance's future and - // mode 4's barrier verifier trips a debug assertion on them (`Inconsistent time data`, - // Mozilla bug 1400153) and traps. `performance`'s time origin is the same hazard, which - // `register_resume_fixup` repairs. The engine's own copy is out of reach from here. - .without_gc_zeal() .script(HANDLER) .start() else { @@ -2675,13 +2668,6 @@ fn a_snapshotted_instance_waits_for_the_scripts_top_level_await() { });"#; let Some(server) = Serve::new(18458) .wizen() - // A resumed snapshot includes SpiderMonkey's GC statistics, whose phase timestamps are - // readings of the monotonic clock the snapshotting process had. `wasmtime serve` starts - // that clock again from zero, so those readings sit in the resumed instance's future and - // mode 4's barrier verifier trips a debug assertion on them (`Inconsistent time data`, - // Mozilla bug 1400153) and traps. `performance`'s time origin is the same hazard, which - // `register_resume_fixup` repairs. The engine's own copy is out of reach from here. - .without_gc_zeal() .ready(Ready::Listening) .module("awaited.mjs", SCRIPT) .start() diff --git a/justfile b/justfile index 505e5b32..345e4be5 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,14 @@ # just clippy Run clippy lints # just check Run fmt check + clippy + tests +# The wasm target the `-wasm` recipes build for. `WASM_TARGET=p3 just test-wasm` retargets them; +# `p2`, `p3` and full triples are all accepted. The pinned toolchain in `rust-toolchain.toml` +# ships no wasm32-wasip3 std, so that target builds through nightly. +_wasm_target_arg := env_var_or_default("WASM_TARGET", "p2") +WASM_TARGET := if _wasm_target_arg == "p2" { "wasm32-wasip2" } else if _wasm_target_arg == "p3" { "wasm32-wasip3" } else { _wasm_target_arg } +_wasm_cargo := if WASM_TARGET == "wasm32-wasip3" { "cargo +nightly" } else { "cargo" } +_wasm_out := "${CARGO_TARGET_DIR:-" + justfile_directory() + "/target}/" + WASM_TARGET + # Build in debug mode. build *TARGET: cargo build --features debugmozjs {{TARGET}} @@ -55,7 +63,7 @@ wpt-test *PATTERN: [group('wpt')] wpt-test-release *PATTERN: @just build-release - node tests/wpt-harness/run-wpt.mjs --runtime=target/release/starling {{PATTERN}} + node tests/wpt-harness/run-wpt.mjs --runtime=target/release/starlingmonkey {{PATTERN}} # Run WPT tests with verbose output. [group('wpt')] @@ -121,24 +129,24 @@ check-wasm: just clippy --all just test-wasm --examples -# Build for wasm32-wasip2. +# Build for the wasm target (`WASM_TARGET`, default wasm32-wasip2). build-wasm *TARGET: - cargo build --target wasm32-wasip2 --features debugmozjs {{TARGET}} + {{_wasm_cargo}} build --target {{WASM_TARGET}} --features debugmozjs {{TARGET}} -# Build for wasm32-wasip2 in release mode. +# Build for the wasm target in release mode. build-wasm-release *TARGET: - cargo build --target wasm32-wasip2 --release {{TARGET}} + {{_wasm_cargo}} build --target {{WASM_TARGET}} --release {{TARGET}} # Run all Rust tests. A run with no arguments also runs the wasm serve end-to-end suite; # arguments (a filter, `--examples`) reach only the cargo tests and leave that suite out. test-wasm *TARGET: - cargo test --target wasm32-wasip2 --features debugmozjs --workspace {{TARGET}} + {{_wasm_cargo}} test --target {{WASM_TARGET}} --features debugmozjs --workspace {{TARGET}} @{{ if TARGET == "" { "just test-serve-wasm" } else { "echo 'Skipped the wasm serve end-to-end suite; run it with: just test-serve-wasm'" } }} # Run all Rust tests in release mode. A run with no arguments also runs the wasm serve # end-to-end suite. test-wasm-release *TARGET: - cargo test --target wasm32-wasip2 --release --workspace {{TARGET}} + {{_wasm_cargo}} test --target {{WASM_TARGET}} --release --workspace {{TARGET}} @{{ if TARGET == "" { "just test-serve-wasm-release" } else { "echo 'Skipped the wasm serve end-to-end suite; run it with: just test-serve-wasm-release'" } }} # Snapshot the component with `wasmtime wizer` and serve the result. Needs wasmtime on PATH. @@ -151,32 +159,32 @@ test-wizer *ARGS: # skips loudly. test-serve-wasm *ARGS: @just build-wasm -p starlingmonkey - STARLING_WASM_COMPONENT="${CARGO_TARGET_DIR:-{{justfile_directory()}}/target}/wasm32-wasip2/debug/starling.wasm" \ + STARLING_WASM_COMPONENT="{{_wasm_out}}/debug/starling.wasm" \ cargo test -p serve-test-support --test serve_wasm_e2e {{ARGS}} # `test-serve-wasm` against the release component. The harness itself stays a debug build. test-serve-wasm-release *ARGS: @just build-wasm-release -p starlingmonkey - STARLING_WASM_COMPONENT="${CARGO_TARGET_DIR:-{{justfile_directory()}}/target}/wasm32-wasip2/release/starling.wasm" \ + STARLING_WASM_COMPONENT="{{_wasm_out}}/release/starling.wasm" \ cargo test -p serve-test-support --test serve_wasm_e2e {{ARGS}} # Run WPT tests against the wasm binary. [group('wpt')] wpt-test-wasm *PATTERN: @just build-wasm - node tests/wpt-harness/run-wpt.mjs --target=wasm {{PATTERN}} + node tests/wpt-harness/run-wpt.mjs --target=wasm --runtime="{{_wasm_out}}/debug/starling.wasm" {{PATTERN}} # Run WPT tests against the wasm binary with verbose output. [group('wpt')] wpt-test-wasm-verbose *PATTERN: @just build-wasm - node tests/wpt-harness/run-wpt.mjs --target=wasm -vv {{PATTERN}} + node tests/wpt-harness/run-wpt.mjs --target=wasm --runtime="{{_wasm_out}}/debug/starling.wasm" -vv {{PATTERN}} # Run WPT tests against the wasm binary and update expectations. [group('wpt')] wpt-update-wasm *PATTERN: @just build-wasm - node tests/wpt-harness/run-wpt.mjs --target=wasm --update-expectations {{PATTERN}} + node tests/wpt-harness/run-wpt.mjs --target=wasm --runtime="{{_wasm_out}}/debug/starling.wasm" --update-expectations {{PATTERN}} # Run WPT tests through a serve-mode runtime: each test runs inside a `fetch` handler rather than # as a one-shot command, which is the shape a deployed server has. Same binary as the command-mode @@ -197,7 +205,7 @@ wpt-test-serve *PATTERN: [group('wpt')] wpt-test-wasm-serve *PATTERN: @just build-wasm - node tests/wpt-harness/run-wpt.mjs --target=wasm --mode=serve --wizen {{PATTERN}} + node tests/wpt-harness/run-wpt.mjs --target=wasm --runtime="{{_wasm_out}}/debug/starling.wasm" --mode=serve --wizen {{PATTERN}} # Run WPT across every configuration: both targets, each as a command and as a server, from one # build per target. diff --git a/scripts/test-wizer.sh b/scripts/test-wizer.sh index 40157c86..f05df341 100755 --- a/scripts/test-wizer.sh +++ b/scripts/test-wizer.sh @@ -11,17 +11,30 @@ # ./scripts/test-wizer.sh # build the component, then run every case # ./scripts/test-wizer.sh --no-build # reuse the component already in target/ # +# `WASM_TARGET` selects the target, as `p2`, `p3` or a full triple. It defaults to `p2`. +# # Requires `wasmtime` on PATH (for both the `wizer` subcommand and `serve`). set -euo pipefail cd "$(dirname "$0")/.." -COMPONENT="target/wasm32-wasip2/debug/starling.wasm" +case "${WASM_TARGET:-p2}" in + p2) TARGET=wasm32-wasip2 ;; + p3) TARGET=wasm32-wasip3 ;; + *) TARGET="${WASM_TARGET}" ;; +esac +# The pinned toolchain ships no wasm32-wasip3 std. +if [ "$TARGET" = wasm32-wasip3 ]; then + CARGO=(cargo +nightly) +else + CARGO=(cargo) +fi +COMPONENT="target/$TARGET/debug/starling.wasm" WASI_FLAGS=(-Scli=y,inherit-env=y,http=y) if [[ "${1:-}" != "--no-build" ]]; then - cargo build --target wasm32-wasip2 --features debugmozjs -p starlingmonkey + "${CARGO[@]}" build --target "$TARGET" --features debugmozjs -p starlingmonkey fi if ! command -v wasmtime >/dev/null; then diff --git a/starling/Cargo.toml b/starling/Cargo.toml index d82e9861..3201f4db 100644 --- a/starling/Cargo.toml +++ b/starling/Cargo.toml @@ -6,8 +6,20 @@ license.workspace = true authors.workspace = true edition.workspace = true -[[bin]] +# The wasm artifact. The `cdylib` link pulls in no `crt1-command.o`, so the `export!` in `lib.rs` +# is the only `wasi:cli/run` in the module. The binary below does link it, and on wasm32-wasip3 +# the `wasi:cli/run@0.3.0#run` it exports collides with that one. The two targets must also differ +# in name, or both would write `starling.wasm`. +[lib] name = "starling" +crate-type = ["cdylib"] +# The wasm entry point, not a library with tests. A test harness is a binary, so it does not +# receive the `--wrap` link argument `build.rs` emits for the cdylib, and its link fails on an +# undefined `__real___wasm_call_ctors`. +test = false + +[[bin]] +name = "starlingmonkey" path = "src/main.rs" test = false @@ -18,17 +30,17 @@ jitspew = ['js/jitspew'] intl = ['js/intl'] [dependencies] -# `core-runtime` and `js` carry no direct use in `main.rs`: the former is needed by the `examples/` -# (whose `#[jsmodule]` expansions name it), the latter backs the `profilemozjs`/`jitspew`/`intl` -# features above. +# `core-runtime` and `js` carry no direct use in `lib.rs` or `main.rs`: the former is needed by the +# `examples/` (whose `#[jsmodule]` expansions name it), the latter backs the +# `profilemozjs`/`jitspew`/`intl` features above. core-runtime.workspace = true js.workspace = true libstarling.workspace = true wasip3.workspace = true -# Pinned to the version `wasip3` itself uses, rather than the workspace's. Both generate a -# `run_ctors_once` guarded by its own static, so two versions in one component means the C++ -# constructors run twice — which a Wizer-resumed instance trips immediately (SpiderMonkey's -# `InitializeUptime` asserts it is called once). +# Pinned to the version `wasip3` itself uses, rather than the workspace's, so the component +# carries one wit-bindgen. Each version generates its own `run_ctors_once` guard, and +# `__wrap___wasm_call_ctors` in `lib.rs` collapses those onto one, so the C++ constructors run +# once. SpiderMonkey's `InitializeUptime` asserts it is called once. # # `inter-task-wakeup` lets one wit task wake another through plain Rust wakers, which the serve # path needs: the response-body writer task parks on a Rust channel the request's event-loop diff --git a/starling/build.rs b/starling/build.rs new file mode 100644 index 00000000..05b02bd3 --- /dev/null +++ b/starling/build.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + // Routes `__wasm_call_ctors` through `__wrap___wasm_call_ctors` in `lib.rs`, so every path + // that runs the static constructors shares one guard. Without it they run twice and + // SpiderMonkey's `InitializeUptime` aborts. + if std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32") { + println!("cargo::rustc-link-arg-cdylib=--wrap=__wasm_call_ctors"); + } +} diff --git a/starling/src/lib.rs b/starling/src/lib.rs new file mode 100644 index 00000000..788e3376 --- /dev/null +++ b/starling/src/lib.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0-WITH-LLVM-exception + +//! The wasm build of StarlingMonkey: a reactor exporting `wasi:cli/run`, +//! `wasi:http/handler` and `wizer-initialize`. +//! +//! This is a `cdylib` rather than the `starlingmonkey` binary because a bin +//! links `crt1-command.o`, which exports `wasi:cli/run` itself. On +//! wasm32-wasip3 that collides with the `export!`s below, and componentization +//! fails. A `cdylib` links no such startup object. + +#![cfg(target_arch = "wasm32")] + +/// Runs the static constructors at most once. +/// +/// `--wrap=__wasm_call_ctors` (see `build.rs`) redirects every reference to +/// `__wasm_call_ctors` here, so every path that runs the constructors shares +/// one guard. `wit_bindgen::rt::run_ctors_once` guards only its own static, and +/// at least one other path runs them as well: without the wrap, the first +/// `wasi:cli/run` call aborts in SpiderMonkey's `InitializeUptime` with "Must +/// not be called more than once". +#[unsafe(no_mangle)] +pub extern "C" fn __wrap___wasm_call_ctors() { + static mut RAN: bool = false; + // SAFETY: wasm32 is single-threaded, and the constructors this guards run + // before any code that could call it reentrantly. + unsafe { + if RAN { + return; + } + RAN = true; + unsafe extern "C" { + fn __real___wasm_call_ctors(); + } + __real___wasm_call_ctors(); + } +} + +/// The wasm component exports both `wasi:cli/run` and `wasi:http/handler`, so one build serves +/// HTTP and runs as a command. There is no dispatch between them: the host picks which export to +/// call. `wasmtime run` calls the former, `wasmtime serve` the latter. +/// +/// The two differ only in where their configuration comes from. The CLI export is handed argv; +/// the HTTP export is not (`wasmtime serve` passes none), so it reads `STARLINGMONKEY_CONFIG`. +mod wasm_entry { + + use libstarling::config::RuntimeConfig; + + struct StarlingCli; + + impl wasip3::exports::cli::run::Guest for StarlingCli { + async fn run() -> Result<(), ()> { + let config = match RuntimeConfig::from_args(std::env::args()) { + Ok(config) => config, + Err(e) => { + let _ = e.print(); + return Err(()); + } + }; + + libstarling::run(config).await.map_err(|e| { + eprintln!("{e}"); + }) + } + } + + struct StarlingHttp; + + impl wasip3::exports::http::handler::Guest for StarlingHttp { + async fn handle( + request: wasip3::http::types::Request, + ) -> Result { + libstarling::serve_wasm::handle(request).await + } + } + + wasip3::cli::command::export!(StarlingCli); + wasip3::http::service::export!(StarlingHttp); + + /// Pre-initialization entry point for `wasmtime wizer`, which runs it and snapshots the + /// initialized instance. + /// + /// Wizer calls a component-level function export, and a component exports only what its world + /// declares. A bare `#[export_name]` does not survive componentization, so this declares the + /// smallest possible world containing just that function. Its fragment merges with the two + /// `export!`s above into one component exporting all three. + /// + /// The export has to survive the snapshot (`--keep-init-func=true`): dropping it, which is + /// wizer's default, leaves the component referring to a core export that is no longer there, + /// and the result fails to load. See `scripts/test-wizer.sh`. + mod wizer { + wit_bindgen::generate!({ + inline: r#" + package local:wizer; + world wizer { + export wizer-initialize: async func(); + } + "#, + world: "wizer", + }); + + struct Init; + + impl Guest for Init { + async fn wizer_initialize() { + // A failure here would otherwise be baked into the snapshot as a runtime that + // silently starts from scratch on every request. + if let Err(e) = libstarling::serve_wasm::pre_initialize().await { + panic!("pre-initialization failed: {e}"); + } + // Last, so that the clock reading it records is past every timestamp the + // snapshot holds. + libstarling::serve_wasm::mark_resumed_from_snapshot(); + } + } + + export!(Init); + } +} diff --git a/starling/src/main.rs b/starling/src/main.rs index 369b263c..178ed825 100644 --- a/starling/src/main.rs +++ b/starling/src/main.rs @@ -3,7 +3,8 @@ //! StarlingMonkey — a JavaScript runtime built on SpiderMonkey. //! //! Parses command-line arguments into a [`RuntimeConfig`](libstarling::config::RuntimeConfig) -//! and delegates execution to [`libstarling::run`]. +//! and delegates execution to [`libstarling::run`]. The wasm build is the `starling` cdylib in +//! `lib.rs`. On wasm targets this binary is built but does nothing. #[cfg(not(target_arch = "wasm32"))] use std::process::exit; @@ -24,96 +25,18 @@ fn main() { let _ = libstarling::run(config).map_err(|e| println!("{e}")); } -/// The wasm component exports **both** `wasi:cli/run` and `wasi:http/handler`, so one build serves -/// HTTP and runs as a command. There is no dispatch between them: the host picks which export to -/// call — `wasmtime run` the former, `wasmtime serve` the latter. -/// -/// The two differ only in where their configuration comes from. The CLI export is handed argv; -/// the HTTP export is not (`wasmtime serve` passes none), so it reads `STARLINGMONKEY_CONFIG`. -#[cfg(target_arch = "wasm32")] -mod wasm_entry { - - use libstarling::config::RuntimeConfig; - - struct StarlingCli; - - impl wasip3::exports::cli::run::Guest for StarlingCli { - async fn run() -> Result<(), ()> { - let config = match RuntimeConfig::from_args(std::env::args()) { - Ok(config) => config, - Err(e) => { - let _ = e.print(); - return Err(()); - } - }; - - libstarling::run(config).await.map_err(|e| { - eprintln!("{e}"); - }) - } - } - - struct StarlingHttp; - - impl wasip3::exports::http::handler::Guest for StarlingHttp { - async fn handle( - request: wasip3::http::types::Request, - ) -> Result { - libstarling::serve_wasm::handle(request).await - } - } - - wasip3::cli::command::export!(StarlingCli); - wasip3::http::service::export!(StarlingHttp); - - /// Pre-initialization entry point for `wasmtime wizer`, which runs it and snapshots the - /// initialized instance. - /// - /// Wizer calls a component-level function export, and a component exports only what its world - /// declares — a bare `#[export_name]` does not survive componentization. So this declares the - /// smallest possible world containing just that function; its fragment merges with the two - /// `export!`s above into one component exporting all three. - /// - /// The export has to survive the snapshot (`--keep-init-func=true`): dropping it, which is - /// wizer's default, leaves the component referring to a core export that is no longer there, - /// and the result fails to load. See `scripts/test-wizer.sh`. - mod wizer { - wit_bindgen::generate!({ - inline: r#" - package local:wizer; - world wizer { - export wizer-initialize: async func(); - } - "#, - world: "wizer", - }); - - struct Init; - - impl Guest for Init { - async fn wizer_initialize() { - // A failure here would otherwise be baked into the snapshot as a runtime that - // silently starts from scratch on every request. - if let Err(e) = libstarling::serve_wasm::pre_initialize().await { - panic!("pre-initialization failed: {e}"); - } - } - } - - export!(Init); - } -} - #[cfg(target_arch = "wasm32")] fn main() { - unreachable!("On wasm32-wasip3, an exported Guest impl above is the entry point"); + unreachable!("On wasm targets the `starling` cdylib is the entry point, not this binary"); } #[cfg(all(test, not(target_arch = "wasm32")))] #[test] fn cli_runs() { let config = libstarling::config::RuntimeConfig::from_args( - ["starling", "-e", "1 + 1"].iter().map(|s| s.to_string()), + ["starlingmonkey", "-e", "1 + 1"] + .iter() + .map(|s| s.to_string()), ) .unwrap(); libstarling::run(config) diff --git a/starling/tests/exit_status.rs b/starling/tests/exit_status.rs index f96c1409..b1903674 100644 --- a/starling/tests/exit_status.rs +++ b/starling/tests/exit_status.rs @@ -13,7 +13,7 @@ use std::process::Command; /// the exit into a SIGABRT. #[test] fn initializer_error_exits_with_code_one() { - let out = Command::new(env!("CARGO_BIN_EXE_starling")) + let out = Command::new(env!("CARGO_BIN_EXE_starlingmonkey")) .args(["-i", "/nonexistent/init.js", "/nonexistent/app.js"]) .output() .expect("failed to run starling"); @@ -46,7 +46,7 @@ fn initializer_event_loop_semantics() { "if (globalThis._fromInit !== 'ready') { throw new Error('initializer microtask not drained'); }", ) .unwrap(); - let out = Command::new(env!("CARGO_BIN_EXE_starling")) + let out = Command::new(env!("CARGO_BIN_EXE_starlingmonkey")) .args(["-i", &init.to_string_lossy(), &main.to_string_lossy()]) .output() .expect("failed to run starling"); @@ -61,7 +61,7 @@ fn initializer_event_loop_semantics() { // pending when the initializer ends is an error. let init_timer = dir.path().join("init_timer.js"); std::fs::write(&init_timer, "setTimeout(() => {}, 1000);").unwrap(); - let out = Command::new(env!("CARGO_BIN_EXE_starling")) + let out = Command::new(env!("CARGO_BIN_EXE_starlingmonkey")) .args(["-i", &init_timer.to_string_lossy(), &main.to_string_lossy()]) .output() .expect("failed to run starling"); @@ -79,7 +79,7 @@ fn initializer_event_loop_semantics() { "globalThis._fromInit = 'ready'; var t = setTimeout(() => {}, 1000); clearTimeout(t);", ) .unwrap(); - let out = Command::new(env!("CARGO_BIN_EXE_starling")) + let out = Command::new(env!("CARGO_BIN_EXE_starlingmonkey")) .args([ "-i", &init_cleared.to_string_lossy(), diff --git a/tests/wpt-harness/run-wpt.mjs b/tests/wpt-harness/run-wpt.mjs index c333116c..7e416131 100644 --- a/tests/wpt-harness/run-wpt.mjs +++ b/tests/wpt-harness/run-wpt.mjs @@ -14,7 +14,7 @@ // --wpt-root=PATH Path to WPT checkout (default: deps/wpt) // --target=[native|wasm] Execution target (default: native) // --permissive Run with the request restrictions disabled -// --runtime=PATH Path to starling binary (default: target/debug/starling) +// --runtime=PATH Path to the starlingmonkey binary (default: target/debug/starlingmonkey) // --expectations=PATH Path to expectations dir (default: tests/wpt-harness/expectations) // --update-expectations Update expectation files with current results // -v Verbose output @@ -83,7 +83,7 @@ const LogLevel = { Quiet: 0, Verbose: 1, VeryVerbose: 2 }; const config = { // Default automatically adjusted to "target/wasm32-wasip2/debug/starling.wasm" for wasm target. - runtime: "target/debug/starling", + runtime: "target/debug/starlingmonkey", target: "native", // "native" or "wasm" // How the runtime executes each test: "cli" runs it as a one-shot command, "serve" POSTs it to a // long-running server built from the same binary (see wpt-server.js). Serve mode is the @@ -132,7 +132,7 @@ const config = { const ArgParsers = { "--runtime": { - help: `Path to starling binary (default: ${config.runtime})`, + help: `Path to the starlingmonkey binary (default: ${config.runtime})`, cmd: (val) => { config.runtime = val; }, @@ -271,7 +271,7 @@ function applyConfig(argv) { } // When targeting wasm, adjust the runtime path if not explicitly set. - if (config.target === "wasm" && config.runtime === "target/debug/starling") { + if (config.target === "wasm" && config.runtime === "target/debug/starlingmonkey") { config.runtime = "target/wasm32-wasip2/debug/starling.wasm"; }