diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 3c94782b2..a2b0ab865 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -116,8 +116,8 @@ jobs: sed -i '/"examples\/axum",/d' Cargo.toml for example in examples/*/; do example_name=$(basename "$example") - if [ "$example_name" = "coredump" ]; then - echo "Skipping coredump example" + if [ "$example_name" = "coredump" ] || [ "$example_name" = "emscripten-tcp" ]; then + echo "Skipping $example_name example" continue fi echo "Building $example_name" @@ -126,6 +126,66 @@ jobs: cd ../.. done + build-emscripten: + name: Emscripten example + needs: worker-build + runs-on: ubuntu-latest + steps: + - uses: dtolnay/rust-toolchain@1.87.0 + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: ~/.cargo/registry ~/.cargo/git target examples/emscripten-tcp/target + key: ${{ runner.os }}-cargo-emscripten-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-emscripten- + + - name: Cache Emscripten SDK + uses: actions/cache@v4 + with: + path: | + ~/.cache/worker-build/emsdk-* + ~/.cache/worker-build/binaryen-* + key: ${{ runner.os }}-emsdk-${{ hashFiles('worker-build/src/versions.rs', 'worker-build/patches/emscripten/*') }} + + - uses: actions/download-artifact@v4 + with: + name: worker-build + path: ./target/debug + + - name: Make worker-build executable + run: chmod +x ./target/debug/worker-build + + - name: Build wasm-bindgen + run: cd wasm-bindgen && cargo build -p wasm-bindgen-cli --bin wasm-bindgen + + - name: Install emscripten target toolchain + run: rustup toolchain install beta --profile minimal --target wasm32-unknown-emscripten + + - name: Build example (dev) + working-directory: examples/emscripten-tcp + run: WASM_BINDGEN_BIN=../../wasm-bindgen/target/debug/wasm-bindgen ../../target/debug/worker-build --emscripten --dev + + - name: Build example (release) + working-directory: examples/emscripten-tcp + run: WASM_BINDGEN_BIN=../../wasm-bindgen/target/debug/wasm-bindgen ../../target/debug/worker-build --emscripten --release + + - name: Smoke test under wrangler + working-directory: examples/emscripten-tcp + run: | + sed '/^\[build\]/,$d' wrangler.toml > wrangler.ci.toml + npx -y wrangler@4 dev -c wrangler.ci.toml --port 8787 > wrangler.log 2>&1 & + for i in $(seq 1 30); do + curl -sf http://localhost:8787/ > /dev/null && break + sleep 1 + done + curl -sf 'http://localhost:8787/?host=example.com' | tee response.txt + grep -q '^HTTP/1\.' response.txt + cat wrangler.log + rustfmt: name: Formatter runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index a88938e02..6136c8592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,6 +734,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "diffy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3dc2f773b6aaa63b1a7684b8589f670a8a0146a510b74d23a401c882364b49" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "digest" version = "0.10.7" @@ -3258,9 +3267,9 @@ dependencies = [ [[package]] name = "walrus" -version = "0.26.5" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b63a2bc6e4acb4cf69080068a531bad4530791098230e972ffcb64fd1dc266" +checksum = "e124668d1bce62ae1d2183c1e85da2b08aa53f36baa3b975427fa6f0ec2fb1e8" dependencies = [ "anyhow", "gimli", @@ -3837,6 +3846,7 @@ dependencies = [ "cargo_metadata", "clap", "console", + "diffy", "dirs-next", "env_logger", "filetime", diff --git a/Cargo.toml b/Cargo.toml index 10c0f49ef..9f91f8063 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ exclude = [ "examples/coredump", "examples/axum", + "examples/emscripten-tcp", "templates/*", "wasm-bindgen", "wasm-streams", diff --git a/examples/emscripten-tcp/.gitignore b/examples/emscripten-tcp/.gitignore new file mode 100644 index 000000000..b06b4f11c --- /dev/null +++ b/examples/emscripten-tcp/.gitignore @@ -0,0 +1,4 @@ +build +target +node_modules +.wrangler diff --git a/examples/emscripten-tcp/Cargo.lock b/examples/emscripten-tcp/Cargo.lock new file mode 100644 index 000000000..90230e28a --- /dev/null +++ b/examples/emscripten-tcp/Cargo.lock @@ -0,0 +1,835 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "js-sys", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "emscripten-tcp" +version = "0.1.0" +dependencies = [ + "tokio", + "worker", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "git+https://github.com/rust-lang/libc?branch=libc-0.2#31503352774deadf65cc53257b59ee4f3299f644" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "git+https://github.com/guybedford/mio?rev=a62c9e46833fc255c9217ab9aa362c6221ed4401#a62c9e46833fc255c9217ab9aa362c6221ed4401" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "git+https://github.com/guybedford/tokio?branch=emscripten-jspi-hooks#5e7090a7953ffab402b784f939c6e9a6fa07e30f" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "git+https://github.com/guybedford/tokio?branch=emscripten-jspi-hooks#5e7090a7953ffab402b784f939c6e9a6fa07e30f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.6.0" +source = "git+https://github.com/guybedford/wasm-streams?branch=rlib-only#115f0f27380a4f5fa33cbd9427a7107ec94cb557" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "worker" +version = "0.8.5" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.5" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 3.0.5", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.5" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/emscripten-tcp/Cargo.toml b/examples/emscripten-tcp/Cargo.toml new file mode 100644 index 000000000..adcaa839f --- /dev/null +++ b/examples/emscripten-tcp/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "emscripten-tcp" +version = "0.1.0" +edition = "2021" +publish = false + +# Standalone: the emscripten target, beta toolchain and dependency patches +# stay out of the workspace lockfile. +[workspace] + +[dependencies] +worker = { path = "../../worker" } +tokio = { version = "1", default-features = false, features = ["rt", "macros", "net", "io-util", "time"] } + +[profile.release] +opt-level = "s" + +# wasm32-unknown-emscripten support pending upstream releases (see README). +[patch.crates-io] +tokio = { git = "https://github.com/guybedford/tokio", branch = "emscripten-jspi-hooks" } +tokio-macros = { git = "https://github.com/guybedford/tokio", branch = "emscripten-jspi-hooks" } +mio = { git = "https://github.com/guybedford/mio", rev = "a62c9e46833fc255c9217ab9aa362c6221ed4401" } +libc = { git = "https://github.com/rust-lang/libc", branch = "libc-0.2" } +# rlib-only: cargo would otherwise also link its cdylib, which emcc cannot produce. +wasm-streams = { git = "https://github.com/guybedford/wasm-streams", branch = "rlib-only" } +# JSPI on emscripten through its lifecycle hooks (wasm-bindgen/wasm-bindgen#5333). +wasm-bindgen = { path = "../../wasm-bindgen" } +wasm-bindgen-macro-support = { path = "../../wasm-bindgen/crates/macro-support" } +wasm-bindgen-shared = { path = "../../wasm-bindgen/crates/shared" } +wasm-bindgen-futures = { path = "../../wasm-bindgen/crates/futures" } +js-sys = { path = "../../wasm-bindgen/crates/js-sys" } +web-sys = { path = "../../wasm-bindgen/crates/web-sys" } + +[patch."https://github.com/guybedford/libc"] +libc = { git = "https://github.com/rust-lang/libc", branch = "libc-0.2" } diff --git a/examples/emscripten-tcp/README.md b/examples/emscripten-tcp/README.md new file mode 100644 index 000000000..086a1b4b7 --- /dev/null +++ b/examples/emscripten-tcp/README.md @@ -0,0 +1,52 @@ +# Emscripten TCP example + +A `worker-build --emscripten` Worker: stock `tokio::net::TcpStream` on +`wasm32-unknown-emscripten`, parking through JSPI on the host event loop. + +```sh +curl 'http://localhost:8787/?host=example.com' +``` + +connects to port 80 of the host from inside the Worker and returns the HEAD +response; `/do?host=...` does the same from inside a Durable Object. The +handler builds a current-thread Tokio runtime and `block_on`s the request; +every blocking wait suspends the Wasm stack via JSPI and resumes when the +runtime delivers the socket readiness, DNS result or timer. + +## Layout + +An emscripten build links a **bin** target: rustc drives `emcc` as the linker, +which runs `wasm-bindgen` as a post-link step. So handlers live in +`src/main.rs` with an empty `fn main() {}`, and there is no `cdylib`. The +`#[event]` and `#[durable_object]` macros export the handlers as +`#[wasm_bindgen(jspi)]` functions on this target. + +The toolchain links `-sREENTRANT_JSPI`, so each activation of `fetch` runs on +its own shadow stack and concurrent requests to one isolate each drive their +own runtime. + +`rust-toolchain.toml` selects `beta` (`OwnedFd::try_clone` on emscripten, +used by mio's registry, lands in 1.99). + +## Dependency patches + +`wasm32-unknown-emscripten` support for the networking stack is pending +upstream releases. The example's `[patch.crates-io]` carries them; add the +same block to your own Worker: + +| Crate | Source | Why | +| --- | --- | --- | +| tokio, tokio-macros | `guybedford/tokio` branch `emscripten-jspi-hooks` | JSPI parking, the `net` feature and fiber-owned runtime context on emscripten (tokio-rs/tokio#8281 follow-ons) | +| mio | `guybedford/mio` rev `a62c9e4` | epoll selector on emscripten (tokio-rs/mio#1969) | +| libc | `rust-lang/libc` branch `libc-0.2` | emscripten epoll bindings, unreleased | +| wasm-streams | `guybedford/wasm-streams` branch `rlib-only` | rlib-only: cargo would otherwise link its cdylib, which emcc cannot produce | + +## Run + +```sh +npx wrangler dev +``` + +The first build downloads the Emscripten SDK into the worker-build cache. See +the [worker-build README](../../worker-build/README.md#emscripten) for the +toolchain details and overrides. diff --git a/examples/emscripten-tcp/rust-toolchain.toml b/examples/emscripten-tcp/rust-toolchain.toml new file mode 100644 index 000000000..30f2c6daf --- /dev/null +++ b/examples/emscripten-tcp/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "beta" +targets = ["wasm32-unknown-emscripten"] diff --git a/examples/emscripten-tcp/src/main.rs b/examples/emscripten-tcp/src/main.rs new file mode 100644 index 000000000..af6766133 --- /dev/null +++ b/examples/emscripten-tcp/src/main.rs @@ -0,0 +1,62 @@ +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use worker::*; + +fn main() {} + +/// Stock `tokio::net` under JSPI: the current-thread runtime parks by +/// suspending the Wasm stack, so blocking waits run on the host event loop. +fn head(host: &str) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| Error::RustError(e.to_string()))?; + runtime + .block_on(async { + let mut stream = TcpStream::connect((host, 80)).await?; + stream + .write_all(format!("HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n").as_bytes()) + .await?; + let mut out = String::new(); + stream.read_to_string(&mut out).await?; + Ok::<_, std::io::Error>(out) + }) + .map_err(|e| Error::RustError(e.to_string())) +} + +#[event(fetch)] +async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { + let url = req.url()?; + let Some(host) = url + .query_pairs() + .find(|(k, _)| k == "host") + .map(|(_, v)| v.into_owned()) + else { + return Response::ok("usage: /?host=example.com or /do?host=example.com"); + }; + if url.path() == "/do" { + let stub = env.durable_object("PROBE")?.id_from_name(&host)?.get_stub()?; + return stub.fetch_with_str(&format!("https://do/?host={host}")).await; + } + Response::ok(head(&host)?) +} + +/// The same request from inside a Durable Object activation. +#[durable_object] +pub struct Probe; + +impl DurableObject for Probe { + fn new(_state: State, _env: Env) -> Self { + Self + } + + async fn fetch(&self, req: Request) -> Result { + let host = req + .url()? + .query_pairs() + .find(|(k, _)| k == "host") + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| Error::RustError("missing host".into()))?; + Response::ok(head(&host)?) + } +} diff --git a/examples/emscripten-tcp/wrangler.toml b/examples/emscripten-tcp/wrangler.toml new file mode 100644 index 000000000..7b7846a13 --- /dev/null +++ b/examples/emscripten-tcp/wrangler.toml @@ -0,0 +1,15 @@ +name = "emscripten-tcp" +main = "build/index.js" +compatibility_date = "2026-06-02" +# new_module_registry gives the Emscripten glue `import.meta.url`. +compatibility_flags = ["nodejs_compat", "new_module_registry"] + +[build] +command = "cargo install -q worker-build && worker-build --emscripten --release" + +[durable_objects] +bindings = [{ name = "PROBE", class_name = "Probe" }] + +[[migrations]] +tag = "v1" +new_classes = ["Probe"] diff --git a/wasm-bindgen b/wasm-bindgen index 246946fdd..511f92061 160000 --- a/wasm-bindgen +++ b/wasm-bindgen @@ -1 +1 @@ -Subproject commit 246946fddd62163e778c3a1f6afe7264347adceb +Subproject commit 511f9206129c215d143edbf6efa01f8f9e2deb59 diff --git a/worker-build/Cargo.toml b/worker-build/Cargo.toml index 5a021a400..ee7f6dc1b 100644 --- a/worker-build/Cargo.toml +++ b/worker-build/Cargo.toml @@ -11,6 +11,7 @@ description = "This is a tool to be used as a custom build command for a Cloudfl [dependencies] anyhow = "1.0.98" binary-install = "0.4.1" +diffy = "0.5" cargo_metadata = "0.23.1" clap = { version = "4.5", features = ['derive'] } console = "0.16.0" diff --git a/worker-build/README.md b/worker-build/README.md index cb39a57fa..5a5c1e534 100644 --- a/worker-build/README.md +++ b/worker-build/README.md @@ -33,4 +33,57 @@ You can override the default binary lookup/download behavior by setting these en export WASM_BINDGEN_BIN=/path/to/custom/wasm-bindgen export WASM_OPT_BIN=/path/to/custom/wasm-opt worker-build --release -``` \ No newline at end of file +``` +## Emscripten + +`worker-build --emscripten` builds for `wasm32-unknown-emscripten`, giving +Workers a libc, epoll-backed sockets and JSPI-suspending blocking calls, so +crates like stock Tokio `net` run unmodified. The output has the same shape +as a regular build (`build/index.js` plus the Wasm), so `wrangler.toml` only +changes the build command: + +```toml +main = "build/index.js" +compatibility_flags = ["nodejs_compat", "new_module_registry"] + +[build] +command = "cargo install -q worker-build && worker-build --emscripten --release" +``` + +The build links a **bin** target rather than a `cdylib`: rustc drives `emcc` +as the linker, and `emcc` runs `wasm-bindgen` over the linked program as a +post-link step. Put handlers in `src/main.rs` with an empty `fn main() {}`, +or use `--bin NAME` when the package has several bin targets. Any `cdylib` +crate type in the package (or in a dependency) fails the link, since emcc +cannot produce one from a static Rust build. + +On the first run worker-build downloads the pinned Emscripten SDK release into +its cache directory (`~/.cache/worker-build/emsdk-`) and applies the +patches under `worker-build/patches/emscripten/` to the frontend. These are +backports the Rust link depends on that the pinned release does not yet +contain (marker-based `-sWASM_BINDGEN`, hostname resolution under +`-sNODERAWSOCKETS`, and `-sREENTRANT_JSPI` fiber stacks so promising exports +can be entered while another activation is suspended); each is removed as the +pin moves past it. Binaryen comes from a separate release carrying the +`jspi-hooks` pass those fiber stacks need. Installing needs `python3` on +`PATH`; the SDK ships its own LLVM and Node. + +Overrides for local toolchain development: + +- **`EMSCRIPTEN`**: an emscripten frontend checkout (the directory holding + `emcc`), used as-is without patching. +- **`EMSDK`**: an emsdk install providing the LLVM backend (`$EMSDK/upstream`) + and Node. +- **`BINARYEN_ROOT`**: a Binaryen install (the directory holding `bin/wasm-opt`) + with the `jspi-hooks` pass. + +Until wasm-bindgen 0.2.129, debuginfo builds (`--dev`, `--profiling`) need a +wasm-bindgen CLI with +[wasm-bindgen#5328](https://github.com/wasm-bindgen/wasm-bindgen/pull/5328), +whose DWARF output survives the exnref translation; build it from `main` and +point `WASM_BINDGEN_BIN` at it. `--release` builds work with the released CLI. + +Emscripten networking support in the Rust ecosystem is still landing upstream; +the [emscripten-tcp example](../examples/emscripten-tcp) lists the +`[patch.crates-io]` entries a Worker currently adds for Tokio, mio, libc and +wasm-streams. diff --git a/worker-build/patches/emscripten/noderawsockets-dns.patch b/worker-build/patches/emscripten/noderawsockets-dns.patch new file mode 100644 index 000000000..26f4c10dc --- /dev/null +++ b/worker-build/patches/emscripten/noderawsockets-dns.patch @@ -0,0 +1,261 @@ +Subject: [PATCH] Real DNS resolution for getaddrinfo under -sNODERAWSOCKETS + +Backport of emscripten-core/emscripten#27693 to the 6.0.9 release, without +its ChangeLog entry and tests. Under -sNODERAWSOCKETS, getaddrinfo resolves +hostnames through node:dns (suspending on JSPI) instead of handing them to +net.connect, which rejects non-IP hosts on Workers. + +Base-commit: 4e4223852a0835923411059a3929907d7df1232e (tag 6.0.9) + +--- +diff --git a/src/lib/libcore.js b/src/lib/libcore.js +index 5247fbfd06284..4fd4c61a28534 100644 +--- a/src/lib/libcore.js ++++ b/src/lib/libcore.js +@@ -1007,9 +1007,17 @@ addToLibrary({ + return inetPton4(DNS.lookup_name(nameString)); + }, + +- getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl'], +- getaddrinfo__proxy: 'sync', +- getaddrinfo: (node, service, hint, out) => { ++ // Returns an EAI_* code (0 on success, having written the addrinfo list to ++ // *out), or - under NODERAWSOCKETS, for a hostname needing a real DNS lookup - ++ // a thunk producing a Promise of that code, for getaddrinfo to wait on where ++ // the calling stack can suspend. ++ $doGetAddrInfo__internal: true, ++ $doGetAddrInfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl', ++#if NODERAWSOCKETS ++ '$nodeSockHelpers', ++#endif ++ ], ++ $doGetAddrInfo: (node, service, hint, out) => { + // Note getaddrinfo currently only returns a single addrinfo with ai_next defaulting to NULL. When NULL + // hints are specified or ai_family set to AF_UNSPEC or ai_socktype or ai_protocol set to 0 then we + // really should provide a linked list of suitable addrinfo values. +@@ -1055,6 +1063,23 @@ addToLibrary({ + return ai; + } + ++#if NODERAWSOCKETS ++ // Chain one addrinfo per {family, addr} entry, returning the head. ++ function allocaddrinfos(entries) { ++ var head = 0, prev = 0; ++ for (var entry of entries) { ++ var ai = allocaddrinfo(entry.family, type, proto, null, entry.addr, port); ++ if (prev) { ++ {{{ makeSetValue('prev', C_STRUCTS.addrinfo.ai_next, 'ai', '*') }}}; ++ } else { ++ head = ai; ++ } ++ prev = ai; ++ } ++ return head; ++ } ++#endif ++ + if (hint) { + flags = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_flags, 'i32') }}}; + family = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_family, 'i32') }}}; +@@ -1167,6 +1192,21 @@ addToLibrary({ + // + // try as a hostname + // ++#if NODERAWSOCKETS ++ // /etc/hosts first (read through emscripten's FS), then a real node:dns ++ // lookup, which is asynchronous: hand the caller a thunk to wait on. ++ var hosts = nodeSockHelpers.readHosts(node).filter((e) => ++ family === {{{ cDefs.AF_UNSPEC }}} || e.family === family); ++ if (hosts.length) { ++ {{{ makeSetValue('out', '0', 'allocaddrinfos(hosts)', '*') }}}; ++ return 0; ++ } ++ return () => nodeSockHelpers.lookupHost(node, family).then((entries) => { ++ if (typeof entries == 'number') return entries; ++ {{{ makeSetValue('out', '0', 'allocaddrinfos(entries)', '*') }}}; ++ return 0; ++ }); ++#else + // resolve the hostname to a temporary fake address + node = DNS.lookup_name(node); + addr = inetPton4(node); +@@ -1178,6 +1218,41 @@ addToLibrary({ + ai = allocaddrinfo(family, type, proto, null, addr, port); + {{{ makeSetValue('out', '0', 'ai', '*') }}}; + return 0; ++#endif ++ }, ++ ++ getaddrinfo__deps: ['$doGetAddrInfo', ++#if NODERAWSOCKETS && ASYNCIFY ++ '$Asyncify', ++#endif ++ ], ++ getaddrinfo__proxy: 'sync', ++#if NODERAWSOCKETS && (PTHREADS || ASYNCIFY) ++ // A hostname needing a real DNS lookup blocks by returning a Promise, which ++ // a proxied pthread awaits (PROXY_SYNC_ASYNC) and ASYNCIFY/JSPI suspends on. ++ // Every other outcome still returns synchronously. ++ getaddrinfo__async: true, ++#endif ++ getaddrinfo: (node, service, hint, out) => { ++ var ret = doGetAddrInfo(node, service, hint, out); ++#if NODERAWSOCKETS ++ if (typeof ret == 'function') { ++#if PTHREADS ++ if (PThread.currentProxiedOperationCallerThread) return ret(); ++#endif ++#if ASYNCIFY ++ return Asyncify.handleAsync(ret); ++#else ++ // No stack that can wait on the lookup (the event-loop thread itself). ++ return {{{ cDefs.EAI_AGAIN }}}; ++#endif ++ } ++#if PTHREADS ++ // A sync-proxied caller awaits a thenable even for an immediate result. ++ if (PThread.currentProxiedOperationCallerThread) return Promise.resolve(ret); ++#endif ++#endif ++ return ret; + }, + + getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], +diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js +index 890f3cea6a807..c5422ca021427 100644 +--- a/src/lib/libsockfs_node.js ++++ b/src/lib/libsockfs_node.js +@@ -56,7 +56,7 @@ null; + + var NodeSockFSLibrary = { + // Node plumbing shared by the interface methods below. +- $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES'], ++ $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$FS', '$inetPton4', '$inetPton6'], + $nodeSockHelpers: { + // node builtins, resolved once each. getBuiltinModule works in both + // CommonJS and ESM output, with require as the fallback. +@@ -69,6 +69,62 @@ var NodeSockFSLibrary = { + getDgram() { + return nodeSockHelpers.dgramModule ??= (process.getBuiltinModule || require)('dgram'); + }, ++ getDns() { ++ return nodeSockHelpers.dnsModule ??= (process.getBuiltinModule || require)('dns'); ++ }, ++ // Address entries for `name` in /etc/hosts, read fresh through emscripten's ++ // FS on each call so a MEMFS or mounted file is honored as written. A ++ // missing file is simply empty. ++ readHosts(name) { ++ var out = []; ++ var text; ++ try { ++ text = FS.readFile('/etc/hosts', { encoding: 'utf8' }); ++ } catch (e) { ++ return out; ++ } ++ for (var line of text.split('\n')) { ++ var hash = line.indexOf('#'); ++ if (hash !== -1) line = line.slice(0, hash); ++ var parts = line.split(/\s+/).filter((p) => p.length); ++ if (parts.length < 2 || !parts.slice(1).includes(name)) continue; ++ var addr = inetPton4(parts[0]); ++ if (addr !== null) { ++ out.push({ family: {{{ cDefs.AF_INET }}}, addr }); ++ } else if ((addr = inetPton6(parts[0])) !== null) { ++ out.push({ family: {{{ cDefs.AF_INET6 }}}, addr }); ++ } ++ } ++ return out; ++ }, ++ // Resolve a hostname via node:dns for `family` (AF_UNSPEC for both). ++ // Resolves to a list of {family, addr} entries, or an EAI_* code: node:dns ++ // surfaces either getaddrinfo EAI_* names or libuv codes, of which the ++ // transient ones map to EAI_AGAIN and the rest to "name not found". ++ lookupHost(name, family) { ++ var opts = { all: true }; ++ if (family === {{{ cDefs.AF_INET }}}) opts.family = 4; ++ else if (family === {{{ cDefs.AF_INET6 }}}) opts.family = 6; ++ return new Promise((resolve) => { ++ nodeSockHelpers.getDns().lookup(name, opts, (err, addresses) => { ++ if (err) { ++ switch (err.code) { ++ case 'EAI_AGAIN': ++ case 'ETIMEDOUT': ++ case 'ESERVFAIL': ++ case 'EREFUSED': ++ return resolve({{{ cDefs.EAI_AGAIN }}}); ++ default: ++ return resolve({{{ cDefs.EAI_NONAME }}}); ++ } ++ } ++ if (!addresses.length) return resolve({{{ cDefs.EAI_NONAME }}}); ++ resolve(addresses.map((a) => a.family === 6 ? ++ { family: {{{ cDefs.AF_INET6 }}}, addr: inetPton6(a.address) } : ++ { family: {{{ cDefs.AF_INET }}}, addr: inetPton4(a.address) })); ++ }); ++ }); ++ }, + // True when node:dgram exposes both synchronous bindSync and connectSync + // (a recent addition), letting UDP run entirely on the public API. A runtime + // missing either falls back to the private udp_wrap handle, which provides +diff --git a/src/struct_info.json b/src/struct_info.json +index be92ff18a8d9c..9d68f3a0f7659 100644 +--- a/src/struct_info.json ++++ b/src/struct_info.json +@@ -206,7 +206,8 @@ + "NI_NAMEREQD", + "EAI_NONAME", + "EAI_SOCKTYPE", +- "EAI_BADFLAGS" ++ "EAI_BADFLAGS", ++ "EAI_AGAIN" + ], + "structs": { + "addrinfo": [ +diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json +index e266b9eb7d2d8..e8cad551d543c 100644 +--- a/src/struct_info_generated.json ++++ b/src/struct_info_generated.json +@@ -64,6 +64,7 @@ + "EADV": 122, + "EAFNOSUPPORT": 5, + "EAGAIN": 6, ++ "EAI_AGAIN": -3, + "EAI_BADFLAGS": -1, + "EAI_FAMILY": -6, + "EAI_NONAME": -2, +diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json +index 115caf29cd902..c08719f390c1f 100644 +--- a/src/struct_info_generated_wasm64.json ++++ b/src/struct_info_generated_wasm64.json +@@ -64,6 +64,7 @@ + "EADV": 122, + "EAFNOSUPPORT": 5, + "EAGAIN": 6, ++ "EAI_AGAIN": -3, + "EAI_BADFLAGS": -1, + "EAI_FAMILY": -6, + "EAI_NONAME": -2, +diff --git a/system/lib/libc/musl/src/network/freeaddrinfo.c b/system/lib/libc/musl/src/network/freeaddrinfo.c +index c4016d9f7c246..25d5c8f668530 100644 +--- a/system/lib/libc/musl/src/network/freeaddrinfo.c ++++ b/system/lib/libc/musl/src/network/freeaddrinfo.c +@@ -7,11 +7,14 @@ + void freeaddrinfo(struct addrinfo *p) + { + #if __EMSCRIPTEN__ +- // Emscripten's usage of this structure is very simple: we always allocate +- // ai_addr, and do not use the linked list aspect at all. There is also no +- // aliasing with aibuf. +- free(p->ai_addr); +- free(p); ++ // Emscripten allocates each node and its ai_addr separately (no aibuf ++ // block, no aliasing), so walk the list freeing both. ++ while (p) { ++ struct addrinfo *next = p->ai_next; ++ free(p->ai_addr); ++ free(p); ++ p = next; ++ } + #else + size_t cnt; + for (cnt=1; p->ai_next; cnt++, p=p->ai_next); diff --git a/worker-build/patches/emscripten/reentrant-jspi.patch b/worker-build/patches/emscripten/reentrant-jspi.patch new file mode 100644 index 000000000..c5390f885 --- /dev/null +++ b/worker-build/patches/emscripten/reentrant-jspi.patch @@ -0,0 +1,1725 @@ +Subject: [PATCH] JSPI lifecycle hooks, REENTRANT_JSPI fiber stacks and epoll listeners + +Backport of emscripten-core/emscripten#27698, #27699 and the +emscripten_epoll_add_listener / emscripten_epoll_remove_listener API to the +6.0.9 release (the cf-final branch of guybedford/emscripten), without their +tests and docs. -sREENTRANT_JSPI gives each JSPI activation its own shadow +stack so promising exports may be re-entered while another is suspended. +-sJSPI_HOOKS needs a Binaryen with the jspi-hooks pass +(WebAssembly/binaryen#9102); worker-build supplies one. + +Base-commit: 4e4223852a0835923411059a3929907d7df1232e (tag 6.0.9), +after wasm-bindgen-marker.patch and noderawsockets-dns.patch + +--- +diff --git a/src/lib/libasync.js b/src/lib/libasync.js +index b0d59fa..2063f5f 100644 +--- a/src/lib/libasync.js ++++ b/src/lib/libasync.js +@@ -160,16 +160,19 @@ addToLibrary({ + #endif + #if ASYNCIFY == 2 + var exportPattern = {{{ new RegExp(`^(${ASYNCIFY_EXPORTS.join('|').replace(/\*/g, '.*')})$`) }}}; ++#if !JSPI_HOOKS + Asyncify.asyncExports = new Set(); ++#endif + #endif + var ret = {}; + for (let [x, original] of Object.entries(exports)) { + if (typeof original == 'function') { + #if ASYNCIFY == 2 + // Wrap all exports with a promising WebAssembly function. +- let isAsyncifyExport = exportPattern.test(x); +- if (isAsyncifyExport) { ++ if (exportPattern.test(x)) { ++#if !JSPI_HOOKS + Asyncify.asyncExports.add(original); ++#endif + original = Asyncify.makeAsyncFunction(original); + } + ret[x] = original; +@@ -453,13 +456,15 @@ addToLibrary({ + // + // JSPI implementation of Asyncify. + // +- +- // Stores all the exported raw Wasm functions that are wrapped with async +- // WebAssembly.Functions. ++#if !JSPI_HOOKS ++ // The raw wasm exports that were wrapped with WebAssembly.promising; with ++ // the hooks the table holds the unwrapped functions instead, and function ++ // pointers are made promising through the trampolines. + asyncExports: null, + isAsyncExport(func) { + return Asyncify.asyncExports?.has(func); + }, ++#endif + handleAsync: async (startAsync) => { + {{{ runtimeKeepalivePush(); }}} + try { +@@ -478,6 +483,23 @@ addToLibrary({ + #endif + }, + ++#if REENTRANT_JSPI ++ __jspi_fiber_stack_size__sig: 'p', ++ __jspi_fiber_stack_size: () => {{{ JSPI_FIBER_STACK_SIZE }}}, ++ __jspi_fiber_stack_guard__sig: 'p', ++ __jspi_fiber_stack_guard: () => {{{ JSPI_FIBER_STACK_GUARD }}}, ++ __jspi_stack_checked__sig: 'i', ++ __jspi_stack_checked: () => {{{ STACK_OVERFLOW_CHECK >= 2 ? 1 : 0 }}}, ++ // The bounds the stack-check pass instruments against live in globals it ++ // generates, reachable only through its export. ++ __jspi_set_stack_limits__sig: 'vpp', ++ __jspi_set_stack_limits: (base, end) => { ++#if STACK_OVERFLOW_CHECK >= 2 ++ ___set_stack_limits(base, end); ++#endif ++ }, ++#endif ++ + emscripten_sleep__async: 'auto', + emscripten_sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + +diff --git a/src/lib/libcore.js b/src/lib/libcore.js +index 4fd4c61..3e57b11 100644 +--- a/src/lib/libcore.js ++++ b/src/lib/libcore.js +@@ -1814,7 +1814,11 @@ addToLibrary({ + var end = _emscripten_stack_get_end(); + abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}` + + `, with stack limits [${ptrToString(end)} - ${ptrToString(base)}` + ++#if REENTRANT_JSPI ++ ']). If you require more stack space build with -sSTACK_SIZE= (or, inside a JSPI activation, -sJSPI_FIBER_STACK_SIZE=)'); ++#else + ']). If you require more stack space build with -sSTACK_SIZE='); ++#endif + }, + #endif + +@@ -1870,14 +1874,37 @@ addToLibrary({ + var f = dynCalls[sig]; + return f(ptr, ...args); + }, ++#endif ++ + $dynCall__deps: [ + #if DYNCALLS || !WASM_BIGINT + '$dynCallLegacy', + #endif + #if !DYNCALLS + '$getWasmTableEntry', ++#endif ++#if JSPI_HOOKS ++ '$jspiDynCall', + #endif + ], ++ ++#if JSPI_HOOKS ++ // Promising callers of the jspi-hooks trampolines, by signature. ++ $jspiDynCallers: {}, ++ $jspiDynCall__deps: ['$jspiDynCallers'], ++ $jspiDynCall: (sig) => { ++ sig = sig.replace(/p/g, {{{ MEMORY64 ? "'j'" : "'i'" }}}); ++ var caller = jspiDynCallers[sig]; ++ if (!caller) { ++ var trampoline = wasmExports['__jspi_dyncall_' + sig]; ++#if ASSERTIONS ++ assert(trampoline, `no JSPI trampoline for function pointer signature '${sig}': no function with that signature was in the table at link time (see JSPI_HOOKS)`); ++#endif ++ var promising = WebAssembly.promising(trampoline); ++ caller = jspiDynCallers[sig] = (ptr, ...args) => promising({{{ toIndexType('ptr') }}}, ...args); ++ } ++ return caller; ++ }, + #endif + + // Used in library code to get JS function from wasm function pointer. +@@ -1886,7 +1913,7 @@ addToLibrary({ + $getDynCaller__deps: ['$dynCall'], + $getDynCaller: (sig, ptr, promising = false) => { + #if ASSERTIONS && !DYNCALLS +- assert(sig.includes('j') || sig.includes('p'), 'getDynCaller should only be called with i64 sigs') ++ assert(promising || sig.includes('j') || sig.includes('p'), 'getDynCaller should only be called with i64 sigs') + #endif + return (...args) => dynCall(sig, ptr, args, promising); + }, +@@ -1920,13 +1947,19 @@ addToLibrary({ + #if ASSERTIONS + assert(getWasmTableEntry(ptr), `missing table entry in dynCall: ${ptr}`); + #endif +- var func = getWasmTableEntry(ptr); + #if JSPI + if (promising) { +- func = WebAssembly.promising(func); ++#if JSPI_HOOKS ++ // Function pointers are made promising through the per-signature ++ // trampoline exports the jspi-hooks pass generates, so that the fiber ++ // gets its lifecycle hooks like any other promising export. ++ return jspiDynCall(sig)(ptr, ...args).then(convert); ++#else ++ return WebAssembly.promising(getWasmTableEntry(ptr))(...args).then(convert); ++#endif + } + #endif +- var rtn = func(...args); ++ var rtn = getWasmTableEntry(ptr)(...args); + #endif // DYNCALLS + + function convert(rtn) { +@@ -1939,11 +1972,6 @@ addToLibrary({ + #endif + } + +-#if JSPI +- if (promising) { +- return rtn.then(convert); +- } +-#endif + return convert(rtn); + }, + +@@ -1987,13 +2015,13 @@ addToLibrary({ + if (!func) { + /** @suppress {checkTypes} */ + wasmTableMirror[funcPtr] = func = wasmTable.get({{{ toIndexType('funcPtr') }}}); +-#if ASYNCIFY == 2 ++#if ASYNCIFY == 2 && !JSPI_HOOKS + if (Asyncify.isAsyncExport(func)) { + wasmTableMirror[funcPtr] = func = Asyncify.makeAsyncFunction(func); + } + #endif + } +-#if ASSERTIONS && ASYNCIFY != 2 // With JSPI the function stored in the table will be a wrapper. ++#if ASSERTIONS && !(ASYNCIFY == 2 && !JSPI_HOOKS) // Without the hooks the function stored in the table may be a wrapper. + /** @suppress {checkTypes} */ + assert(wasmTable.get({{{ toIndexType('funcPtr') }}}) == func, 'table mirror is out of date'); + #endif +diff --git a/src/lib/libembind.js b/src/lib/libembind.js +index 3542d45..bb0dcfe 100644 +--- a/src/lib/libembind.js ++++ b/src/lib/libembind.js +@@ -815,7 +815,7 @@ var LibraryEmbind = { + }, + + $embind__requireFunction__deps: ['$AsciiToString', '$throwBindingError' +-#if DYNCALLS || !WASM_BIGINT || MEMORY64 || CAN_ADDRESS_2GB ++#if DYNCALLS || !WASM_BIGINT || MEMORY64 || CAN_ADDRESS_2GB || JSPI + , '$getDynCaller' + #endif + ], +@@ -840,13 +840,12 @@ var LibraryEmbind = { + return getDynCaller(signature, rawFunction, isAsync); + } + #endif +- var rtn = getWasmTableEntry(rawFunction); + #if JSPI + if (isAsync) { +- rtn = WebAssembly.promising(rtn); ++ return getDynCaller(signature, rawFunction, true); + } + #endif +- return rtn; ++ return getWasmTableEntry(rawFunction); + #endif + } + +diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js +index dffeb7d..d88f84a 100644 +--- a/src/lib/libepoll.js ++++ b/src/lib/libepoll.js +@@ -4,9 +4,10 @@ + * SPDX-License-Identifier: MIT + */ + +-// epoll(7) for the JS filesystem. The epoll syscalls build on the per-inode +-// readiness wait-queue (FSNode.addListener/notifyListeners) and the synchronous +-// readiness derivation ($pollOne) defined in libsyscall.js. ++// epoll(7) for the JS filesystem. The epoll syscalls and the ++// emscripten_epoll_add_listener extension build on the per-inode readiness ++// wait-queue (FSNode.addListener/notifyListeners) and the synchronous readiness ++// derivation ($pollOne) defined in libsyscall.js. + + var EpollLibrary = { + // An epoll instance's state lives on the stream's `shared` object - the open +@@ -15,19 +16,19 @@ var EpollLibrary = { + // (rdlHead/rdlTail). Each registration arms a persistent listener on the + // watched node's wait-queue at EPOLL_CTL_ADD (not per-wait), feeding the ready + // list on each edge so readiness can be tracked across waits and up a nesting +- // chain. dup(2) yields another fd to the SAME instance (registrations and +- // ready list shared); close(2) drops one reference and only the last close +- // reclaims it (tearing every registration down). An epoll fd can itself be +- // added to another epoll. ++ // chain. dup(2) yields another fd to the SAME instance (registrations, ready ++ // list, and listeners all shared); close(2) drops one reference and only the ++ // last close reclaims it (tearing every registration down). An epoll fd can ++ // itself be added to another epoll. + + // Would a wait on this epoll block - i.e. does no listed registration have a + // genuine ready event? Walks the ready list (O(ready)), masking out the + // reporting-time flags (edge/oneshot/exclusive), and evicts a closed/reused fd + // as it goes (so a set only ever probed, never drained, does not accumulate +- // dead registrations). This is the readiness derivation behind the epoll fd's +- // own poll handler (nesting): a stale ready-list entry (a spurious edge, or +- // one left after its fd was drained then closed) is not a ready event, so it +- // never reports one. ++ // dead registrations). This is the shared readiness derivation behind the ++ // epoll fd's own poll handler (nesting) and the listeners' fire gate: a stale ++ // ready-list entry (a spurious edge, or one left after its fd was drained then ++ // closed) is not a ready event, so neither fires on it. + $epollWouldBlock__internal: true, + $epollWouldBlock__deps: ['$FS', '$pollOne', '$epollEvict'], + $epollWouldBlock: (ep) => { +@@ -45,7 +46,7 @@ var EpollLibrary = { + }, + + $epollNewInstance__internal: true, +- $epollNewInstance__deps: ['$FS', '$epollWouldBlock'], ++ $epollNewInstance__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive'], + $epollNewInstance: () => { + // Its own (detached) node, so the epoll fd can be watched by a parent epoll + // (nesting) and carry the readiness wait-queue methods. Shared across dups. +@@ -66,15 +67,17 @@ var EpollLibrary = { + stream.shared.refcount++; + }, + // close(2): drop one reference. Only the last close reclaims the +- // instance: drop every registration's listener (a fired EPOLLONESHOT has +- // already dropped its own) from its watched node. A surviving dup keeps +- // it all live. ++ // instance: remove any readiness listeners, then drop every ++ // registration's listener (a fired EPOLLONESHOT has already dropped its ++ // own) from its watched node. A surviving dup keeps it all live. + close(stream) { + var ep = stream.shared; + // FS.close already fired POLLNVAL on the (shared) node, waking any + // parent epoll watching this fd so it re-derives and drops the + // now-stale registration (via doEpollWait's shared check). + if (--ep.refcount) return; ++ for (var it of ep.interests.values()) epollClearListener(ep, it); ++ epollReconcileKeepalive(ep); + for (var reg of ep.epoll.values()) { + reg.listener?.listeners.delete(reg.listener.entry); + } +@@ -86,12 +89,78 @@ var EpollLibrary = { + Object.assign(stream.shared, { + node, + epoll: new Map(), ++ // Readiness listeners (emscripten_epoll_add_listener), keyed by ++ // (registering thread, callback). ++ interests: new Map(), ++ // Registrations with a live watched-node listener; keys the listener ++ // keepalive (0 means the set is terminal - it can never fire again). ++ armed: 0, + // Open references (fds) to this instance; the last close reclaims it. + refcount: 1, + }); + return stream; + }, + ++ // Drop one readiness listener: remove its wait-queue entry on the epoll node ++ // and release its holds. The caller reconciles the main keepalive. ++ $epollClearListener__internal: true, ++ $epollClearListener__deps: [ ++#if PTHREADS ++ '$epollDeliveries', '_emscripten_epoll_keepalive_on_thread', ++#endif ++ ], ++ $epollClearListener: (ep, it) => { ++ ep.interests.delete(it.key); ++ it.cleared = true; ++ it.listener.listeners.delete(it.listener.entry); ++#if PTHREADS ++ if (it.keptAlive && it.ownerThread) { ++ __emscripten_epoll_keepalive_on_thread(it.ownerThread, -1); ++ } ++ it.keptAlive = false; ++ // Retire its delivery token; a still-in-flight cross-thread delivery whose ++ // completion arrives after this finds nothing and is dropped. ++ if (it.token) delete epollDeliveries[it.token]; ++#endif ++ }, ++ ++ // Listeners hold the runtime alive only while the epoll can still fire: at ++ // least one listener and one armed registration (Node.js-style, registered ++ // I/O interest holds the loop open; a terminal set releases it). With ++ // pthreads each listener's owner thread (which runs its deliveries) is held ++ // too. ++ $epollReconcileKeepalive__internal: true, ++ $epollReconcileKeepalive__deps: [ ++#if PTHREADS ++ '_emscripten_epoll_keepalive_on_thread', ++#endif ++ ], ++ $epollReconcileKeepalive: (ep) => { ++ var armed = ep.armed > 0; ++#if PTHREADS ++ for (var it of ep.interests.values()) { ++ if (armed != !!it.keptAlive) { ++ it.keptAlive = armed; ++ // ownerThread is 0 when the main thread registered; the main keepalive ++ // below covers it. ++ if (it.ownerThread) { ++ __emscripten_epoll_keepalive_on_thread(it.ownerThread, armed ? 1 : -1); ++ } ++ } ++ } ++#endif ++ var want = armed && ep.interests.size > 0; ++ if (want == !!ep.keepalive) return; ++ ep.keepalive = want; ++#if useRuntimeKeepaliveStack() ++ if (want) { ++ {{{ runtimeKeepalivePush() }}} ++ } else { ++ {{{ runtimeKeepalivePop() }}} ++ } ++#endif ++ }, ++ + // The ready list (Linux's rdllist): registrations whose readiness edge has + // fired but not yet been consumed by a wait, linked intrusively through + // reg.rdlPrev/reg.rdlNext with head/tail on the epoll stream. Membership +@@ -125,19 +194,24 @@ var EpollLibrary = { + // entry at ctl time, and a closed/reused fd seen at derive time (doEpollWait + // or the nesting poll). + $epollEvict__internal: true, +- $epollEvict__deps: ['$readyListRemove'], ++ $epollEvict__deps: ['$readyListRemove', '$epollReconcileKeepalive'], + $epollEvict: (ep, reg) => { + readyListRemove(ep, reg); +- reg.listener?.listeners.delete(reg.listener.entry); +- reg.listener = null; ++ // A fired EPOLLONESHOT already dropped its listener and armed count. ++ if (reg.listener) { ++ reg.listener.listeners.delete(reg.listener.entry); ++ reg.listener = null; ++ ep.armed--; ++ } + ep.epoll.delete(reg.fd); ++ epollReconcileKeepalive(ep); + }, + + // The heavy lifting behind the epoll syscalls. The `__syscall_epoll_*` entry + // points stay in libsyscall.js (like every other syscall) and resolve the + // epoll stream before calling in here, so `ep` is a known-valid epoll stream. + $epollCtl__internal: true, +- $epollCtl__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict'], ++ $epollCtl__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict', '$epollReconcileKeepalive'], + $epollCtl: (ep, op, fd, ev) => { + var target = FS.getStream(fd); + if (!target) return -{{{ cDefs.EBADF }}}; +@@ -227,6 +301,7 @@ var EpollLibrary = { + // EPOLLEXCLUSIVE: when one fd is watched by several epolls, the watched + // node wakes only one of them per edge (round-robin), not all. + }, !!(events & {{{ cDefs.EPOLLEXCLUSIVE }}})); ++ ep.armed++; + } + // Arming is itself an event source (ep_insert/ep_modify): a source-based + // model only learns readiness from edges, so sample the level now - the +@@ -235,6 +310,7 @@ var EpollLibrary = { + readyListAdd(ep, reg); + ep.node.notifyListeners({{{ cDefs.POLLIN }}}); + } ++ epollReconcileKeepalive(ep); + return 0; + }, + +@@ -246,8 +322,9 @@ var EpollLibrary = { + // EPOLL_CTL_MOD; a no-longer-ready (spurious) edge is dropped; a closed/reused + // fd is evicted. + $doEpollWait__internal: true, +- $doEpollWait__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict'], ++ $doEpollWait__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict', '$epollReconcileKeepalive'], + $doEpollWait: (ep, ev, maxevents) => { ++ var disarmed = false; + // Detach the list and drain from the head: re-armed level triggers and the + // unprocessed remainder go back onto ep's now-empty list, so a single pass + // never revisits an entry. O(delivered), not O(registered). +@@ -278,6 +355,8 @@ var EpollLibrary = { + // listener - the watched node stops poking it (no re-arm needed). + node.listener.listeners.delete(node.listener.entry); + node.listener = null; ++ ep.armed--; ++ disarmed = true; + } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { + readyListAdd(ep, node); // level: re-list at tail + } +@@ -296,6 +375,8 @@ var EpollLibrary = { + else ep.rdlTail = tail; + ep.rdlHead = node; + } ++ // Evictions above reconciled themselves. ++ if (disarmed) epollReconcileKeepalive(ep); + return n; + }, + +@@ -345,6 +426,147 @@ var EpollLibrary = { + #endif + return count; + }, ++ ++ // Register a persistent readiness listener on an existing epoll fd: instead of ++ // blocking in epoll_wait, the runtime invokes `callback` on the event loop ++ // whenever the epoll set has ready events waiting to be collected. The callback ++ // receives only `userdata` and does NOT drain the set - to collect the events ++ // it calls epoll_wait(epfd, ..., 0) (a non-blocking, zero-timeout wait) itself. ++ // ++ // Any number of listeners may be added, keyed by (registering thread, ++ // callback). Every listener is signalled while uncollected ready events remain ++ // (broadcast); collectors race, so per-fd EPOLLET/EPOLLONESHOT items are ++ // collected by exactly one of them - the same load balancing as multiple ++ // blocking epoll_wait callers on one epoll. A level fd left undrained ++ // re-signals every tick, an edge fd once per edge. ++ emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$callUserCallback', ++#if PTHREADS ++ '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', ++#endif ++ ], ++ emscripten_epoll_add_listener__proxy: 'sync', ++ emscripten_epoll_add_listener: (epfd, callback, userdata) => { ++ var stream = FS.getStream(epfd); ++ // This is a direct public API (not a syscall), so it returns a positive ++ // errno rather than the -errno syscall convention. ++ if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; ++ // Operate on the shared instance so a listener added on one fd sees ++ // registrations made through any dup of it. ++ var ep = stream.shared; ++ ++#if PTHREADS ++ // __proxy: 'sync' runs this (and every derivation) on the main thread; each ++ // delivery is back-proxied to the registering thread (0 = the main thread ++ // itself, delivered inline). ++ var callerThread = PThread.currentProxiedOperationCallerThread; ++ var key = callerThread + ':' + callback; ++#else ++ var key = callback; ++#endif ++ // Re-adding the same (thread, callback) identity replaces the registration, ++ // just updating userdata. ++ var prev = ep.interests.get(key); ++ if (prev) epollClearListener(ep, prev); ++ ++ var it = {key}; ++#if PTHREADS ++ it.ownerThread = callerThread; ++#endif ++ ep.interests.set(key, it); ++ // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce ++ // them into one delivery per listener on a microtask (the callback must not ++ // run in the producer's/caller's stack; a microtask avoids the setTimeout ++ // clamp). Fire whenever the set is readable, and re-fire while it stays ++ // readable (whether the callback left a level fd undrained, or a drain ++ // re-listed a still-ready level fd). ++ function deliver() { ++ if (it.cleared) return; ++#if PTHREADS ++ // One cross-thread delivery in flight at a time: the registering thread ++ // collects (drains) inside the callback via a proxied epoll_wait, so firing ++ // again before it completes would just re-see the same still-ready level fd ++ // in a tight spin. The delivery's completion (do_epoll_done -> ++ // epoll_delivery_done) clears this and re-wakes. ++ if (it.inflight) return; ++#endif ++ if (epollWouldBlock(ep)) return; // no genuine uncollected ready event ++#if PTHREADS ++ if (callerThread) { ++ it.inflight = true; ++ __emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token); ++ return; ++ } ++#endif ++ callUserCallback(() => {{{ makeDynCall('vp', 'callback') }}}(userdata)); ++ // Still readable (this callback didn't drain, or a still-ready level fd ++ // re-listed): fire again on the next tick. Note this is NOT a blocking ++ // epoll_wait loop - a level-triggered fd that is structurally always ready ++ // (e.g. EPOLLOUT on a writable socket) will re-schedule a microtask each ++ // tick and so starve the event loop; use EPOLLET or remove the listener ++ // for such fds. ++ if (!it.cleared && !epollWouldBlock(ep)) wake(); ++ } ++ function wake() { ++ if (it.scheduled) return; ++ it.scheduled = true; ++ queueMicrotask(() => { ++ it.scheduled = false; ++ deliver(); ++ }); ++ } ++#if PTHREADS ++ // Resume point for a completed cross-thread delivery, keyed by token so the ++ // C completion can find this listener again. ++ if (callerThread) { ++ it.wake = wake; ++ it.token = epollDeliveries.nextToken++; ++ epollDeliveries[it.token] = it; ++ } ++#endif ++ it.listener = ep.node.addListener(wake); ++ epollReconcileKeepalive(ep); ++ wake(); // deliver initial readiness if the set is already ready ++ return 0; ++ }, ++ ++ // Remove the calling thread's listener for `callback`. All listeners are also ++ // removed when the last fd to the instance closes. ++ emscripten_epoll_remove_listener__deps: ['$FS', '$epollClearListener', '$epollReconcileKeepalive'], ++ emscripten_epoll_remove_listener__proxy: 'sync', ++ emscripten_epoll_remove_listener: (epfd, callback) => { ++ var stream = FS.getStream(epfd); ++ if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; ++ var ep = stream.shared; ++#if PTHREADS ++ var key = PThread.currentProxiedOperationCallerThread + ':' + callback; ++#else ++ var key = callback; ++#endif ++ var it = ep.interests.get(key); ++ if (!it) return {{{ cDefs.ENOENT }}}; ++ epollClearListener(ep, it); ++ epollReconcileKeepalive(ep); ++ return 0; ++ }, ++ ++#if PTHREADS ++ // Token -> listener for cross-thread deliveries (numeric keys), plus nextToken: ++ // the next token to hand out. A monotonic token means a stale completion ++ // (listener removed mid-flight) never resolves to a different listener - it ++ // simply finds nothing. ++ $epollDeliveries: {nextToken: 1}, ++ ++ // Called (on the main thread) by the C helper once a cross-thread delivery ++ // finishes on the registering thread: clear the in-flight gate and re-derive, ++ // so a still-ready set delivers its next batch. ++ _emscripten_epoll_delivery_done__deps: ['$epollDeliveries'], ++ _emscripten_epoll_delivery_done: (token) => { ++ var it = epollDeliveries[token]; ++ if (!it) return; // listener was removed while the delivery was in flight ++ it.inflight = false; ++ it.wake(); ++ }, ++#endif + }; + + addToLibrary(EpollLibrary); +diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js +index 89e5cba..3f980d9 100644 +--- a/src/lib/libsigs.js ++++ b/src/lib/libsigs.js +@@ -330,6 +330,7 @@ sigs = { + _emscripten_create_wasm_worker__sig: 'iipip', + _emscripten_dlopen_js__sig: 'vpppp', + _emscripten_dlsync_threads__sig: 'v', ++ _emscripten_epoll_delivery_done__sig: 'vi', + _emscripten_fetch_get_response_headers__sig: 'pipp', + _emscripten_fetch_get_response_headers_length__sig: 'pi', + _emscripten_fs_load_embedded_files__sig: 'vp', +@@ -643,6 +644,8 @@ sigs = { + emscripten_destroy_web_audio_node__sig: 'vi', + emscripten_destroy_worker__sig: 'vi', + emscripten_enter_soft_fullscreen__sig: 'ipp', ++ emscripten_epoll_add_listener__sig: 'iipp', ++ emscripten_epoll_remove_listener__sig: 'iip', + emscripten_err__sig: 'vp', + emscripten_errn__sig: 'vpp', + emscripten_exit_fullscreen__sig: 'i', +diff --git a/src/parseTools.mjs b/src/parseTools.mjs +index e4acfc1..a313b2c 100644 +--- a/src/parseTools.mjs ++++ b/src/parseTools.mjs +@@ -692,6 +692,13 @@ function makeDynCall(sig, funcPtr, promising = false) { + ); + assert(!(DYNCALLS && promising), 'DYNCALLS cannot be used with JSPI'); + ++ if (promising) { ++ // Routed through $dynCall so that the call goes via the jspi-hooks ++ // trampoline for the signature; direct WebAssembly.promising of a table ++ // entry would run the fiber without its lifecycle hooks. ++ return `getDynCaller("${sig}", ${funcPtr}, true)`; ++ } ++ + let args = []; + for (let i = 1; i < sig.length; ++i) { + args.push(`a${i}`); +@@ -762,21 +769,12 @@ Please update to new syntax.`); + return `(() => ${dyncall}(${funcPtr}))`; + } + +- let getWasmTableEntry = `getWasmTableEntry(${funcPtr})`; +- if (promising) { +- getWasmTableEntry = `WebAssembly.promising(${getWasmTableEntry})`; +- } +- ++ const getWasmTableEntry = `getWasmTableEntry(${funcPtr})`; + if (needArgConversion) { + if (needRtnConversion) { +- if (promising) { +- return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}).then(Number))`; +- } else { +- return `((${args}) => Number(${getWasmTableEntry}.call(null, ${callArgs})))`; +- } +- } else { +- return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}))`; ++ return `((${args}) => Number(${getWasmTableEntry}.call(null, ${callArgs})))`; + } ++ return `((${args}) => ${getWasmTableEntry}.call(null, ${callArgs}))`; + } + return getWasmTableEntry; + } +diff --git a/src/settings.js b/src/settings.js +index 7ce3dc7..dc73734 100644 +--- a/src/settings.js ++++ b/src/settings.js +@@ -931,7 +931,9 @@ var JSPI = 0; + // that will call an asynchronous import (listed in ``JSPI_IMPORTS``) must be + // included here. + // +-// By default this includes ``main``. ++// By default this includes ``main``. These exports are also where the ++// :ref:`JSPI lifecycle hooks ` see a fiber being entered and ++// exited (see ``JSPI_HOOKS``). + // [link] + var JSPI_EXPORTS = []; + +@@ -942,9 +944,49 @@ var JSPI_EXPORTS = []; + // + // Note when using JS library files, the function can be marked with + // ``_async:: true`` in the library instead of this setting. ++// These imports are also where the :ref:`JSPI lifecycle hooks ` ++// see a fiber being suspended and resumed (see ``JSPI_HOOKS``). + // [link] + var JSPI_IMPORTS = []; + ++// Instrument the JSPI boundary with the fiber lifecycle hooks of ++// ```` (see :ref:`jspi_lifecycle_hooks`): a post-link binaryen pass ++// wraps the promising exports and suspending imports, and a small runtime ++// library dispatches the events. Function pointers made promising from JS ++// (``dynCall(sig, ptr, args, true)``, Embind ``async()``) then go through a ++// per-signature trampoline export, which exists only for signatures present ++// in the table at link time, and a JSPI export fetched from the table as a ++// function pointer is no longer made promising automatically. Requires ++// ``JSPI``; implied by ``REENTRANT_JSPI``. ++// [link] ++// [experimental] ++var JSPI_HOOKS = false; ++ ++// Run each promising activation on its own shadow stack, so that any number ++// of activations may be suspended at once on a thread and run interleaved ++// (see :ref:`reentrant_jspi_stacks`). Requires ``JSPI`` and implies ``JSPI_HOOKS``; ++// defaults ``STACK_OVERFLOW_CHECK`` to 2 so that a fiber stack overflow traps ++// (set it explicitly to opt out); not supported with dynamic linking. ++// [link] ++// [experimental] ++var REENTRANT_JSPI = false; ++ ++// The size of the shadow stack given to each promising activation under ++// ``REENTRANT_JSPI``, in bytes. Every live activation holds one, so tune it ++// independently of ``STACK_SIZE``, which it defaults to. ++// [link] ++var JSPI_FIBER_STACK_SIZE = 0; ++ ++// The size of the guard region below each ``REENTRANT_JSPI`` activation ++// stack, in bytes, for builds that opt out of ``STACK_OVERFLOW_CHECK=2``: a ++// stack overflow of up to this size stays inside memory the runtime owns and ++// is reported when the activation next suspends or exits, instead of silently ++// corrupting the heap below. Defaults to 0 with ``STACK_OVERFLOW_CHECK=2`` ++// (the bounds check traps at the overflowing store itself) and to 16KB ++// otherwise; 0 disables the guard (the checks at suspension and exit remain). ++// [link] ++var JSPI_FIBER_STACK_GUARD = -1; ++ + // Runtime elements that are exported on Module by default. We used to export + // quite a lot here, but have removed them all. You should use + // EXPORTED_RUNTIME_METHODS for things you want to export from the runtime. +diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h +new file mode 100644 +index 0000000..7093316 +--- /dev/null ++++ b/system/include/emscripten/epoll.h +@@ -0,0 +1,75 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ */ ++ ++#pragma once ++ ++#include ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++// EXPERIMENTAL. This API is new and may change (signature or semantics) over the ++// next few releases. ++// ++// Register a persistent readiness listener on an existing epoll fd (built with ++// epoll_create1/epoll_ctl): instead of blocking in epoll_wait, the runtime ++// invokes `callback` on the event loop whenever the epoll set has ready events ++// waiting to be collected. The callback receives only `userdata`; it does not ++// receive the events. To collect them it calls epoll_wait(epfd, ..., 0) itself ++// - a non-blocking, zero-timeout wait - from within the callback (or later). ++// Unlike epoll_wait it never blocks the calling stack, so it works without ++// ASYNCIFY/JSPI. The callback is delivered on the registering thread's event ++// loop: with pthreads the epoll readiness is tracked on the main thread (the ++// syscalls are proxied there), but each delivery is dispatched back to the ++// thread that added the listener. ++// ++// Any number of listeners may be added, from any threads, identified by the ++// (callback, registering thread) pair; re-adding the same identity just updates ++// `userdata`. Every listener is signalled while uncollected ready events remain ++// (broadcast), and listeners race to collect: per-fd trigger modes distribute ++// events across collectors exactly as between multiple blocking epoll_wait ++// callers on one epoll, so an EPOLLET edge or an EPOLLONESHOT firing is ++// collected by exactly one listener (load balancing), while a level fd keeps ++// signalling every listener until drained. ++// ++// A listener fires on the next event-loop tick while the set has ready events ++// that have not yet been collected, and keeps firing while any remain - it only ++// signals that events are pending, so a callback that does not drain them (via ++// epoll_wait) leaves them pending and re-fires. Whether a given fd is ++// re-reported follows its per-fd trigger mode (set via epoll_ctl) exactly as ++// epoll_wait does, so one epoll can mix modes: ++// - Level-triggered (the default): the fd is reported on the next tick whenever ++// it is ready, and keeps re-firing while it stays ready. The runtime - not ++// the application - drives the loop, so an fd that is structurally always ++// ready (notably EPOLLOUT on a writable socket) will spin the event loop. ++// Use one of the modes below for such fds. ++// - EPOLLET (edge-triggered): reported once per readiness edge and not again ++// until a fresh edge; usually preferable in this model. ++// - EPOLLONESHOT: reported once, then the registration is disabled until you ++// re-arm it with epoll_ctl(EPOLL_CTL_MOD). ++// ++// Listeners keep the runtime alive as long as the set can still fire - i.e. ++// while the epoll has at least one open watched fd. This follows the Node.js ++// model, where registered I/O interest holds the event loop open. Once every ++// watched fd is closed the set is terminal (it can never become ready again) ++// and its listeners stop holding the runtime, so no explicit disposal is ++// required in that case. ++// ++// Listeners are shared instance state: they see registrations made through any ++// dup'd fd, and closing the last fd to the instance removes them all. Returns ++// 0, or a positive errno (EBADF if `epfd` is not an epoll fd). ++typedef void (*em_epoll_callback)(void *userdata); ++int emscripten_epoll_add_listener(int epfd, em_epoll_callback callback, void *userdata); ++ ++// Remove the calling thread's listener for `callback`. Returns 0, EBADF if ++// `epfd` is not an epoll fd, or ENOENT if no such listener is registered. ++int emscripten_epoll_remove_listener(int epfd, em_epoll_callback callback); ++ ++#ifdef __cplusplus ++} ++#endif +diff --git a/system/include/emscripten/jspi.h b/system/include/emscripten/jspi.h +new file mode 100644 +index 0000000..e1d971f +--- /dev/null ++++ b/system/include/emscripten/jspi.h +@@ -0,0 +1,70 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ */ ++ ++#pragma once ++ ++#include ++ ++// Lifecycle hooks for JSPI (-sJSPI) stack-switching fibers (-sJSPI_HOOKS). ++// ++// A fiber is the wasm activation started by a call to a promising export. It ++// is entered once, may be suspended and resumed any number of times while its ++// suspending imports await, and exits once, normally or with an exception. ++// A suspension is a leave of the fiber: while it is suspended other fibers ++// (or the top level) run on the same thread and see the same static storage ++// and shadow stack, so libraries with fiber-affine state stash it on ++// JSPI_SUSPEND and restore it on JSPI_RESUME, with the lifetime bounded by ++// JSPI_ENTER/JSPI_EXIT. Fibers never move between threads. ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++// Hooks run synchronously inside the boundary call, on whatever stack the ++// fiber's code runs on: ENTER before the export body (the fiber is already ++// current), SUSPEND before the import is called with the fiber's frames still ++// live, RESUME after the import returned with the frames live again, and EXIT ++// after the export body returned, with the fiber still current. A trap inside ++// a fiber bypasses the hooks and leaves the JSPI state (and, with ++// REENTRANT_JSPI, the stack pointer) undefined. ++// ++// Events are bit flags so that they combine into the mask for jspi_register. ++typedef enum { ++ // A promising export was called. ++ JSPI_ENTER = 1, ++ // The promising export is returning (or throwing). ++ JSPI_EXIT = 2, ++ // A suspending import is about to be called (it may or may not suspend). ++ JSPI_SUSPEND = 4, ++ // The suspending import has returned (or thrown); the fiber is running again. ++ JSPI_RESUME = 8, ++} jspi_event; ++ ++#define JSPI_ALL (JSPI_ENTER | JSPI_EXIT | JSPI_SUSPEND | JSPI_RESUME) ++ ++// Called for each event of every fiber, inside the fiber's own frames ++// immediately before/after the boundary call. `token` is the hook's own ++// per-fiber value: NULL at the first event the hook sees for a fiber, then ++// whatever the hook returned at the previous event of that fiber; the return ++// value at JSPI_EXIT is ignored. `error` is nonzero when the export or import ++// completed with an exception (a JS exception, a rejected promise or a wasm ++// exception such as a C++ throw), which is rethrown unchanged after the hooks ++// run; hooks cannot inspect it. Hooks must not throw, suspend, or call ++// promising exports. ++typedef void* (*jspi_hook)(jspi_event event, void* token, int error); ++ ++// Registers `fn` for the events in `mask` on the calling thread. Hooks run in ++// registration order. Returns 0, -1 if the program was linked without ++// -sJSPI_HOOKS (no events are ever delivered), or -2 if the table is full ++// (JSPI_MAX_HOOKS registrations per thread). ++int jspi_register(jspi_hook fn, uint32_t mask); ++ ++#define JSPI_MAX_HOOKS 64 ++ ++#ifdef __cplusplus ++} ++#endif +diff --git a/system/lib/jspi/jspi.c b/system/lib/jspi/jspi.c +new file mode 100644 +index 0000000..bdf3fab +--- /dev/null ++++ b/system/lib/jspi/jspi.c +@@ -0,0 +1,325 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ * ++ * Runtime side of the JSPI lifecycle hooks. The __jspi_enter/exit/suspend/ ++ * resume exports (shims in jspi_ops.S calling __jspi_hook_impl here) are ++ * called by the wrappers that binaryen's --jspi-hooks pass places around every ++ * promising export and suspending import; see for the ++ * model. With REENTRANT_JSPI it also gives each fiber its own shadow stack. ++ */ ++ ++#include ++#include ++#include ++#include ++#include ++ ++typedef struct { ++ jspi_hook fn; ++ uint32_t mask; ++} jspi_registration; ++ ++static _Thread_local jspi_registration hooks[JSPI_MAX_HOOKS]; ++static _Thread_local uint32_t hook_count; ++ ++// One record per live fiber, holding each registration's token for it. The ++// record's address is the token the wrappers carry between the events of a ++// pair, and the current record follows the same protocol the stack pointer ++// does: whoever entered or last resumed the fiber is remembered, made current ++// again when the fiber leaves at SUSPEND (the import may return to that code ++// synchronously), refreshed at RESUME and made current at EXIT. ++// ++// With REENTRANT_JSPI each fiber also runs on its own shadow stack, allocated ++// at ENTER and released at EXIT, so any number of fibers can be suspended at ++// once with their frames intact and imports may write through pointers into a ++// suspended fiber's frames. The stack pointer (and the stack limits, for ++// STACK_OVERFLOW_CHECK and emscripten_stack_get_*) are switched at the four ++// events; see site/source/docs/porting/asyncify.rst. ++typedef struct jspi_fiber { ++ struct jspi_fiber* host; ++ void* tokens[JSPI_MAX_HOOKS]; ++#if REENTRANT_JSPI ++ uintptr_t sp; // fiber stack pointer while suspended ++ uintptr_t host_sp; // where the code that entered or resumed us was ++ uintptr_t host_base; ++ uintptr_t host_end; ++ void* stack; ++#endif ++} jspi_fiber; ++ ++// Records for the first fibers alive at once come from a per-thread pool (a ++// free record links to the next through `host`; static storage, which a fiber ++// stack overflow into the heap cannot reach); further ones are heap-allocated. ++#define POOL_FIBERS 64 ++static _Thread_local jspi_fiber pool[POOL_FIBERS]; ++static _Thread_local jspi_fiber* free_list; ++static _Thread_local uint32_t pool_used; ++ ++static jspi_fiber* alloc_fiber(void) { ++ jspi_fiber* f = free_list; ++ if (f) { ++ free_list = f->host; ++ } else if (pool_used < POOL_FIBERS) { ++ f = &pool[pool_used++]; ++ } else { ++ f = emscripten_builtin_malloc(sizeof(jspi_fiber)); ++ if (!f) { ++ emscripten_err("JSPI: out of memory"); ++ abort(); ++ } ++ } ++ *f = (jspi_fiber){0}; ++ return f; ++} ++ ++static void free_fiber(jspi_fiber* f) { ++ if (f >= pool && f < pool + POOL_FIBERS) { ++ f->host = free_list; ++ free_list = f; ++ } else { ++ emscripten_builtin_free(f); ++ } ++} ++ ++// The current fiber lives in a wasm global (per instance, hence per thread); ++// NULL outside any fiber. ++#ifdef __wasm64__ ++#define PTR "i64" ++#else ++#define PTR "i32" ++#endif ++ ++// The current fiber lives in a wasm global (per instance, hence per thread); ++// NULL outside any fiber. ++__asm__(".globaltype __jspi_cur_fiber, " PTR "\n" ++ ".globl __jspi_cur_fiber\n" ++ "__jspi_cur_fiber:\n"); ++ ++static jspi_fiber* get_cur_fiber(void) { ++ jspi_fiber* f; ++ __asm__ volatile("global.get __jspi_cur_fiber\n" ++ "local.set %0" ++ : "=r"(f)); ++ return f; ++} ++ ++static void set_cur_fiber(jspi_fiber* f) { ++ __asm__ volatile("local.get %0\n" ++ "global.set __jspi_cur_fiber" ++ : ++ : "r"(f)); ++} ++ ++__attribute__((noinline)) static void ++dispatch(jspi_event event, jspi_fiber* f, int error) { ++ for (uint32_t i = 0; i < hook_count; i++) { ++ jspi_registration* r = &hooks[i]; ++ if (r->mask & event) { ++ f->tokens[i] = r->fn(event, f->tokens[i], error); ++ } ++ } ++} ++ ++#if REENTRANT_JSPI ++// Released fiber stacks kept for reuse. ++#define STACK_POOL 4 ++static _Thread_local void* stack_pool[STACK_POOL]; ++static _Thread_local int stack_pool_count; ++static _Thread_local size_t stack_size; ++static _Thread_local size_t guard_size; ++ ++size_t __jspi_fiber_stack_size(void); ++size_t __jspi_fiber_stack_guard(void); ++int __jspi_stack_checked(void); ++ ++// The stack limits to install along with the returned stack pointer, and ++// whether the STACK_OVERFLOW_CHECK=2 bounds also need updating. The shim in ++// jspi_ops.S defines these globals and applies them after the impl returned. ++__asm__(".globaltype __jspi_pending_base, " PTR "\n" ++ ".globaltype __jspi_pending_end, " PTR "\n" ++ ".globaltype __jspi_stack_check, i32\n"); ++ ++static void set_pending_limits(uintptr_t base, uintptr_t end) { ++ __asm__ volatile("local.get %0\n" ++ "global.set __jspi_pending_base\n" ++ "local.get %1\n" ++ "global.set __jspi_pending_end" ++ : ++ : "r"(base), "r"(end)); ++} ++ ++// Below each fiber stack lies a guard region of JSPI_FIBER_STACK_GUARD bytes ++// that is ours, so an overflow of up to that size corrupts nothing else, and ++// is caught when the fiber leaves its stack at a suspension or exit: the ++// stack pointer must still be inside the stack and the cookies at its low end ++// intact. STACK_OVERFLOW_CHECK=2 catches the overflowing store itself. ++#define COOKIE0 0x4a535049 ++#define COOKIE1 0x46494252 ++ ++static void write_cookies(uintptr_t low) { ++ ((uint32_t*)low)[0] = COOKIE0; ++ ((uint32_t*)low)[1] = COOKIE1; ++} ++ ++static void check_overflow(uintptr_t low, uintptr_t sp) { ++ if (sp < low || ((uint32_t*)low)[0] != COOKIE0 || ++ ((uint32_t*)low)[1] != COOKIE1) { ++ emscripten_err("REENTRANT_JSPI: fiber stack overflow; increase " ++ "-sJSPI_FIBER_STACK_SIZE"); ++ abort(); ++ } ++} ++ ++// The stack region within a fiber's allocation. ++static uintptr_t stack_low(jspi_fiber* f) { ++ return (uintptr_t)f->stack + guard_size; ++} ++ ++#ifdef __EMSCRIPTEN_PTHREADS__ ++#include ++ ++static pthread_key_t cleanup_key; ++static pthread_once_t cleanup_once = PTHREAD_ONCE_INIT; ++ ++static void thread_cleanup(void* arg) { ++ while (stack_pool_count) { ++ emscripten_builtin_free(stack_pool[--stack_pool_count]); ++ } ++} ++ ++static void create_cleanup_key(void) { ++ pthread_key_create(&cleanup_key, thread_cleanup); ++} ++#endif ++ ++static void init_thread(void) { ++ stack_size = (__jspi_fiber_stack_size() + 15) & ~(size_t)15; ++ guard_size = (__jspi_fiber_stack_guard() + 15) & ~(size_t)15; ++ __asm__ volatile("local.get %0\n" ++ "global.set __jspi_stack_check" ++ : ++ : "r"(__jspi_stack_checked())); ++#ifdef __EMSCRIPTEN_PTHREADS__ ++ pthread_once(&cleanup_once, create_cleanup_key); ++ pthread_setspecific(cleanup_key, (void*)1); ++#endif ++} ++ ++static void alloc_stack(jspi_fiber* f) { ++ if (!stack_size) { ++ init_thread(); ++ } ++ if (stack_pool_count) { ++ f->stack = stack_pool[--stack_pool_count]; ++ } else { ++ f->stack = emscripten_builtin_memalign(16, guard_size + stack_size); ++ if (!f->stack) { ++ emscripten_err("REENTRANT_JSPI: out of memory allocating a fiber stack"); ++ abort(); ++ } ++ write_cookies(stack_low(f)); ++ } ++} ++ ++static void release_stack(jspi_fiber* f) { ++ if (stack_pool_count < STACK_POOL) { ++ stack_pool[stack_pool_count++] = f->stack; ++ } else { ++ emscripten_builtin_free(f->stack); ++ } ++} ++ ++// Switching to the fiber's stack: remember the host's limits (the host may ++// itself be a fiber) and prepare the fiber's. ++static void switch_to_fiber(jspi_fiber* f, uintptr_t host_sp) { ++ f->host_sp = host_sp; ++ f->host_base = emscripten_stack_get_base(); ++ f->host_end = emscripten_stack_get_end(); ++ set_pending_limits(stack_low(f) + stack_size, stack_low(f)); ++} ++ ++static uintptr_t switch_to_host(jspi_fiber* f, uintptr_t sp) { ++ check_overflow(stack_low(f), sp); ++ set_pending_limits(f->host_base, f->host_end); ++ return f->host_sp; ++} ++#endif ++ ++// The implementation behind the hook exports (shims in jspi_ops.S): ++// delivers the event (0 ENTER, 1 EXIT, 2 SUSPEND, 3 RESUME), returns the stack ++// pointer to install and leaves the token to return (the fiber) in the ++// __jspi_token global. ++__asm__(".globaltype __jspi_token, i64\n"); ++ ++static void set_token(jspi_fiber* f) { ++ uint64_t token = (uintptr_t)f; ++ __asm__ volatile("local.get %0\n" ++ "global.set __jspi_token" ++ : ++ : "r"(token)); ++} ++ ++uintptr_t ++__jspi_hook_impl(uintptr_t sp, uint32_t event, uint64_t token, int error) { ++ jspi_fiber* f = (jspi_fiber*)(uintptr_t)token; ++ set_token(NULL); ++ switch ((jspi_event)(1u << event)) { ++ case JSPI_ENTER: ++ f = alloc_fiber(); ++ f->host = get_cur_fiber(); ++ set_cur_fiber(f); ++#if REENTRANT_JSPI ++ alloc_stack(f); ++ switch_to_fiber(f, sp); ++ sp = stack_low(f) + stack_size; ++#endif ++ dispatch(JSPI_ENTER, f, 0); ++ set_token(f); ++ break; ++ case JSPI_EXIT: ++ dispatch(JSPI_EXIT, f, error); ++ set_cur_fiber(f->host); ++#if REENTRANT_JSPI ++ sp = switch_to_host(f, sp); ++ release_stack(f); ++#endif ++ free_fiber(f); ++ break; ++ case JSPI_SUSPEND: ++ f = get_cur_fiber(); ++ // Without a fiber the import is about to fail with a SuspendError. ++ if (f) { ++ dispatch(JSPI_SUSPEND, f, 0); ++ set_cur_fiber(f->host); ++#if REENTRANT_JSPI ++ f->sp = sp; ++ sp = switch_to_host(f, sp); ++#endif ++ } ++ set_token(f); ++ break; ++ case JSPI_RESUME: ++ if (f) { ++ f->host = get_cur_fiber(); ++ set_cur_fiber(f); ++#if REENTRANT_JSPI ++ switch_to_fiber(f, sp); ++ sp = f->sp; ++#endif ++ dispatch(JSPI_RESUME, f, error); ++ } ++ break; ++ } ++ return sp; ++} ++ ++int jspi_register(jspi_hook fn, uint32_t mask) { ++ if (hook_count == JSPI_MAX_HOOKS) { ++ return -2; ++ } ++ hooks[hook_count++] = (jspi_registration){fn, mask}; ++ return 0; ++} +diff --git a/system/lib/jspi/jspi_ops.S b/system/lib/jspi/jspi_ops.S +new file mode 100644 +index 0000000..87acda5 +--- /dev/null ++++ b/system/lib/jspi/jspi_ops.S +@@ -0,0 +1,128 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ * ++ * The JSPI hook exports. Each passes the current __stack_pointer and its event ++ * to the C implementation in jspi.c and installs the stack pointer it returns, ++ * which a C function could not do itself since its epilogue restores the stack ++ * pointer it entered with. Under REENTRANT_JSPI the C side also leaves the stack limits ++ * to install in the __jspi_pending_* globals; they are applied here, after the ++ * C code has fully returned, since with STACK_OVERFLOW_CHECK=2 any C epilogue ++ * running between the limits switch and the stack pointer switch would be ++ * checked against the wrong limits. The token to return is left in the ++ * __jspi_token global. ++ */ ++ ++#ifdef __wasm64__ ++#define PTR i64 ++#else ++#define PTR i32 ++#endif ++ ++.globaltype __stack_pointer, PTR ++ ++.functype __jspi_hook_impl (PTR, i32, i64, i32) -> (PTR) ++ ++.section .globals,"",@ ++ ++.globl __jspi_token ++.globaltype __jspi_token, i64 ++__jspi_token: ++ ++#ifdef REENTRANT_JSPI ++.functype emscripten_stack_set_limits (PTR, PTR) -> () ++.functype __jspi_set_stack_limits (PTR, PTR) -> () ++ ++.globl __jspi_pending_base ++.globaltype __jspi_pending_base, PTR ++__jspi_pending_base: ++.globl __jspi_pending_end ++.globaltype __jspi_pending_end, PTR ++__jspi_pending_end: ++# Nonzero when STACK_OVERFLOW_CHECK=2 instrumented the module, whose bounds ++# globals are only reachable through its export, via JS. ++.globl __jspi_stack_check ++.globaltype __jspi_stack_check, i32 ++__jspi_stack_check: ++#endif ++ ++.section .text,"",@ ++ ++# Common tail: takes the stack pointer the impl returned, installs it (and the ++# pending limits) and returns the token. Called from frameless shims, so the ++# switch is in effect when they return. ++__jspi_finish: ++ .functype __jspi_finish (PTR) -> (i64) ++#ifdef REENTRANT_JSPI ++ # Install the pending stack limits, if any. ++ block ++ global.get __jspi_pending_base ++ PTR.eqz ++ br_if 0 ++ global.get __jspi_pending_base ++ global.get __jspi_pending_end ++ call emscripten_stack_set_limits ++ block ++ global.get __jspi_stack_check ++ i32.eqz ++ br_if 0 ++ global.get __jspi_pending_base ++ global.get __jspi_pending_end ++ call __jspi_set_stack_limits ++ end_block ++ PTR.const 0 ++ global.set __jspi_pending_base ++ end_block ++#endif ++ local.get 0 ++ global.set __stack_pointer ++ global.get __jspi_token ++ end_function ++ ++.globl __jspi_enter ++__jspi_enter: ++ .functype __jspi_enter () -> (i64) ++ global.get __stack_pointer ++ i32.const 0 ++ i64.const 0 ++ i32.const 0 ++ call __jspi_hook_impl ++ call __jspi_finish ++ end_function ++ ++.globl __jspi_exit ++__jspi_exit: ++ .functype __jspi_exit (i64, i32) -> () ++ global.get __stack_pointer ++ i32.const 1 ++ local.get 0 ++ local.get 1 ++ call __jspi_hook_impl ++ call __jspi_finish ++ drop ++ end_function ++ ++.globl __jspi_suspend ++__jspi_suspend: ++ .functype __jspi_suspend () -> (i64) ++ global.get __stack_pointer ++ i32.const 2 ++ i64.const 0 ++ i32.const 0 ++ call __jspi_hook_impl ++ call __jspi_finish ++ end_function ++ ++.globl __jspi_resume ++__jspi_resume: ++ .functype __jspi_resume (i64, i32) -> () ++ global.get __stack_pointer ++ i32.const 3 ++ local.get 0 ++ local.get 1 ++ call __jspi_hook_impl ++ call __jspi_finish ++ drop ++ end_function +diff --git a/system/lib/jspi/jspi_stub.c b/system/lib/jspi/jspi_stub.c +new file mode 100644 +index 0000000..76e0ae6 +--- /dev/null ++++ b/system/lib/jspi/jspi_stub.c +@@ -0,0 +1,12 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ * ++ * Linked in place of jspi.c when JSPI_HOOKS is off: no events are delivered. ++ */ ++ ++#include ++ ++int jspi_register(jspi_hook fn, uint32_t mask) { return -1; } +diff --git a/system/lib/libc/emscripten_internal.h b/system/lib/libc/emscripten_internal.h +index 55ca0fa..8052c61 100644 +--- a/system/lib/libc/emscripten_internal.h ++++ b/system/lib/libc/emscripten_internal.h +@@ -67,6 +67,10 @@ emscripten_stack_unwind_buffer(uintptr_t pc, uintptr_t* buffer, uint32_t depth); + + bool _emscripten_get_now_is_monotonic(void); + ++// Defined in library.js; called by emscripten_epoll_callback.c to report a ++// completed cross-thread epoll callback delivery back to the main thread. ++void _emscripten_epoll_delivery_done(int token); ++ + void _emscripten_get_progname(char*, int); + + // Not defined in musl, but defined in library.js. Included here for +diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c +new file mode 100644 +index 0000000..3cf0670 +--- /dev/null ++++ b/system/lib/pthread/emscripten_epoll_callback.c +@@ -0,0 +1,82 @@ ++/* ++ * Copyright 2026 The Emscripten Authors. All rights reserved. ++ * Emscripten is available under two separate licenses, the MIT license and the ++ * University of Illinois/NCSA Open Source License. Both these licenses can be ++ * found in the LICENSE file. ++ */ ++ ++// Backs emscripten_epoll_add_listener under PTHREADS: the epoll readiness lives ++// on the main thread (the epoll syscalls are proxied there), but the user ++// callback must run on the thread that registered it. This mirrors ++// _emscripten_run_callback_on_thread in html5/callback.c, but reports back to ++// the main thread when a delivery completes so it can pace the next one - the ++// callback collects the ready events (via a proxied epoll_wait) itself, so the ++// main thread must wait for that before firing again, or it would spin ++// re-signalling the same still-ready level fd. ++ ++#include ++#include ++#include ++#include ++ ++#include ++#include ++ ++#include "emscripten_internal.h" ++ ++typedef void (*em_epoll_callback)(void* userdata); ++ ++typedef struct epoll_callback_args_t { ++ em_epoll_callback callback; ++ void* userdata; ++ int token; ++} epoll_callback_args_t; ++ ++// Runs on the registering thread: signal the user callback that events are ++// pending (it collects them itself via epoll_wait). ++static void do_epoll_callback(void* arg) { ++ epoll_callback_args_t* args = (epoll_callback_args_t*)arg; ++ args->callback(args->userdata); ++} ++ ++// Runs back on the main thread once the delivery above has finished (or was ++// cancelled because the target thread went away): let the JS layer re-derive. ++static void do_epoll_done(void* arg) { ++ epoll_callback_args_t* args = (epoll_callback_args_t*)arg; ++ _emscripten_epoll_delivery_done(args->token); ++ free(arg); ++} ++ ++void _emscripten_epoll_run_callback_on_thread(pthread_t t, ++ em_epoll_callback callback, ++ void* userdata, ++ int token) { ++ em_proxying_queue* q = emscripten_proxy_get_system_queue(); ++ epoll_callback_args_t* args = malloc(sizeof(epoll_callback_args_t)); ++ args->callback = callback; ++ args->userdata = userdata; ++ args->token = token; ++ ++ if (!emscripten_proxy_callback( ++ q, t, do_epoll_callback, do_epoll_done, do_epoll_done, args)) { ++ assert(false && "emscripten_proxy_callback failed"); ++ } ++} ++ ++// Adjust the owner thread's (thread-local) runtime keepalive so the epoll ++// callback holds the thread it was registered on, not the main thread. ++static void do_epoll_keepalive(void* arg) { ++ if ((intptr_t)arg > 0) { ++ emscripten_runtime_keepalive_push(); ++ } else { ++ emscripten_runtime_keepalive_pop(); ++ } ++} ++ ++void _emscripten_epoll_keepalive_on_thread(pthread_t t, int delta) { ++ em_proxying_queue* q = emscripten_proxy_get_system_queue(); ++ if (!emscripten_proxy_async( ++ q, t, do_epoll_keepalive, (void*)(intptr_t)delta)) { ++ assert(false && "emscripten_proxy_async failed"); ++ } ++} +diff --git a/tools/building.py b/tools/building.py +index ff48145..ad960c2 100644 +--- a/tools/building.py ++++ b/tools/building.py +@@ -63,6 +63,8 @@ _is_ar_cache: dict[str, bool] = {} + user_requested_exports: set[str] = set() + # JS library symbols exported via the `__export` decorator. + extra_js_exports: set[str] = set() ++# JS library symbols (mangled) that were emitted into the JS output. ++js_library_symbols: set[str] = set() + # Mangled wasm exports wasm-bindgen's glue reaches by name, kept off the public surface. + wasm_bindgen_internal_exports: set[str] = set() + # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, +diff --git a/tools/emscripten.py b/tools/emscripten.py +index 3ad4b0f..8c08c98 100644 +--- a/tools/emscripten.py ++++ b/tools/emscripten.py +@@ -127,8 +127,11 @@ def update_settings_glue(wasm_file, metadata, base_metadata): + + # start with the MVP features, and add any detected features. + building.binaryen_features = ['--mvp-features', *metadata.features] +- if settings.ASYNCIFY == 2: ++ if settings.JSPI: + building.binaryen_features += ['--enable-reference-types'] ++ if settings.JSPI_HOOKS: ++ # The jspi-hooks pass adds exception handling to the module. ++ building.binaryen_features += ['--enable-exception-handling'] + + if settings.PTHREADS: + assert '--enable-threads' in building.binaryen_features +@@ -456,6 +459,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat + pre += "}\n" + + report_missing_exports(forwarded_json['librarySymbols']) ++ building.js_library_symbols.update(forwarded_json['librarySymbols']) + + building.extra_js_exports.update(forwarded_json['extraExports']) + +diff --git a/tools/link.py b/tools/link.py +index 7ef1a65..4cee57e 100644 +--- a/tools/link.py ++++ b/tools/link.py +@@ -386,6 +386,29 @@ def get_binaryen_passes(): + check_human_readable_list(settings.ASYNCIFY_ONLY) + passes += ['--pass-arg=asyncify-onlylist@%s' % ','.join(settings.ASYNCIFY_ONLY)] + ++ if settings.JSPI_HOOKS: ++ if not settings.WASM_LEGACY_EXCEPTIONS: ++ # The hook wrappers use the module's exception handling flavor, so a ++ # module targeting exnref must not still carry legacy instructions from ++ # prebuilt inputs when the pass runs. ++ passes += ['--translate-to-exnref'] ++ # Wrap the promising exports and suspending imports (exactly the sets the ++ # JS wraps in WebAssembly.promising / WebAssembly.Suspending) with the ++ # fiber lifecycle hooks provided by libjspi, plus trampolines for making ++ # function pointers promising. Side modules are not instrumented: their ++ # direct imports of suspending JS functions run without hooks. ++ passes += ['--jspi-hooks'] ++ # The JS matches suspending imports by base name only (see ++ # instrumentWasmImports), so match any module here too. ++ jspi_imports = ['*.' + i.split('.', 1)[1] for i in settings.ASYNCIFY_IMPORTS] ++ passes += [f"--pass-arg=jspi-imports@{','.join(jspi_imports)}"] ++ passes += [f"--pass-arg=jspi-exports@{','.join(settings.ASYNCIFY_EXPORTS)}"] ++ # The trampolines keep every table entry of their signatures alive, so only ++ # emit them when the JS can actually make function pointers promising, ++ # which is only done through $dynCall. ++ if 'dynCall' in building.js_library_symbols: ++ passes += ['--pass-arg=jspi-dyncalls'] ++ + if settings.MEMORY64 == 2: + passes += ['--memory64-lowering', '--table64-lowering'] + +@@ -1008,6 +1031,46 @@ def phase_linker_setup(linker_args): # ruff: ignore[complex-structure, too-many + else: + default_setting('INCOMING_MODULE_JS_API', []) + ++ # JSPI and ASYNCIFY=2 are the same mode. ++ if settings.ASYNCIFY == 2: ++ settings.JSPI = 1 ++ if settings.JSPI: ++ settings.ASYNCIFY = 2 ++ if settings.SIDE_MODULE: ++ # The hooks and fiber stacks belong to the main module's runtime; a side ++ # module contributes no imports or exports to wrap. Like JSPI itself, the ++ # settings are accepted so one flag set serves every link of a build. ++ settings.JSPI_HOOKS = 0 ++ settings.REENTRANT_JSPI = 0 ++ if settings.REENTRANT_JSPI: ++ diagnostics.warning('experimental', 'REENTRANT_JSPI is experimental') ++ if not settings.JSPI: ++ exit_with_error('REENTRANT_JSPI requires JSPI') ++ if 'JSPI_HOOKS' in user_settings and not settings.JSPI_HOOKS: ++ exit_with_error('REENTRANT_JSPI requires JSPI_HOOKS') ++ if settings.MAIN_MODULE: ++ exit_with_error('REENTRANT_JSPI is not compatible with dynamic linking') ++ settings.JSPI_HOOKS = 1 ++ if settings.JSPI_HOOKS: ++ if not settings.REENTRANT_JSPI: ++ diagnostics.warning('experimental', 'JSPI_HOOKS is experimental') ++ if not settings.JSPI: ++ exit_with_error('JSPI_HOOKS requires JSPI') ++ if not settings.WASM_BIGINT: ++ # The hook export takes and returns the fiber token as an i64. ++ exit_with_error('JSPI_HOOKS requires WASM_BIGINT') ++ ++ if settings.REENTRANT_JSPI: ++ # Fiber stacks live in the heap, so the only way to make an overflow trap ++ # at the overflowing store is the bounds check; -sSTACK_OVERFLOW_CHECK=1 or ++ # 0 opts out, leaving the guard region and the checks at suspension/exit. ++ default_setting('STACK_OVERFLOW_CHECK', 2) ++ if not settings.JSPI_FIBER_STACK_SIZE: ++ settings.JSPI_FIBER_STACK_SIZE = settings.STACK_SIZE ++ if settings.JSPI_FIBER_STACK_GUARD < 0: ++ settings.JSPI_FIBER_STACK_GUARD = 0 if settings.STACK_OVERFLOW_CHECK >= 2 else 16 * 1024 ++ settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['__jspi_fiber_stack_size', '__jspi_fiber_stack_guard', '__jspi_stack_checked', '__jspi_set_stack_limits'] ++ + if settings.ASYNCIFY == 1: + # ASYNCIFY=1 wraps only wasm exports so we need to enable legacy + # dyncalls via dynCall_xxx exports. +@@ -1726,11 +1789,15 @@ def phase_linker_setup(linker_args): # ruff: ignore[complex-structure, too-many + if not settings.DISABLE_EXCEPTION_CATCHING: + settings.REQUIRED_EXPORTS += ['setThrew'] + ++ if settings.JSPI_HOOKS: ++ settings.REQUIRED_EXPORTS += ['__jspi_enter', '__jspi_exit', '__jspi_suspend', '__jspi_resume'] ++ + if settings.ASYNCIFY: +- if not settings.ASYNCIFY_IGNORE_INDIRECT: ++ if settings.ASYNCIFY == 1 and not settings.ASYNCIFY_IGNORE_INDIRECT: + # if we are not ignoring indirect calls, then we must treat invoke_* as if + # they are indirect calls, since that is what they do - we can't see their +- # targets statically. ++ # targets statically. (JSPI cannot suspend across the JS frame of an ++ # invoke, so there they are never suspending.) + settings.ASYNCIFY_IMPORTS += ['invoke_*'] + # add the default imports + settings.ASYNCIFY_IMPORTS += DEFAULT_ASYNCIFY_IMPORTS +@@ -2352,6 +2419,13 @@ def phase_binaryen(target, wasm_target): + args=passes, + debug=intermediate_debug_info) + building.save_intermediate(wasm_target, 'byn.wasm') ++ if '--pass-arg=jspi-dyncalls' in passes: ++ # The jspi-hooks pass adds the __jspi_dyncall_* trampoline exports, which ++ # the JS looks up by signature at runtime; keep them through metadce. ++ for e in webassembly.get_exports(wasm_target): ++ if e.name.startswith('__jspi_dyncall_'): ++ settings.WASM_EXPORTS.append(e.name) ++ building.user_requested_exports.add(shared.asmjs_mangle(e.name)) + + if settings.EVAL_CTORS: + with ToolchainProfiler.profile_block('eval_ctors'): +diff --git a/tools/native_sigs.py b/tools/native_sigs.py +index 20a459d..551e3c2 100644 +--- a/tools/native_sigs.py ++++ b/tools/native_sigs.py +@@ -530,6 +530,8 @@ native_sigs = { + '__year_to_secs': '__p', + '_embind_register_bindings': '_p', + '_emscripten_dlsync_self_async': '_p', ++ '_emscripten_epoll_keepalive_on_thread': '_p_', ++ '_emscripten_epoll_run_callback_on_thread': '_ppp_', + '_emscripten_find_dylib': 'ppppp', + '_emscripten_memcpy_bulkmem': 'pppp', + '_emscripten_memset_bulkmem': 'pp_p', +@@ -1126,6 +1128,7 @@ native_sigs = { + 'iswxdigit_l': '__p', + 'isxdigit_l': '__p', + 'jrand48': 'pp', ++ 'jspi_register': '_p_', + 'l64a': 'pp', + 'labs': 'pp', + 'lchmod': '_p_', +diff --git a/tools/settings.py b/tools/settings.py +index f7cda5e..c935f86 100644 +--- a/tools/settings.py ++++ b/tools/settings.py +@@ -24,6 +24,8 @@ MEM_SIZE_SETTINGS = { + 'MAXIMUM_MEMORY', + 'DEFAULT_PTHREAD_STACK_SIZE', + 'ASYNCIFY_STACK_SIZE', ++ 'JSPI_FIBER_STACK_SIZE', ++ 'JSPI_FIBER_STACK_GUARD', + } + + PORTS_SETTINGS = { +diff --git a/tools/system_libs.py b/tools/system_libs.py +index 9ccda61..6b922d9 100644 +--- a/tools/system_libs.py ++++ b/tools/system_libs.py +@@ -1018,6 +1018,43 @@ class libnoexit(Library): + src_files = ['atexit_dummy.c'] + + ++class libjspi(MTLibrary): ++ name = 'libjspi' ++ src_dir = 'system/lib/jspi' ++ ++ def __init__(self, **kwargs): ++ self.hooks = kwargs.pop('hooks') ++ self.is_reentrant = kwargs.pop('is_reentrant') ++ super().__init__(**kwargs) ++ ++ def get_cflags(self): ++ cflags = super().get_cflags() ++ if self.is_reentrant: ++ cflags += ['-DREENTRANT_JSPI'] ++ return cflags ++ ++ @classmethod ++ def vary_on(cls): ++ return super().vary_on() + ['hooks', 'is_reentrant'] ++ ++ def get_base_name(self): ++ name = super().get_base_name() ++ if not self.hooks: ++ name += '-stub' ++ elif self.is_reentrant: ++ name += '-reentrant' ++ return name ++ ++ def get_files(self): ++ if not self.hooks: ++ return [utils.path_from_root('system/lib/jspi/jspi_stub.c')] ++ return files_in_path(path='system/lib/jspi', filenames=['jspi.c', 'jspi_ops.S']) ++ ++ @classmethod ++ def get_default_variation(cls, **kwargs): ++ return super().get_default_variation(hooks=settings.JSPI_HOOKS, is_reentrant=settings.REENTRANT_JSPI, **kwargs) ++ ++ + class llvmlibc(DebugLibrary, AsanInstrumentedLibrary, MTLibrary): + name = 'libllvmlibc' + never_force = True +@@ -1223,6 +1260,7 @@ class libc(MuslInternalLibrary, + 'em_task_queue.c', + 'proxying.c', + 'proxying_legacy.c', ++ 'emscripten_epoll_callback.c', + 'thread_mailbox.c', + 'pthread_create.c', + 'pthread_kill.c', +@@ -2493,6 +2531,8 @@ def get_libs_to_link(): + else: + add_library('libsockets') + ++ add_library('libjspi') ++ + if settings.WASM_WORKERS and (not settings.SINGLE_FILE and not settings.MAIN_MODULE): + # When we include libwasm_workers we use `--whole-archive` to ensure + # that the static constructor (`emscripten_wasm_worker_main_thread_initialize`) diff --git a/worker-build/patches/emscripten/wasm-bindgen-marker.patch b/worker-build/patches/emscripten/wasm-bindgen-marker.patch new file mode 100644 index 000000000..d73f91981 --- /dev/null +++ b/worker-build/patches/emscripten/wasm-bindgen-marker.patch @@ -0,0 +1,250 @@ +Subject: [PATCH] Unify wasm-bindgen output under -sWASM_BINDGEN with marker-based detection + +Backport of emscripten-core/emscripten#27208 (commit 8de9aa023) to the +6.0.9 release, so `-sWASM_BINDGEN` is a plain link switch: emcc detects +the wasm-bindgen marker section in the linker inputs, runs the CLI as a +post-link step, keeps wasm-bindgen's self-registered public exports and +drops its internal expansion glue from the export surface. This is the +only change between the release frontend and upstream main that the +Rust bin-crate link depends on; the release backend (LLVM/Binaryen) +matches this frontend. + +Base-commit: 4e4223852a0835923411059a3929907d7df1232e (tag 6.0.9) + +--- +diff --git a/src/postamble.js b/src/postamble.js +index a474aa572..54ab9bbe3 100644 +--- a/src/postamble.js ++++ b/src/postamble.js +@@ -238,7 +238,14 @@ function checkUnflushedContent() { + #endif // EXIT_RUNTIME + #endif // ASSERTIONS + ++#if WASM_ESM_INTEGRATION && WASM_BINDGEN ++// wasm-bindgen's glue reaches the wasm exports by name on an aggregate object. ++// TODO: Remove once the minimum wasm-bindgen version uses the per-export ++// receiving bindings instead (wasm-bindgen/wasm-bindgen#5270). ++import * as wasmExports from './{{{ WASM_BINARY_FILE }}}'; ++#else + var wasmExports; ++#endif + #if SPLIT_MODULE + var wasmRawExports; + #endif +diff --git a/src/settings.js b/src/settings.js +index 388a2977d..7ce3dc75e 100644 +--- a/src/settings.js ++++ b/src/settings.js +@@ -2236,7 +2236,17 @@ var LEGACY_RUNTIME = false; + // [link] + var SIGNATURE_CONVERSIONS = []; + +-// Run wasm-bindgen and integrate the rust-exported symbols into the rest of Emscripten's JS output. ++// Run wasm-bindgen and integrate the rust-exported symbols into the rest of ++// Emscripten's JS output. ++// Even with this setting enabled, wasm-bindgen processing is only performed ++// when the linker inputs carry the wasm-bindgen Emscripten marker section ++// (emitted by the wasm-bindgen crate). When the marker is absent the build is ++// unchanged, so -sWASM_BINDGEN can safely be passed unconditionally to ++// non-wasm-bindgen builds, and by toolchains that link via emcc. ++// If EXPORTED_FUNCTIONS is set it is taken as the complete export list and ++// must include every export wasm-bindgen reaches by name (rustc supplies this ++// when driving the link). Otherwise those exports are discovered from the ++// linker inputs. + // [link] + // [experimental] + var WASM_BINDGEN = 0; +diff --git a/tools/building.py b/tools/building.py +index fc85d5e61..ff48145bd 100644 +--- a/tools/building.py ++++ b/tools/building.py +@@ -39,6 +39,7 @@ from .shared import ( + LLVM_DWARFDUMP, + LLVM_NM, + LLVM_OBJCOPY, ++ LLVM_OBJDUMP, + WASM_LD, + asmjs_mangle, + check_call, +@@ -62,6 +63,8 @@ _is_ar_cache: dict[str, bool] = {} + user_requested_exports: set[str] = set() + # JS library symbols exported via the `__export` decorator. + extra_js_exports: set[str] = set() ++# Mangled wasm exports wasm-bindgen's glue reaches by name, kept off the public surface. ++wasm_bindgen_internal_exports: set[str] = set() + # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, + # etc.). This is received by the first call to binaryen (e.g. `wasm-emscripten-finalize`) + # which reads it using `--detect-features`. +@@ -307,16 +310,12 @@ def get_wasm_bindgen_exported_symbols(input_files): + return symbols + + +-def lld_flags(args, linker_inputs=None): ++def lld_flags(args): + # lld doesn't currently support --start-group/--end-group since the + # semantics are more like the windows linker where there is no need for + # grouping. + args = [a for a in args if a not in {'--start-group', '--end-group'}] + +- if settings.WASM_BINDGEN: +- exported_symbols = get_wasm_bindgen_exported_symbols(linker_inputs) +- args.extend(f'--export={e}' for e in exported_symbols) +- + # Emscripten currently expects linkable output (SIDE_MODULE/MAIN_MODULE) to + # include all archive contents. + if settings.LINKABLE and (settings.FAKE_DYLIBS or not settings.SIDE_MODULE): +@@ -345,7 +344,7 @@ def lld_flags(args, linker_inputs=None): + return args + + +-def link_lld(args, target, external_symbols=None, linker_inputs=None): ++def link_lld(args, target, external_symbols=None): + # runs lld to link things. + if not os.path.exists(WASM_LD): + exit_with_error('linker binary not found in LLVM directory: %s', WASM_LD) +@@ -354,7 +353,7 @@ def link_lld(args, target, external_symbols=None, linker_inputs=None): + # normal linker flags that are used when building and executable + if '--relocatable' not in args and '-r' not in args: + cmd += lld_flags_for_executable(external_symbols) +- cmd += lld_flags(args, linker_inputs) ++ cmd += lld_flags(args) + cmd = get_command_with_possible_response_file(cmd) + if settings.LINK_AS_CXX: + check_call(cmd) +@@ -1319,6 +1318,13 @@ def run_wasm_opt(infile, outfile=None, args=[], **kwargs): # ruff: ignore[mutab + return run_binaryen_command('wasm-opt', infile, outfile, args=args, **kwargs) + + ++def has_wasm_bindgen_marker(input_files): ++ if not input_files: ++ return False ++ result = check_call([LLVM_OBJDUMP, '--section-headers', *input_files], stdout=PIPE) ++ return '__wasm_bindgen_emscripten_marker' in result.stdout ++ ++ + def run_wasm_bindgen(infile): + bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out') + +@@ -1333,16 +1339,31 @@ def run_wasm_bindgen(infile): + '--out-dir', + bindgen_out_dir, + ] ++ exports_before = {e.name for e in webassembly.get_exports(infile)} ++ + check_call(cmd) + + # Don't try to predict the .wasm filename that wasm-bindgen outputs. Instead + # just grab the .wasm file itself. + all_output_files = os.listdir(bindgen_out_dir) + new_wasm_file = [x for x in all_output_files if x.endswith('.wasm')][0] ++ new_wasm_path = os.path.join(bindgen_out_dir, new_wasm_file) ++ ++ exports_after = {e.name for e in webassembly.get_exports(new_wasm_path)} ++ removed_exports = exports_before - exports_after ++ added_exports = exports_after - exports_before ++ ++ shutil.copyfile(new_wasm_path, infile) + +- shutil.copyfile(os.path.join(bindgen_out_dir, new_wasm_file), infile) ++ # Only emitted when the crate imports JS snippets. ++ extern_pre_js = os.path.join(bindgen_out_dir, 'library_bindgen.extern-pre.js') ++ if not os.path.exists(extern_pre_js): ++ extern_pre_js = None ++ snippets_dir = os.path.join(bindgen_out_dir, 'snippets') ++ if not os.path.isdir(snippets_dir): ++ snippets_dir = None + +- return os.path.join(bindgen_out_dir, 'library_bindgen.js') ++ return os.path.join(bindgen_out_dir, 'library_bindgen.js'), removed_exports, added_exports, extern_pre_js, snippets_dir + + + intermediate_counter = 0 +diff --git a/tools/emscripten.py b/tools/emscripten.py +index f531dee50..3ad4b0f6c 100644 +--- a/tools/emscripten.py ++++ b/tools/emscripten.py +@@ -610,6 +610,7 @@ def finalize_wasm(infile, outfile, js_syms): + expected_exports = set(settings.EXPORTED_FUNCTIONS) + expected_exports.update(asmjs_mangle(s) for s in settings.REQUIRED_EXPORTS) + expected_exports.update(asmjs_mangle(s) for s in settings.EXPORT_IF_DEFINED) ++ expected_exports.update(building.wasm_bindgen_internal_exports) + # Assume that when JS symbol dependencies are exported it is because they + # are needed by by a JS symbol and are not being explicitly exported due + # to EMSCRIPTEN_KEEPALIVE (llvm.used). +@@ -637,7 +638,7 @@ def finalize_wasm(infile, outfile, js_syms): + metadata.all_exports.remove('main') + else: + metadata.all_exports.remove('__main_argc_argv') +- else: ++ elif '_main' not in building.wasm_bindgen_internal_exports: + unexpected_exports.append('_main') + + building.user_requested_exports.update(unexpected_exports) +diff --git a/tools/link.py b/tools/link.py +index 63ac7476b..7ef1a6562 100644 +--- a/tools/link.py ++++ b/tools/link.py +@@ -1891,12 +1891,22 @@ def phase_link(linker_args, linker_inputs, wasm_target, js_syms): + # TODO(sbc): Remove this double execution of wasm-ld if we ever find a way to + # distinguish EMSCRIPTEN_KEEPALIVE exports from `--export-dynamic` exports. + settings.LINKABLE = False +- building.link_lld(linker_args, wasm_target, external_symbols=js_syms, +- linker_inputs=linker_inputs) ++ building.link_lld(linker_args, wasm_target, external_symbols=js_syms) + settings.LINKABLE = True + rtn = extract_metadata.extract_metadata(wasm_target) + +- building.link_lld(linker_args, wasm_target, external_symbols=js_syms, linker_inputs=linker_inputs) ++ # WASM_BINDGEN is a no-op unless the inputs carry the wasm-bindgen marker section. ++ if settings.WASM_BINDGEN and not building.has_wasm_bindgen_marker(linker_inputs): ++ settings.WASM_BINDGEN = 0 ++ ++ # If EXPORTED_FUNCTIONS is provided for WASM_BINDGEN, it forms the authoritative ++ # list of exports of the Wasm module (per rustc linking semantics). ++ # Otherwise, discover the symbols directly from the linker inputs for e.g. static ++ # linking Rust. ++ if settings.WASM_BINDGEN and 'EXPORTED_FUNCTIONS' not in user_settings: ++ linker_args += [f'--export={e}' for e in building.get_wasm_bindgen_exported_symbols(linker_inputs)] ++ ++ building.link_lld(linker_args, wasm_target, external_symbols=js_syms) + return rtn + + +@@ -1919,8 +1929,24 @@ def phase_post_link(in_wasm, wasm_target, target, js_syms, base_metadata=None): + settings.TARGET_JS_NAME = os.path.basename(js_target) + + if settings.WASM_BINDGEN: +- bindgen_jslib = building.run_wasm_bindgen(in_wasm) ++ bindgen_jslib, removed_exports, added_exports, extern_pre_js, snippets_dir = building.run_wasm_bindgen(in_wasm) + settings.JS_LIBRARIES.append(bindgen_jslib) ++ # The exports wasm-bindgen reaches by name (the supplied EXPORTED_FUNCTIONS ++ # plus anything its expansion added) are internal glue only on the Wasm module, ++ # while wasm-bindgen's JS library registers the final user-facing API itself. ++ # Keep EXPORTED_FUNCTIONS off every export layer, and drop the placeholder exports it consumed ++ # (__wbindgen_describe*, etc.) so they aren't reported as undefined. ++ removed = {shared.asmjs_mangle(e) for e in removed_exports} ++ building.wasm_bindgen_internal_exports = ( ++ set(settings.USER_EXPORTS) | {shared.asmjs_mangle(e) for e in added_exports}) ++ drop = removed | building.wasm_bindgen_internal_exports ++ settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in drop] ++ settings.USER_EXPORTS = [e for e in settings.USER_EXPORTS if e not in removed] ++ building.user_requested_exports.clear() ++ if extern_pre_js: ++ options.extern_pre_js.append(extern_pre_js) ++ if snippets_dir: ++ shutil.copytree(snippets_dir, os.path.join(os.path.dirname(js_target), 'snippets'), dirs_exist_ok=True) + + metadata = phase_emscript(in_wasm, wasm_target, js_syms, base_metadata) + +diff --git a/tools/shared.py b/tools/shared.py +index 85d25c86f..7e08205f9 100644 +--- a/tools/shared.py ++++ b/tools/shared.py +@@ -634,6 +634,7 @@ LLVM_RANLIB = llvm_tool_path('llvm-ranlib') + LLVM_NM = llvm_tool_path('llvm-nm') + LLVM_DWARFDUMP = llvm_tool_path('llvm-dwarfdump') + LLVM_OBJCOPY = llvm_tool_path('llvm-objcopy') ++LLVM_OBJDUMP = llvm_tool_path('llvm-objdump') + WASM_LD = llvm_tool_path('wasm-ld') + LLVM_PROFDATA = llvm_tool_path('llvm-profdata') + LLVM_COV = llvm_tool_path('llvm-cov') diff --git a/worker-build/src/binary.rs b/worker-build/src/binary.rs index 3f6bbaba6..709ff1336 100644 --- a/worker-build/src/binary.rs +++ b/worker-build/src/binary.rs @@ -1,6 +1,6 @@ use crate::build::PBAR; use crate::emoji::{CONFIG, DOWN_ARROW}; -use crate::versions::{CUR_ESBUILD_VERSION, CUR_WASM_OPT_VERSION}; +use crate::versions::{CUR_BINARYEN_JSPI_VERSION, CUR_ESBUILD_VERSION, CUR_WASM_OPT_VERSION}; use anyhow::{bail, Context, Result}; use flate2::read::GzDecoder; use heck::ToShoutySnakeCase; @@ -118,13 +118,22 @@ fn remove_all_versions(name: &str, target: &str) -> Result { Ok(deleted_count) } +/// Root of the worker-build cache directory +pub(crate) fn cache_root() -> Result { + let path = dirs_next::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("worker-build"); + if !path.exists() { + create_dir_all(&path) + .with_context(|| format!("Failed to create cache directory {}", path.display()))?; + } + Ok(path) +} + /// Cache path for this binary instance fn cache_path(name: &str, version: &str, target: &str) -> Result { let path_name = format!("{name}-{target}-{version}"); - let path = dirs_next::cache_dir() - .unwrap_or_else(std::env::temp_dir) - .join("worker-build") - .join(&path_name); + let path = cache_root()?.join(&path_name); if !path.exists() { create_dir_all(&path) .with_context(|| format!("Failed to create cache directory {}", path.display()))?; @@ -143,8 +152,8 @@ fn fix_permissions(options: &mut OpenOptions) -> &mut OpenOptions { options } -/// Download this binary instance into its cache path -fn download(url: &str, bin_dir: &Path) -> Result<()> { +/// Download and extract a tar.gz archive, stripping its top-level directory +pub(crate) fn download(url: &str, bin_dir: &Path) -> Result<()> { let agent = ureq::Agent::config_builder() .tls_config( ureq::tls::TlsConfig::builder() @@ -168,9 +177,14 @@ fn download(url: &str, bin_dir: &Path) -> Result<()> { { let mut entry = entry?; let path_stripped = entry.path()?.components().skip(1).collect::(); + let entry_type = entry.header().entry_type(); + // Skip the top-level directory itself, pax headers, and links. + if path_stripped.as_os_str().is_empty() || !(entry_type.is_dir() || entry_type.is_file()) { + continue; + } let bin_path = bin_dir.join(path_stripped); - if entry.header().entry_type().is_dir() { + if entry_type.is_dir() { std::fs::create_dir_all(&bin_path) .with_context(|| format!("Failed to create directory {}", bin_path.display()))?; } else { @@ -324,3 +338,33 @@ impl BinaryDep for WasmBindgen<'_> { }) } } + +/// Binaryen release with the `jspi-hooks` pass (WebAssembly/binaryen#9102), +/// used as the emcc backend for `--emscripten`. +pub struct Binaryen; + +impl BinaryDep for Binaryen { + fn full_name(&self) -> &'static str { + "Binaryen" + } + fn name(&self) -> &'static str { + "binaryen" + } + fn version(&self) -> String { + CUR_BINARYEN_JSPI_VERSION.to_owned() + } + fn target(&self) -> &'static str { + WasmOpt.target() + } + fn download_url(&self) -> String { + let version = self.version(); + let target = self.target(); + format!("https://github.com/guybedford/binaryen/releases/download/{version}/binaryen-{version}-{target}.tar.gz") + } + fn bin_path(&self, name: Option<&str>) -> Result { + Ok(match name { + None | Some("wasm-opt") => format!("bin/wasm-opt{MAYBE_EXE}"), + Some(name) => bail!("Unknown binary {name} in {}", self.full_name()), + }) + } +} diff --git a/worker-build/src/build/manifest.rs b/worker-build/src/build/manifest.rs index cac489fc9..56f5b6508 100644 --- a/worker-build/src/build/manifest.rs +++ b/worker-build/src/build/manifest.rs @@ -426,6 +426,48 @@ impl CrateData { Ok(()) } + /// Resolve the bin target linked by an emscripten build: `--bin NAME`, or + /// the package's only bin target. + pub fn resolve_bin_target(&self, bin: Option<&str>) -> Result { + let pkg = self.pkg(); + // Cargo links every crate type of a lib target when building a bin + // that depends on it, and a cdylib link is a PIC shared object emcc + // cannot produce from a static Rust build. + if pkg + .targets + .iter() + .any(|t| t.crate_types.contains(&CrateType::CDyLib)) + { + bail!( + "crate-type cdylib cannot be linked for wasm32-unknown-emscripten. \ + Use `crate-type = [\"rlib\"]` in [lib], or move the handlers into the bin target." + ); + } + let bins: Vec<&str> = pkg + .targets + .iter() + .filter(|t| t.kind.contains(&TargetKind::Bin)) + .map(|t| t.name.as_str()) + .collect(); + match (bin, bins.as_slice()) { + (Some(name), _) if bins.contains(&name) => Ok(name.to_string()), + (Some(name), _) => bail!("No bin target named `{name}` in this package"), + (None, [only]) => Ok(only.to_string()), + (None, []) => bail!( + "An emscripten build links a bin target: emcc runs wasm-bindgen over the \ + linked program. Add src/main.rs with your handlers and an empty `fn main() {{}}`, \ + or alongside an rlib lib:\n\n\ + use {} as _;\n\ + fn main() {{}}", + self.crate_name() + ), + (None, _) => bail!( + "Multiple bin targets found ({}); select one with --bin NAME", + bins.join(", ") + ), + } + } + fn check_crate_type(&self) -> Result<()> { let pkg = &self.data.packages[self.current_idx]; let any_cdylib = pkg diff --git a/worker-build/src/build/mod.rs b/worker-build/src/build/mod.rs index 69317ae41..2b268ca61 100644 --- a/worker-build/src/build/mod.rs +++ b/worker-build/src/build/mod.rs @@ -1,15 +1,16 @@ use crate::binary::{GetBinary, WasmOpt}; use crate::emoji; +use crate::emscripten; use crate::lockfile::{DepCheckError, Lockfile}; use crate::versions::{ - CUR_WORKER_VERSION, LATEST_WASM_BINDGEN_VERSION, MIN_WASM_BINDGEN_LIB_VERSION, - MIN_WORKER_LIB_VERSION, + CUR_WORKER_VERSION, LATEST_WASM_BINDGEN_VERSION, MIN_EMSCRIPTEN_DEBUG_WASM_BINDGEN_VERSION, + MIN_WASM_BINDGEN_LIB_VERSION, MIN_WORKER_LIB_VERSION, }; mod manifest; mod progressbar; mod target; -mod utils; +pub(crate) mod utils; use console::style; use progressbar::ProgressOutput; @@ -48,6 +49,9 @@ pub struct Build { pub extra_options: Vec, pub wasm_bindgen_version: Option, pub panic_unwind: bool, + pub emscripten: bool, + pub bin: Option, + pub emscripten_toolchain: Option, } /// What sort of output we're going to be generating and flags we're invoking @@ -168,6 +172,16 @@ pub struct BuildOptions { /// with panic=unwind, allowing panics to be caught and converted to /// JavaScript errors instead of aborting the Worker. pub panic_unwind: bool, + + #[clap(long = "emscripten")] + /// Build for wasm32-unknown-emscripten. Links a bin target through emcc, + /// which runs wasm-bindgen post-link, on a worker-build provisioned + /// Emscripten toolchain. + pub emscripten: bool, + + #[clap(long = "bin", requires = "emscripten")] + /// The bin target to link for --emscripten when the package has several. + pub bin: Option, } type BuildStep = fn(&mut Build) -> Result<()>; @@ -226,12 +240,18 @@ impl Build { extra_options: build_opts.extra_options, wasm_bindgen_version: None, panic_unwind: build_opts.panic_unwind, + emscripten: build_opts.emscripten, + bin: build_opts.bin, + emscripten_toolchain: None, }) } /// Prepare this `Build` command. pub fn init(&mut self) -> Result<()> { - let process_steps = Build::get_preprocess_steps(); + if self.emscripten && self.panic_unwind { + bail!("--panic-unwind is not supported with --emscripten"); + } + let process_steps = Build::get_preprocess_steps(self.emscripten); for (_, process_step) in process_steps { process_step(self)?; } @@ -240,7 +260,7 @@ impl Build { /// Execute this `Build` command. pub fn run(&mut self) -> Result<()> { - let process_steps = Build::get_process_steps(self.no_opt); + let process_steps = Build::get_process_steps(self.no_opt, self.emscripten); let started = Instant::now(); @@ -266,7 +286,7 @@ impl Build { } #[allow(clippy::vec_init_then_push)] - fn get_preprocess_steps() -> Vec<(&'static str, BuildStep)> { + fn get_preprocess_steps(emscripten: bool) -> Vec<(&'static str, BuildStep)> { macro_rules! steps { ($($name:ident),+) => { { @@ -277,18 +297,22 @@ impl Build { }; ($($name:ident,)*) => (steps![$($name),*]) } - steps![ + let mut steps = steps![ step_check_rustc_version, step_check_crate_config, step_check_for_wasm_target, step_check_nightly_prerequisites, step_check_lib_versions, step_install_wasm_bindgen, - ] + ]; + if emscripten { + steps.extend(steps![step_provision_emscripten]); + } + steps } #[allow(clippy::vec_init_then_push)] - fn get_process_steps(no_opt: bool) -> Vec<(&'static str, BuildStep)> { + fn get_process_steps(no_opt: bool, emscripten: bool) -> Vec<(&'static str, BuildStep)> { macro_rules! steps { ($($name:ident),+) => { { @@ -300,14 +324,22 @@ impl Build { ($($name:ident,)*) => (steps![$($name),*]) } let mut steps = Vec::new(); - steps.extend(steps![ - step_build_wasm, - step_create_dir, - step_run_wasm_bindgen, - ]); - - if !no_opt { - steps.extend(steps![step_run_wasm_opt]); + // emcc runs wasm-bindgen and wasm-opt itself during the link. + if emscripten { + steps.extend(steps![ + step_build_wasm, + step_create_dir, + step_collect_emscripten_output, + ]); + } else { + steps.extend(steps![ + step_build_wasm, + step_create_dir, + step_run_wasm_bindgen, + ]); + if !no_opt { + steps.extend(steps![step_run_wasm_opt]); + } } steps.extend(steps![step_create_json,]); @@ -324,14 +356,22 @@ impl Build { fn step_check_crate_config(&mut self) -> Result<()> { info!("Checking crate configuration..."); - self.crate_data.check_crate_config()?; + if self.emscripten { + self.bin = Some(self.crate_data.resolve_bin_target(self.bin.as_deref())?); + } else { + self.crate_data.check_crate_config()?; + } info!("Crate is correctly configured."); Ok(()) } fn step_check_for_wasm_target(&mut self) -> Result<()> { info!("Checking for wasm-target..."); - target::check_for_wasm32_target()?; + target::check_for_wasm32_target(if self.emscripten { + target::WASM32_EMSCRIPTEN + } else { + target::WASM32_UNKNOWN + })?; info!("Checking for wasm-target was successful."); Ok(()) } @@ -346,23 +386,94 @@ impl Build { Ok(()) } + fn step_provision_emscripten(&mut self) -> Result<()> { + PBAR.info(&format!( + "{}Checking the Emscripten toolchain...", + emoji::TARGET + )); + self.emscripten_toolchain = Some(emscripten::provision()?); + Ok(()) + } + fn step_build_wasm(&mut self) -> Result<()> { info!("Building wasm..."); + let emscripten = + self.emscripten_toolchain + .as_ref() + .map(|toolchain| target::EmscriptenBuild { + toolchain, + bin: self.bin.as_deref().unwrap(), + bindgen_dir: self.bindgen.as_ref().unwrap().parent().unwrap(), + }); target::cargo_build_wasm( &self.crate_path, self.profile.clone(), &self.extra_options, self.panic_unwind, + emscripten, )?; - info!( - "wasm built at {:#?}.", - &self - .crate_path - .join("target") - .join("wasm32-unknown-unknown") - .join("release") - ); + info!("wasm built at {:#?}.", self.target_out_dir()); + Ok(()) + } + + /// Cargo's output directory for the selected target and profile. + fn target_out_dir(&self) -> PathBuf { + let target_directory = { + let mut iter = self.extra_options.iter(); + iter.find(|&it| it == "--target-dir") + .and_then(|_| iter.next()) + .map(PathBuf::from) + .unwrap_or_else(|| self.crate_data.target_directory().to_path_buf()) + }; + target_directory + .join(if self.emscripten { + target::WASM32_EMSCRIPTEN + } else { + target::WASM32_UNKNOWN + }) + .join(profile_dir(&self.profile)) + } + + /// Copy emcc's `.js` and the wasm it imports into the output + /// directory under the `index` names the rest of the pipeline expects. + fn step_collect_emscripten_output(&mut self) -> Result<()> { + let bin = self.bin.as_deref().unwrap(); + let out = self.target_out_dir(); + let js_path = out.join(format!("{bin}.js")); + let js = std::fs::read_to_string(&js_path) + .with_context(|| format!("Failed to read emcc output {}", js_path.display()))?; + // `import source wasmModule from "./x.wasm";`, possibly minified. + let wasm_name = js + .split("import source ") + .skip(1) + .find_map(|rest| { + let (_, spec) = rest.split_once("from")?; + let spec = spec.trim_start(); + let quote = spec.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let spec = &spec[1..spec[1..].find(quote)? + 1]; + spec.strip_prefix("./").filter(|s| s.ends_with(".wasm")) + }) + .ok_or_else(|| { + anyhow!( + "emcc output {} has no `import source` of its wasm module", + js_path.display() + ) + })?; + let prefix = self.crate_data.name_prefix(); + let wasm_out = format!("{prefix}_bg.wasm"); + std::fs::copy(out.join(wasm_name), self.out_dir.join(&wasm_out)) + .with_context(|| format!("Failed to copy {wasm_name}"))?; + std::fs::write( + self.out_dir.join(format!("{prefix}.js")), + js.replace(&format!("./{wasm_name}"), &format!("./{wasm_out}")), + )?; + // wasm-bindgen inline JS snippets are written beside the link output, + // which cargo does not uplift alongside the js and wasm. + let snippets = out.join("deps/snippets"); + if snippets.is_dir() { + copy_dir(&snippets, &self.out_dir.join("snippets"))?; + } Ok(()) } @@ -427,8 +538,22 @@ impl Build { fn step_install_wasm_bindgen(&mut self) -> Result<()> { info!("Installing wasm-bindgen-cli..."); use crate::binary::{GetBinary, WasmBindgen}; - let (bindgen, bindgen_override) = - WasmBindgen(self.wasm_bindgen_version.as_ref().unwrap()).get_binary(None)?; + let version = self.wasm_bindgen_version.as_ref().unwrap(); + let (bindgen, bindgen_override) = WasmBindgen(version).get_binary(None)?; + // emcc runs wasm-bindgen with --keep-debug; before #5328 its DWARF + // output fails binaryen's exnref translation. + if self.emscripten + && !bindgen_override + && !matches!(self.profile, BuildProfile::Release) + && semver::Version::parse(version)? < *MIN_EMSCRIPTEN_DEBUG_WASM_BINDGEN_VERSION + { + bail!( + "--emscripten debuginfo builds need a wasm-bindgen CLI with wasm-bindgen/wasm-bindgen#5328, \ + unreleased as of {version}. Build with --release, or build the CLI from main and set WASM_BINDGEN_BIN:\n\n \ + cargo install wasm-bindgen-cli --git https://github.com/wasm-bindgen/wasm-bindgen\n \ + export WASM_BINDGEN_BIN=~/.cargo/bin/wasm-bindgen" + ); + } self.bindgen = Some(bindgen); self.bindgen_override = bindgen_override; info!("Installing wasm-bindgen-cli was successful."); @@ -487,6 +612,29 @@ impl Build { } } +fn copy_dir(from: &Path, to: &Path) -> Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +/// Cargo's output directory name for a profile. +fn profile_dir(profile: &BuildProfile) -> &str { + match profile { + BuildProfile::Release | BuildProfile::Profiling => "release", + BuildProfile::Dev => "debug", + BuildProfile::Custom(name) => name, + } +} + /// Run the `wasm-bindgen` CLI to generate bindings for the current crate's /// `.wasm`. #[allow(clippy::too_many_arguments)] @@ -501,11 +649,7 @@ pub fn wasm_bindgen_build( extra_args: &[String], extra_options: &[String], ) -> Result<()> { - let profile_name = match profile.clone() { - BuildProfile::Release | BuildProfile::Profiling => "release", - BuildProfile::Dev => "debug", - BuildProfile::Custom(profile_name) => &profile_name.clone(), - }; + let profile_name = profile_dir(&profile); let out_dir = out_dir.to_str().unwrap(); diff --git a/worker-build/src/build/target.rs b/worker-build/src/build/target.rs index f71e662e7..e01eac1f4 100644 --- a/worker-build/src/build/target.rs +++ b/worker-build/src/build/target.rs @@ -4,6 +4,7 @@ use crate::build::utils; use crate::build::BuildProfile; use crate::build::PBAR; use crate::emoji; +use crate::emscripten::{self, Toolchain}; use crate::versions::MIN_RUSTC_VERSION; use anyhow::{anyhow, bail, Context, Result}; use core::str; @@ -16,7 +17,11 @@ use std::process::Command; const NIGHTLY_TOOLCHAIN: &str = "nightly"; +pub const WASM32_UNKNOWN: &str = "wasm32-unknown-unknown"; +pub const WASM32_EMSCRIPTEN: &str = "wasm32-unknown-emscripten"; + struct Wasm32Check { + target: &'static str, rustc_path: PathBuf, sysroot: PathBuf, found: bool, @@ -25,7 +30,7 @@ struct Wasm32Check { impl fmt::Display for Wasm32Check { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let target = "wasm32-unknown-unknown"; + let target = self.target; if !self.found { let rustup_string = if self.is_rustup { @@ -57,14 +62,14 @@ impl fmt::Display for Wasm32Check { } } -/// Ensure that `rustup` has the `wasm32-unknown-unknown` target installed for +/// Ensure that `rustup` has the given wasm32 target installed for the /// current toolchain -pub fn check_for_wasm32_target() -> Result<()> { +pub fn check_for_wasm32_target(target: &'static str) -> Result<()> { let msg = format!("{}Checking for the Wasm target...", emoji::TARGET); PBAR.info(&msg); // Check if wasm32 target is present, otherwise bail. - match check_wasm32_target() { + match check_wasm32_target(target) { Ok(ref wasm32_check) if wasm32_check.found => Ok(()), Ok(wasm32_check) => bail!("{wasm32_check}"), Err(err) => Err(err), @@ -87,37 +92,32 @@ fn get_rustc_sysroot() -> Result { } } -/// Get wasm32-unknown-unknown target libdir -fn get_rustc_wasm32_unknown_unknown_target_libdir() -> Result { +/// Get the target libdir for a wasm32 target +fn get_rustc_wasm32_target_libdir(target: &str) -> Result { let command = Command::new("rustc") - .args([ - "--target", - "wasm32-unknown-unknown", - "--print", - "target-libdir", - ]) + .args(["--target", target, "--print", "target-libdir"]) .output()?; if command.status.success() { Ok(String::from_utf8(command.stdout)?.trim().into()) } else { Err(anyhow!( - "Getting rustc's wasm32-unknown-unknown target wasn't successful. Got {}", + "Getting rustc's {target} target wasn't successful. Got {}", command.status )) } } -fn does_wasm32_target_libdir_exist() -> bool { - let result = get_rustc_wasm32_unknown_unknown_target_libdir(); +fn does_wasm32_target_libdir_exist(target: &str) -> bool { + let result = get_rustc_wasm32_target_libdir(target); match result { Ok(wasm32_target_libdir_path) => { if wasm32_target_libdir_path.exists() { - info!("Found wasm32-unknown-unknown in {wasm32_target_libdir_path:?}"); + info!("Found {target} in {wasm32_target_libdir_path:?}"); true } else { - info!("Failed to find wasm32-unknown-unknown in {wasm32_target_libdir_path:?}"); + info!("Failed to find {target} in {wasm32_target_libdir_path:?}"); false } } @@ -128,12 +128,13 @@ fn does_wasm32_target_libdir_exist() -> bool { } } -fn check_wasm32_target() -> Result { +fn check_wasm32_target(target: &'static str) -> Result { let sysroot = get_rustc_sysroot()?; let rustc_path = which::which("rustc")?; - if does_wasm32_target_libdir_exist() { + if does_wasm32_target_libdir_exist(target) { Ok(Wasm32Check { + target, rustc_path, sysroot, found: true, @@ -142,9 +143,10 @@ fn check_wasm32_target() -> Result { // If it doesn't exist, then we need to check if we're using rustup. } else { // If sysroot contains "rustup", then we can assume we're using rustup - // and use rustup to add the wasm32-unknown-unknown target. + // and use rustup to add the target. if sysroot.to_string_lossy().contains("rustup") { - rustup_add_wasm_target().map(|()| Wasm32Check { + rustup_add_wasm_target(target).map(|()| Wasm32Check { + target, rustc_path, sysroot, found: true, @@ -152,6 +154,7 @@ fn check_wasm32_target() -> Result { }) } else { Ok(Wasm32Check { + target, rustc_path, sysroot, found: false, @@ -161,11 +164,11 @@ fn check_wasm32_target() -> Result { } } -/// Add wasm32-unknown-unknown using `rustup`. -fn rustup_add_wasm_target() -> Result<()> { +/// Add a wasm32 target using `rustup`. +fn rustup_add_wasm_target(target: &str) -> Result<()> { let mut cmd = Command::new("rustup"); - cmd.arg("target").arg("add").arg("wasm32-unknown-unknown"); - utils::run(cmd, "rustup").context("Adding the wasm32-unknown-unknown target with rustup")?; + cmd.arg("target").arg("add").arg(target); + utils::run(cmd, "rustup").with_context(|| format!("Adding the {target} target with rustup"))?; Ok(()) } @@ -319,6 +322,14 @@ pub fn check_rustc_version() -> Result { } } +/// Append to a space-separated environment variable, preserving user flags. +fn append_env(name: &str, extra: String) -> String { + match std::env::var(name) { + Ok(existing) if !existing.is_empty() => format!("{existing} {extra}"), + _ => extra, + } +} + // from https://github.com/alexcrichton/proc-macro2/blob/79e40a113b51836f33214c6d00228934b41bd4ad/build.rs#L44-L61 fn rustc_minor_version() -> Option { macro_rules! otry { @@ -338,15 +349,27 @@ fn rustc_minor_version() -> Option { otry!(pieces.next()).parse().ok() } -/// Run `cargo build` targetting `wasm32-unknown-unknown`. +/// Emscripten link configuration for `cargo_build_wasm`. +pub struct EmscriptenBuild<'a> { + pub toolchain: &'a Toolchain, + pub bin: &'a str, + /// Directory containing the `wasm-bindgen` CLI emcc runs post-link. + pub bindgen_dir: &'a Path, +} + +/// Run `cargo build` targetting `wasm32-unknown-unknown`, or +/// `wasm32-unknown-emscripten` with emcc as the linker. pub fn cargo_build_wasm( path: &Path, profile: BuildProfile, extra_options: &[String], panic_unwind: bool, + emscripten: Option>, ) -> Result<()> { let msg = if panic_unwind { format!("{}Compiling to Wasm (with panic=unwind)...", emoji::CYCLONE) + } else if emscripten.is_some() { + format!("{}Compiling to Wasm (emscripten)...", emoji::CYCLONE) } else { format!("{}Compiling to Wasm...", emoji::CYCLONE) }; @@ -359,7 +382,11 @@ pub fn cargo_build_wasm( cmd.arg("+nightly"); } - cmd.current_dir(path).arg("build").arg("--lib"); + cmd.current_dir(path).arg("build"); + match &emscripten { + Some(em) => cmd.arg("--bin").arg(em.bin), + None => cmd.arg("--lib"), + }; if PBAR.quiet() { cmd.arg("--quiet"); @@ -386,7 +413,11 @@ pub fn cargo_build_wasm( } } - cmd.arg("--target").arg("wasm32-unknown-unknown"); + cmd.arg("--target").arg(if emscripten.is_some() { + WASM32_EMSCRIPTEN + } else { + WASM32_UNKNOWN + }); // Tell wasm-bindgen's proc-macro to use `js_sys::futures` instead of // `wasm_bindgen_futures`. We pass this as an environment variable rather @@ -398,16 +429,45 @@ pub fn cargo_build_wasm( // When panic_unwind is enabled, rebuild std with panic=unwind and pass // `-Cpanic=unwind`. Combine with any user-provided RUSTFLAGS so we // don't clobber their flags. + let mut rustflags: Vec = Vec::new(); if panic_unwind { cmd.arg("-Z").arg("build-std=std,panic_unwind"); + rustflags.push("-Cpanic=unwind".into()); + } - let existing_rustflags = std::env::var("RUSTFLAGS").unwrap_or_default(); - let rustflags = if existing_rustflags.is_empty() { - "-Cpanic=unwind".to_string() - } else { - format!("{existing_rustflags} -Cpanic=unwind") - }; - cmd.env("RUSTFLAGS", rustflags); + if let Some(em) = &emscripten { + rustflags.extend(emscripten::RUSTFLAGS.iter().map(|f| f.to_string())); + rustflags.extend( + emscripten::LINK_ARGS + .iter() + .map(|arg| format!("-Clink-arg={arg}")), + ); + cmd.env( + "CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER", + em.toolchain.emcc(), + ); + cmd.env("EM_CONFIG", &em.toolchain.em_config); + cmd.env( + "EMCC_CFLAGS", + append_env("EMCC_CFLAGS", emscripten::EMCC_CFLAGS.join(" ")), + ); + let mut paths = vec![ + em.toolchain.emscripten_dir.clone(), + em.bindgen_dir.to_path_buf(), + ]; + paths.extend( + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect::>()) + .unwrap_or_default(), + ); + cmd.env( + "PATH", + std::env::join_paths(paths).context("Building PATH for emcc")?, + ); + } + + if !rustflags.is_empty() { + cmd.env("RUSTFLAGS", append_env("RUSTFLAGS", rustflags.join(" "))); } // The `cargo` command is executed inside the directory at `path`, so relative paths set via extra options won't work. diff --git a/worker-build/src/emscripten.rs b/worker-build/src/emscripten.rs new file mode 100644 index 000000000..d39baba95 --- /dev/null +++ b/worker-build/src/emscripten.rs @@ -0,0 +1,310 @@ +//! Emscripten toolchain provisioning for `--emscripten` builds. +//! +//! A pinned emsdk release is installed under the worker-build cache directory +//! and the patches in `worker-build/patches/emscripten/` are applied to its +//! frontend. Patches are backports the Rust link depends on that the pinned +//! release does not yet contain; each is dropped when the pin moves past it. +//! Binaryen comes from a separate release carrying the jspi-hooks pass. + +use crate::binary::{cache_root, download, Binaryen, GetBinary}; +use crate::build::PBAR; +use crate::emoji::{CONFIG, DOWN_ARROW}; +use crate::versions::CUR_EMSCRIPTEN_VERSION; +use anyhow::{anyhow, bail, Context, Result}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PATCHES: &[(&str, &str)] = &[ + ( + "wasm-bindgen-marker.patch", + include_str!("../patches/emscripten/wasm-bindgen-marker.patch"), + ), + ( + "noderawsockets-dns.patch", + include_str!("../patches/emscripten/noderawsockets-dns.patch"), + ), + ( + "reentrant-jspi.patch", + include_str!("../patches/emscripten/reentrant-jspi.patch"), + ), +]; + +const STAMP: &str = ".worker-build-patches"; + +/// Codegen flags for every target crate. +pub const RUSTFLAGS: &[&str] = &[ + "-Crelocation-model=static", + // exnref exception handling throughout: EMCC_CFLAGS gives the C side + // `-fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0`, the prebuilt std is + // translated at link, and wasm-bindgen's JSPI wrappers use try_table. + // V8 rejects a module mixing the two encodings. + "-Cllvm-args=-wasm-use-legacy-eh=false", + // Tokio's emscripten port owns its runtime context per JSPI fiber through + // the lifecycle hooks the link provides. + "--cfg=tokio_jspi_hooks", +]; + +/// emcc settings for the final link. Kept out of EMCC_CFLAGS so they do not +/// reach C compiles of crates like `ring`, where `-Werror` makes an unused link +/// setting fatal. +pub const LINK_ARGS: &[&str] = &[ + "-sBINARYEN_EXTRA_PASSES=--translate-to-exnref", + "-sWASM_BINDGEN", + "-sJSPI", + // Each JSPI activation runs on its own shadow stack, so a promising export + // may be entered while another activation is suspended. + "-sREENTRANT_JSPI", + "-sMODULARIZE=instance", + "-sEXPORT_ES6", + "-sAUTO_INIT", + "-sSOURCE_PHASE_IMPORTS", + "-sENVIRONMENT=node", + "-sNODERAWSOCKETS", + // With assertions on, shell.js auto-detects the environment at runtime and + // misdetects workerd (which exposes WorkerGlobalScope) as a web worker. + "-sASSERTIONS=0", + "-sALLOW_MEMORY_GROWTH=1", +]; + +pub const EMCC_CFLAGS: &[&str] = &["-fwasm-exceptions", "-sWASM_LEGACY_EXCEPTIONS=0"]; + +pub struct Toolchain { + /// Directory containing `emcc`. + pub emscripten_dir: PathBuf, + pub em_config: PathBuf, +} + +impl Toolchain { + pub fn emcc(&self) -> PathBuf { + self.emscripten_dir.join("emcc") + } +} + +/// Locate or install the toolchain. +/// +/// `EMSCRIPTEN` selects a frontend checkout used as-is (never patched) and +/// `EMSDK` a backend (LLVM, Binaryen, Node) install; when only one is given +/// the other comes from the pinned emsdk under the cache directory. +pub fn provision() -> Result { + let env_frontend = env::var_os("EMSCRIPTEN").map(PathBuf::from); + let env_emsdk = env::var_os("EMSDK").map(PathBuf::from); + + let emsdk = match env_emsdk { + Some(dir) => { + PBAR.info(&format!("{CONFIG}Using EMSDK: {}", dir.display())); + if !dir.join("upstream/bin/clang").exists() { + bail!( + "EMSDK={} has no installed backend at upstream/bin", + dir.display() + ); + } + dir + } + None => provision_emsdk(env_frontend.is_none())?, + }; + + let emscripten_dir = match env_frontend { + Some(dir) => { + PBAR.info(&format!("{CONFIG}Using EMSCRIPTEN: {}", dir.display())); + if !dir.join("emcc").exists() { + bail!("EMSCRIPTEN={} does not contain emcc", dir.display()); + } + dir + } + None => emsdk.join("upstream/emscripten"), + }; + + // -sJSPI_HOOKS runs Binaryen's jspi-hooks pass, which the emsdk release + // does not ship yet. + let binaryen = match env::var_os("BINARYEN_ROOT") { + Some(dir) => { + PBAR.info(&format!( + "{CONFIG}Using BINARYEN_ROOT: {}", + dir.to_string_lossy() + )); + PathBuf::from(dir) + } + None => { + let (wasm_opt, _) = Binaryen.get_binary(None)?; + wasm_opt.parent().unwrap().parent().unwrap().to_path_buf() + } + }; + + let em_config = cache_root()?.join(format!("emscripten-{CUR_EMSCRIPTEN_VERSION}.config")); + write_config(&em_config, &emsdk, &binaryen)?; + + Ok(Toolchain { + emscripten_dir, + em_config, + }) +} + +/// Install the pinned emsdk release into the cache, patching its frontend when +/// it is the one that will be used. +fn provision_emsdk(patch_frontend: bool) -> Result { + let dir = cache_root()?.join(format!("emsdk-{CUR_EMSCRIPTEN_VERSION}")); + let frontend = dir.join("upstream/emscripten"); + let stamp = frontend.join(STAMP); + let expected_stamp = stamp_contents(); + + let installed = dir.join("upstream/bin/clang").exists() && frontend.join("emcc").exists(); + let stamp_ok = fs::read_to_string(&stamp).ok().as_deref() == Some(&expected_stamp); + if installed && (stamp_ok || !patch_frontend) { + return Ok(dir); + } + + let python = which::which("python3") + .or_else(|_| which::which("python")) + .map_err(|_| anyhow!("python3 is required to install the Emscripten SDK"))?; + + if dir.exists() { + // A stale or unstamped install cannot be patched incrementally. + fs::remove_dir_all(&dir).with_context(|| format!("Failed to remove {}", dir.display()))?; + } + fs::create_dir_all(&dir)?; + + PBAR.info(&format!( + "{DOWN_ARROW}Downloading Emscripten SDK {CUR_EMSCRIPTEN_VERSION}..." + )); + download( + &format!( + "https://github.com/emscripten-core/emsdk/archive/refs/tags/{CUR_EMSCRIPTEN_VERSION}.tar.gz" + ), + &dir, + )?; + + PBAR.info(&format!( + "{DOWN_ARROW}Installing Emscripten {CUR_EMSCRIPTEN_VERSION} (LLVM, Binaryen, Node)..." + )); + let mut cmd = Command::new(&python); + cmd.arg(dir.join("emsdk.py")) + .arg("install") + .arg(CUR_EMSCRIPTEN_VERSION) + .current_dir(&dir); + crate::build::utils::run(cmd, "emsdk install")?; + + if patch_frontend { + for (name, contents) in PATCHES { + PBAR.info(&format!("{CONFIG}Applying {name}")); + apply_patch(&frontend, contents).with_context(|| format!("Applying {name}"))?; + } + // The release ships a populated sysroot; drop its stamp so emcc + // reinstalls the system headers the patches add. + let _ = fs::remove_file(frontend.join("cache/sysroot_install.stamp")); + fs::write(&stamp, expected_stamp)?; + } + Ok(dir) +} + +fn stamp_contents() -> String { + PATCHES + .iter() + .map(|(name, contents)| format!("{name} {:016x}\n", fnv1a(contents.as_bytes()))) + .collect() +} + +fn fnv1a(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf29ce484222325u64, |hash, b| { + (hash ^ u64::from(*b)).wrapping_mul(0x100000001b3) + }) +} + +/// Apply a multi-file `git diff` to `root`. Every hunk must apply exactly. +pub fn apply_patch(root: &Path, patch: &str) -> Result<()> { + let mut applied = 0; + for chunk in patch.split("\ndiff --git ").skip(1) { + let Some(start) = chunk.find("\n--- ") else { + continue; + }; + // Splitting consumed the chunk's trailing newline; diffy otherwise + // treats the final context line as lacking one. + let text = format!("{}\n", &chunk[start + 1..]); + let file_patch = + diffy::Patch::from_str(&text).map_err(|e| anyhow!("Invalid patch: {e}"))?; + let target = file_patch + .modified() + .and_then(|p| p.strip_prefix("b/")) + .ok_or_else(|| anyhow!("Patch chunk without a b/ target path"))?; + let path = root.join(target); + let original = if file_patch.original() == Some("/dev/null") { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + String::new() + } else { + fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))? + }; + let patched = diffy::apply(&original, &file_patch) + .map_err(|e| anyhow!("Failed to apply patch to {target}: {e}"))?; + fs::write(&path, patched).with_context(|| format!("Failed to write {}", path.display()))?; + applied += 1; + } + if applied == 0 { + bail!("Patch contains no file diffs"); + } + Ok(()) +} + +fn write_config(path: &Path, emsdk: &Path, binaryen: &Path) -> Result<()> { + let node = emsdk_node(emsdk) + .or_else(|| which::which("node").ok()) + .ok_or_else(|| anyhow!("node is required by emcc and was not found"))?; + let contents = format!( + "LLVM_ROOT = {:?}\nBINARYEN_ROOT = {:?}\nNODE_JS = {:?}\n", + emsdk.join("upstream/bin"), + binaryen, + node + ); + if fs::read_to_string(path).ok().as_deref() != Some(&contents) { + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display()))?; + } + Ok(()) +} + +fn emsdk_node(emsdk: &Path) -> Option { + fs::read_dir(emsdk.join("node")) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path().join("bin/node")) + .find(|p| p.exists()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn embedded_patches_parse() { + for (name, contents) in PATCHES { + let files = contents.matches("\ndiff --git ").count(); + assert!(files > 0, "{name} has no file diffs"); + for chunk in contents.split("\ndiff --git ").skip(1) { + let start = chunk.find("\n--- ").unwrap(); + diffy::Patch::from_str(&format!("{}\n", &chunk[start + 1..])) + .unwrap_or_else(|e| panic!("{name}: {e}")); + } + } + } + + #[test] + fn apply_patch_multi_file() { + let dir = std::env::temp_dir().join(format!("wb-patch-{}", std::process::id())); + fs::create_dir_all(dir.join("sub")).unwrap(); + fs::write(dir.join("a.txt"), "one\ntwo\nthree\n").unwrap(); + fs::write(dir.join("sub/b.txt"), "x\ny\n").unwrap(); + let patch = "Subject: test\n\n---\n\ +diff --git a/a.txt b/a.txt\nindex 1..2 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+2\n three\n\ +diff --git a/sub/b.txt b/sub/b.txt\n--- a/sub/b.txt\n+++ b/sub/b.txt\n@@ -1,2 +1,2 @@\n x\n-y\n+z\n"; + apply_patch(&dir, patch).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("a.txt")).unwrap(), + "one\n2\nthree\n" + ); + assert_eq!(fs::read_to_string(dir.join("sub/b.txt")).unwrap(), "x\nz\n"); + assert!(apply_patch(&dir, patch).is_err()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/worker-build/src/js/shim-emscripten.js b/worker-build/src/js/shim-emscripten.js new file mode 100644 index 000000000..d1999575f --- /dev/null +++ b/worker-build/src/js/shim-emscripten.js @@ -0,0 +1,8 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; +import * as exports from "./index.js"; + +class Entrypoint extends WorkerEntrypoint {} + +$HANDLERS + +export default Entrypoint; diff --git a/worker-build/src/main.rs b/worker-build/src/main.rs index 6ca91ff4b..8b5786072 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -11,11 +11,13 @@ use clap::Parser; const SHIM_FILE: &str = include_str!("./js/shim.js"); const SHIM_UNWIND_FILE: &str = include_str!("./js/shim-unwind.js"); +const SHIM_EMSCRIPTEN_FILE: &str = include_str!("./js/shim-emscripten.js"); pub(crate) mod binary; mod build; mod build_lock; mod emoji; +mod emscripten; mod lockfile; mod main_legacy; mod producers; @@ -85,8 +87,11 @@ pub fn main() -> Result<()> { builder.init()?; + let emscripten = builder.emscripten; let module_target = !no_panic_recovery && env::var("CUSTOM_SHIM").is_err(); - if module_target { + if emscripten { + builder.run()?; + } else if module_target { builder.extra_args.extend_from_slice(&[ "--experimental-reset-state-function".into(), "--force-enable-abort-handler".into(), @@ -105,8 +110,10 @@ pub fn main() -> Result<()> { producers::inject_workers_rs_sdk_metadata(&staging_dir, VERSION)?; - if module_target { - let shim = if builder.panic_unwind { + if emscripten || module_target { + let shim = if emscripten { + SHIM_EMSCRIPTEN_FILE + } else if builder.panic_unwind { SHIM_UNWIND_FILE } else { SHIM_FILE @@ -116,12 +123,12 @@ pub fn main() -> Result<()> { fs::write(&shim_path, shim) .with_context(|| format!("Failed to write {}", shim_path.display()))?; - add_export_wrappers(&staging_dir)?; + add_export_wrappers(&staging_dir, emscripten)?; update_package_json(&staging_dir)?; let esbuild_path = Esbuild.get_binary(None)?.0; - bundle(&staging_dir, &esbuild_path)?; + bundle(&staging_dir, &esbuild_path, emscripten)?; fix_wasm_import(&staging_dir)?; @@ -148,9 +155,14 @@ fn generate_handlers(out_dir: &Path) -> Result { // This code is specialized to what wasm-bindgen outputs for ESM and is therefore // brittle to upstream changes. It is comprehensive to current output patterns though. // TODO: Convert this to Wasm binary exports analysis for entry point detection instead. + // Emscripten output indents (or minifies) the wasm-bindgen exports and + // emits JSPI exports as `export async function`. let mut func_names = Vec::new(); - for line in content.lines() { - if let Some(rest) = line.strip_prefix("export function") { + for line in export_decls(&content) { + if let Some(rest) = line + .strip_prefix("export function") + .or_else(|| line.strip_prefix("export async function")) + { if let Some(bracket_pos) = rest.find("(") { let func_name = rest[..bracket_pos].trim(); // strip the exported function (we re-wrap all handlers) @@ -184,6 +196,7 @@ fn generate_handlers(out_dir: &Path) -> Result { || func_name == "queue" || func_name == "scheduled" || func_name == "email" + || func_name == "connect" { // TODO: Switch these over to https://github.com/wasm-bindgen/wasm-bindgen/pull/4757 // once that lands. @@ -203,18 +216,38 @@ fn generate_handlers(out_dir: &Path) -> Result { static SYSTEM_FNS: &[&str] = &["__wbg_reset_state", "__worker_init_state"]; -fn add_export_wrappers(out_dir: &Path) -> Result<()> { +/// Each `export` declaration in the module text, starting at the keyword, +/// whether the module is one declaration per line or minified. +fn export_decls(content: &str) -> impl Iterator { + content.match_indices("export ").filter_map(move |(i, _)| { + let boundary = i == 0 + || content[..i] + .chars() + .next_back() + .is_some_and(|c| c.is_whitespace() || c == ';' || c == '}'); + boundary.then(|| &content[i..]) + }) +} + +fn add_export_wrappers(out_dir: &Path, plain: bool) -> Result<()> { let index_path = output_path(out_dir, "index.js"); let content = fs::read_to_string(&index_path) .with_context(|| format!("Failed to read {}", index_path.display()))?; let mut class_names = Vec::new(); - for line in content.lines() { + for line in export_decls(&content) { + // Emscripten output declares classes as `export var Name = class Name {`. if let Some(rest) = line.strip_prefix("export class ") { if let Some(brace_pos) = rest.find("{") { let class_name = rest[..brace_pos].trim(); class_names.push(class_name.to_string()); } + } else if let Some(rest) = line.strip_prefix("export var ") { + if let Some((class_name, def)) = rest.split_once("=") { + if def.trim_start().starts_with("class") { + class_names.push(class_name.trim().to_string()); + } + } } } @@ -222,9 +255,18 @@ fn add_export_wrappers(out_dir: &Path) -> Result<()> { let mut output = fs::read_to_string(&shim_path) .with_context(|| format!("Failed to read {}", shim_path.display()))?; for class_name in class_names { - output.push_str(&format!( - "export const {class_name} = new Proxy(exports.{class_name}, classProxyHooks);\n" - )); + if plain { + // The runtime only exposes RPC on classes deriving from DurableObject. + output.push_str(&format!( + "Object.setPrototypeOf(exports.{class_name}.prototype, DurableObject.prototype);\n\ + Object.setPrototypeOf(exports.{class_name}, DurableObject);\n\ + export const {class_name} = exports.{class_name};\n" + )); + } else { + output.push_str(&format!( + "export const {class_name} = new Proxy(exports.{class_name}, classProxyHooks);\n" + )); + } } fs::write(&shim_path, output) .with_context(|| format!("Failed to write {}", shim_path.display()))?; @@ -359,7 +401,7 @@ where } // Bundles the snippets and worker-related code into a single file. -fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { +fn bundle(out_dir: &Path, esbuild_path: &Path, emscripten: bool) -> Result<()> { let no_minify = !matches!(env::var("NO_MINIFY"), Err(VarError::NotPresent)); let path = out_dir .canonicalize() @@ -370,9 +412,7 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { let mut command = Command::new(esbuild_path); command.args([ "--external:./index_bg.wasm", - "--external:cloudflare:email", - "--external:cloudflare:sockets", - "--external:cloudflare:workers", + "--external:cloudflare:*", "--format=esm", "--bundle", "./shim.js", @@ -380,6 +420,12 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { "--allow-overwrite", ]); + // Emscripten's node environment glue imports Node builtins, served by + // nodejs_compat in the runtime. + if emscripten { + command.args(["--external:node:*", "--platform=node"]); + } + if !no_minify { command.arg("--minify"); } diff --git a/worker-build/src/versions.rs b/worker-build/src/versions.rs index 7d4722cbb..222b80dec 100644 --- a/worker-build/src/versions.rs +++ b/worker-build/src/versions.rs @@ -9,10 +9,15 @@ macro_rules! version { // Current build toolchain, always used exactly for builds, unless overridden by {}_BIN env vars pub(crate) static LATEST_WASM_BINDGEN_VERSION: LazyLock = version!("0.2.128"); pub(crate) static CUR_WASM_OPT_VERSION: &str = "132"; +pub(crate) static CUR_EMSCRIPTEN_VERSION: &str = "6.0.9"; +pub(crate) static CUR_BINARYEN_JSPI_VERSION: &str = "version_132_jspi_hooks_1"; pub(crate) static CUR_ESBUILD_VERSION: LazyLock = version!("0.28.2"); // Minimum required libraries, validated before build pub(crate) static MIN_WASM_BINDGEN_LIB_VERSION: LazyLock = version!("0.2.122"); +// First release whose --keep-debug output binaryen accepts (wasm-bindgen/wasm-bindgen#5328) +pub(crate) static MIN_EMSCRIPTEN_DEBUG_WASM_BINDGEN_VERSION: LazyLock = + version!("0.2.129"); pub(crate) static MIN_RUSTC_VERSION: LazyLock = version!("1.77.0"); // workers-rs MSRV pub(crate) static MIN_WORKER_LIB_VERSION: LazyLock = version!(&format!( diff --git a/worker-macros/src/async_export.rs b/worker-macros/src/async_export.rs new file mode 100644 index 000000000..d9ef6d6f3 --- /dev/null +++ b/worker-macros/src/async_export.rs @@ -0,0 +1,52 @@ +use proc_macro2::TokenStream; +use quote::quote; + +/// A wasm-bindgen export running `body` (statements ending in an `async move` +/// block resolving to `Result`) to a JS Promise. +/// +/// Normally a synchronous export returning `future_to_promise(body)`. On +/// emscripten the export is a JSPI promising function that suspends on the +/// future's promise instead, so the activation runs on its own fiber and +/// blocking waits inside it (Tokio parks, `epoll_wait`) suspend the stack. +pub fn async_export(opts: TokenStream, sig: TokenStream, body: TokenStream) -> TokenStream { + let opts = if opts.is_empty() { + opts + } else { + quote! { #opts, } + }; + quote! { + #[cfg(not(target_os = "emscripten"))] + #[wasm_bindgen(#opts wasm_bindgen=::worker::wasm_bindgen)] + pub fn #sig -> ::worker::js_sys::Promise { + ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe({ #body })) + } + + #[cfg(target_os = "emscripten")] + #[allow(deprecated)] + #[wasm_bindgen(jspi, #opts wasm_bindgen=::worker::wasm_bindgen)] + pub fn #sig -> ::std::result::Result<::worker::wasm_bindgen::JsValue, ::worker::wasm_bindgen::JsValue> { + ::worker::js_sys::futures::jspi_block_on_promise( + &::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe({ #body })) + ) + } + } +} + +/// The same for a free function, placed in `mod_name` with `uses` in scope. +/// The `#[wasm_bindgen]` attribute is left for rustc to expand so that only +/// the variant surviving `cfg` produces an export descriptor. +pub fn async_export_mod( + mod_name: &proc_macro2::Ident, + uses: TokenStream, + sig: TokenStream, + body: TokenStream, +) -> TokenStream { + let export = async_export(TokenStream::new(), sig, body); + quote! { + mod #mod_name { + use ::worker::wasm_bindgen::prelude::wasm_bindgen; + #uses + #export + } + } +} diff --git a/worker-macros/src/durable_object.rs b/worker-macros/src/durable_object.rs index 39a2a7d33..8cc5ea5c9 100644 --- a/worker-macros/src/durable_object.rs +++ b/worker-macros/src/durable_object.rs @@ -20,10 +20,24 @@ impl syn::parse::Parse for DurableObjectType { } mod bindgen_methods { + use crate::async_export::async_export; use proc_macro2::TokenStream; use quote::quote; pub fn core() -> TokenStream { + let fetch = async_export( + quote! { js_name = fetch }, + quote! { fetch(&self, req: ::worker::worker_sys::web_sys::Request) }, + quote! { + let static_self = static_self(self); + async move { + ::fetch(static_self, req.into()).await + .map(::worker::worker_sys::web_sys::Response::from) + .map(::worker::wasm_bindgen::JsValue::from) + .map_err(::worker::wasm_bindgen::JsValue::from) + } + }, + ); quote! { #[wasm_bindgen(constructor, wasm_bindgen=::worker::wasm_bindgen)] pub fn new( @@ -36,114 +50,93 @@ mod bindgen_methods { ) } - #[wasm_bindgen(js_name = fetch, wasm_bindgen=::worker::wasm_bindgen)] - pub fn fetch( - &self, - req: ::worker::worker_sys::web_sys::Request - ) -> ::worker::js_sys::Promise { - // SAFETY: - // Durable Object will never be destroyed while there is still - // a running promise inside of it, therefore we can let a reference - // to the durable object escape into a static-lifetime future. - let static_self: &'static Self = unsafe { &*(self as *const _) }; - - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { - ::fetch(static_self, req.into()).await - .map(::worker::worker_sys::web_sys::Response::from) - .map(::worker::wasm_bindgen::JsValue::from) - .map_err(::worker::wasm_bindgen::JsValue::from) - })) - } + #fetch } } pub fn alarm() -> TokenStream { - quote! { - #[wasm_bindgen(js_name = alarm, wasm_bindgen=::worker::wasm_bindgen)] - pub fn alarm(&self) -> ::worker::js_sys::Promise { - // SAFETY: - // Durable Object will never be destroyed while there is still - // a running promise inside of it, therefore we can let a reference - // to the durable object escape into a static-lifetime future. - let static_self: &'static Self = unsafe { &*(self as *const _) }; - - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + async_export( + quote! { js_name = alarm }, + quote! { alarm(&self) }, + quote! { + let static_self = static_self(self); + async move { ::alarm(static_self).await .map(::worker::worker_sys::web_sys::Response::from) .map(::worker::wasm_bindgen::JsValue::from) .map_err(::worker::wasm_bindgen::JsValue::from) - })) - } - } + } + }, + ) } pub fn websocket() -> TokenStream { - quote! { - #[wasm_bindgen(js_name = webSocketMessage, wasm_bindgen=::worker::wasm_bindgen)] - pub fn websocket_message( - &self, - ws: ::worker::worker_sys::web_sys::WebSocket, - message: ::worker::wasm_bindgen::JsValue - ) -> ::worker::js_sys::Promise { - let message = match message.as_string() { - Some(message) => ::worker::WebSocketIncomingMessage::String(message), - None => ::worker::WebSocketIncomingMessage::Binary( - ::worker::js_sys::Uint8Array::new(&message).to_vec() - ) - }; - - // SAFETY: - // Durable Object will never be destroyed while there is still - // a running promise inside of it, therefore we can let a reference - // to the durable object escape into a static-lifetime future. - let static_self: &'static Self = unsafe { &*(self as *const _) }; - - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let message = async_export( + quote! { js_name = webSocketMessage }, + quote! { + websocket_message( + &self, + ws: ::worker::worker_sys::web_sys::WebSocket, + message: ::worker::wasm_bindgen::JsValue + ) + }, + quote! { + let static_self = static_self(self); + async move { + let message = match message.as_string() { + Some(message) => ::worker::WebSocketIncomingMessage::String(message), + None => ::worker::WebSocketIncomingMessage::Binary( + ::worker::js_sys::Uint8Array::new(&message).to_vec() + ) + }; ::websocket_message(static_self, ws.into(), message).await .map(|_| ::worker::wasm_bindgen::JsValue::NULL) .map_err(::worker::wasm_bindgen::JsValue::from) - })) - } - - #[wasm_bindgen(js_name = webSocketClose, wasm_bindgen=::worker::wasm_bindgen)] - pub fn websocket_close( - &self, - ws: ::worker::worker_sys::web_sys::WebSocket, - code: usize, - reason: String, - was_clean: bool - ) -> ::worker::js_sys::Promise { - // SAFETY: - // Durable Object will never be destroyed while there is still - // a running promise inside of it, therefore we can let a reference - // to the durable object escape into a static-lifetime future. - let static_self: &'static Self = unsafe { &*(self as *const _) }; - - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + } + }, + ); + let close = async_export( + quote! { js_name = webSocketClose }, + quote! { + websocket_close( + &self, + ws: ::worker::worker_sys::web_sys::WebSocket, + code: usize, + reason: String, + was_clean: bool + ) + }, + quote! { + let static_self = static_self(self); + async move { ::websocket_close(static_self, ws.into(), code, reason, was_clean).await .map(|_| ::worker::wasm_bindgen::JsValue::NULL) .map_err(::worker::wasm_bindgen::JsValue::from) - })) - } - - #[wasm_bindgen(js_name = webSocketError, wasm_bindgen=::worker::wasm_bindgen)] - pub fn websocket_error( - &self, - ws: ::worker::worker_sys::web_sys::WebSocket, - error: ::worker::wasm_bindgen::JsValue - ) -> ::worker::js_sys::Promise { - // SAFETY: - // Durable Object will never be destroyed while there is still - // a running promise inside of it, therefore we can let a reference - // to the durable object escape into a static-lifetime future. - let static_self: &'static Self = unsafe { &*(self as *const _) }; - - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + } + }, + ); + let error = async_export( + quote! { js_name = webSocketError }, + quote! { + websocket_error( + &self, + ws: ::worker::worker_sys::web_sys::WebSocket, + error: ::worker::wasm_bindgen::JsValue + ) + }, + quote! { + let static_self = static_self(self); + async move { ::websocket_error(static_self, ws.into(), error.into()).await .map(|_| ::worker::wasm_bindgen::JsValue::NULL) .map_err(::worker::wasm_bindgen::JsValue::from) - })) - } + } + }, + ); + quote! { + #message + #close + #error } } } @@ -191,6 +184,13 @@ pub fn expand_macro(attr: TokenStream, tokens: TokenStream) -> syn::Result &'static #target_name { + unsafe { &*(this as *const _) } + } + #[wasm_bindgen(wasm_bindgen=::worker::wasm_bindgen)] #[::worker::consume] #target diff --git a/worker-macros/src/event.rs b/worker-macros/src/event.rs index bd72f2d52..478976f0f 100644 --- a/worker-macros/src/event.rs +++ b/worker-macros/src/event.rs @@ -1,5 +1,6 @@ +use crate::async_export::async_export_mod; use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::{parse_macro_input, punctuated::Punctuated, token::Comma, Ident, ItemFn}; #[derive(strum::EnumString, strum::Display)] @@ -86,15 +87,21 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { // create a new "main" function that takes the worker_sys::Request, and calls the // original attributed function, passing in a converted worker::Request. - // We use a synchronous wrapper that returns a Promise via future_to_promise - // with AssertUnwindSafe to support panic=unwind. - let wrapper_fn = quote! { - pub fn #wrapper_fn_ident( - req: ::worker::worker_sys::web_sys::Request, - env: ::worker::Env, - ctx: ::worker::worker_sys::Context - ) -> ::worker::js_sys::Promise { - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let glue = async_export_mod( + &format_ident!("_worker_fetch"), + quote! { + use ::worker::{wasm_bindgen, js_sys}; + use super::#input_fn_ident; + }, + quote! { + #wrapper_fn_ident( + req: ::worker::worker_sys::web_sys::Request, + env: ::worker::Env, + ctx: ::worker::worker_sys::Context + ) + }, + quote! { + async move { let ctx = worker::Context::new(ctx); let response: ::worker::worker_sys::web_sys::Response = match ::worker::FromRequest::from_raw(req) { Ok(req) => { @@ -125,21 +132,13 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { } }; Ok(::worker::wasm_bindgen::JsValue::from(response)) - })) - } - }; - let wasm_bindgen_code = - wasm_bindgen_macro_support::expand(TokenStream::new().into(), wrapper_fn) - .expect("wasm_bindgen macro failed to expand"); + } + }, + ); let output = quote! { #input_fn - - mod _worker_fetch { - use ::worker::{wasm_bindgen, js_sys}; - use super::#input_fn_ident; - #wasm_bindgen_code - } + #glue }; TokenStream::from(output) @@ -155,29 +154,27 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { // rename the original attributed fn input_fn.sig.ident = input_fn_ident.clone(); - // Use a synchronous wrapper that returns a Promise via future_to_promise - // with AssertUnwindSafe to support panic=unwind. - let wrapper_fn = quote! { - pub fn #wrapper_fn_ident(event: ::worker::worker_sys::ScheduledEvent, env: ::worker::Env, ctx: ::worker::worker_sys::ScheduleContext) -> ::worker::js_sys::Promise { - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let glue = async_export_mod( + &format_ident!("_worker_scheduled"), + quote! { + use ::worker::wasm_bindgen; + use super::#input_fn_ident; + }, + quote! { + #wrapper_fn_ident(event: ::worker::worker_sys::ScheduledEvent, env: ::worker::Env, ctx: ::worker::worker_sys::ScheduleContext) + }, + quote! { + async move { // call the original fn #input_fn_ident(::worker::ScheduledEvent::from(event), env, ::worker::ScheduleContext::from(ctx)).await; Ok(::worker::wasm_bindgen::JsValue::UNDEFINED) - })) - } - }; - let wasm_bindgen_code = - wasm_bindgen_macro_support::expand(TokenStream::new().into(), wrapper_fn) - .expect("wasm_bindgen macro failed to expand"); + } + }, + ); let output = quote! { #input_fn - - mod _worker_scheduled { - use ::worker::wasm_bindgen; - use super::#input_fn_ident; - #wasm_bindgen_code - } + #glue }; TokenStream::from(output) @@ -194,11 +191,17 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { // rename the original attributed fn input_fn.sig.ident = input_fn_ident.clone(); - // Use a synchronous wrapper that returns a Promise via future_to_promise - // with AssertUnwindSafe to support panic=unwind. - let wrapper_fn = quote! { - pub fn #wrapper_fn_ident(event: ::worker::worker_sys::MessageBatch, env: ::worker::Env, ctx: ::worker::worker_sys::Context) -> ::worker::js_sys::Promise { - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let glue = async_export_mod( + &format_ident!("_worker_queue"), + quote! { + use ::worker::wasm_bindgen; + use super::#input_fn_ident; + }, + quote! { + #wrapper_fn_ident(event: ::worker::worker_sys::MessageBatch, env: ::worker::Env, ctx: ::worker::worker_sys::Context) + }, + quote! { + async move { // call the original fn let ctx = worker::Context::new(ctx); match #input_fn_ident(::worker::MessageBatch::from(event), env, ctx).await { @@ -209,21 +212,13 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { } } Ok(::worker::wasm_bindgen::JsValue::UNDEFINED) - })) - } - }; - let wasm_bindgen_code = - wasm_bindgen_macro_support::expand(TokenStream::new().into(), wrapper_fn) - .expect("wasm_bindgen macro failed to expand"); + } + }, + ); let output = quote! { #input_fn - - mod _worker_queue { - use ::worker::wasm_bindgen; - use super::#input_fn_ident; - #wasm_bindgen_code - } + #glue }; TokenStream::from(output) @@ -260,9 +255,17 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { // rename the original attributed fn input_fn.sig.ident = input_fn_ident.clone(); - let wrapper_fn = quote! { - pub fn #wrapper_fn_ident(message: ::worker::ForwardableEmailMessage, env: ::worker::Env, ctx: ::worker::worker_sys::Context) -> ::worker::js_sys::Promise { - ::worker::js_sys::futures::future_to_promise(::std::panic::AssertUnwindSafe(async move { + let glue = async_export_mod( + &format_ident!("_worker_email"), + quote! { + use ::worker::wasm_bindgen; + use super::#input_fn_ident; + }, + quote! { + #wrapper_fn_ident(message: ::worker::ForwardableEmailMessage, env: ::worker::Env, ctx: ::worker::worker_sys::Context) + }, + quote! { + async move { let ctx = worker::Context::new(ctx); match #input_fn_ident(message, env, ctx).await { Ok(()) => {}, @@ -272,21 +275,13 @@ pub fn expand_macro(attr: TokenStream, item: TokenStream) -> TokenStream { } } Ok(::worker::wasm_bindgen::JsValue::UNDEFINED) - })) - } - }; - let wasm_bindgen_code = - wasm_bindgen_macro_support::expand(TokenStream::new().into(), wrapper_fn) - .expect("wasm_bindgen macro failed to expand"); + } + }, + ); let output = quote! { #input_fn - - mod _worker_email { - use ::worker::wasm_bindgen; - use super::#input_fn_ident; - #wasm_bindgen_code - } + #glue }; TokenStream::from(output) } diff --git a/worker-macros/src/lib.rs b/worker-macros/src/lib.rs index c6f2cd376..9039c90a4 100644 --- a/worker-macros/src/lib.rs +++ b/worker-macros/src/lib.rs @@ -1,3 +1,4 @@ +mod async_export; mod durable_object; mod event; mod send; diff --git a/worker/src/init.rs b/worker/src/init.rs index f4f3ac548..75ba88ed0 100644 --- a/worker/src/init.rs +++ b/worker/src/init.rs @@ -21,12 +21,14 @@ extern "C" { fn set_instance_id(this: &InitState, val: u32); } -// On abort -> reinit +// On abort -> reinit. Emscripten owns instantiation, so there is no +// instance to reset there. +#[cfg(not(target_os = "emscripten"))] fn on_abort() { wasm_bindgen::handler::schedule_reinit(); } -#[wasm_bindgen(start)] +#[wasm_bindgen(start, private)] fn init() { let default_hook = panic::take_hook(); panic::set_hook(Box::new(move |info| { @@ -45,6 +47,7 @@ fn init() { s.set_instance_id(id + 1); }); + #[cfg(not(target_os = "emscripten"))] wasm_bindgen::handler::set_on_abort(on_abort); }