Compare commits

..
Author SHA1 Message Date
70ff11d0a1
feat(wasm): link libsetjmp, so setjmp needs no flag
All checks were successful
Flatpak / flatpak (push) Successful in 17m58s
-mllvm -wasm-enable-sjlj, in every wasm compile because setjmp.h does
not compile without it, turns setjmp and longjmp into calls to
__wasm_setjmp, __wasm_setjmp_test and __wasm_longjmp and a throw of
the __c_longjmp tag, all of which live in wasi-libc's libsetjmp. The
driver does not link it, so a program that used setjmp, or a C library
that handles its errors that way, failed to link with undefined
__wasm_setjmp until the project put -lsetjmp in its linkFlags.

Build() adds it to every wasm link, next to -lunwind. A static archive
contributes only the members something references, so a program
without setjmp gets nothing from it, and every sysroot that passes the
exception check ships it. WasmExceptions drops its own -lsetjmp, and
its C fixture now fails to link without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:29:52 +01:00
3e125d83f0
docs: wasm exceptions as the review left them
The README called an exception escaping main a trap. It is an uncaught
WebAssembly exception, and nothing prints its type or what(); the
README says so now. It also lists what a wasm build refuses and why,
says that setjmp needs -lsetjmp and that a longjmp across a noexcept
function ends the program, that a wasmtime older than 47 needs
-W exceptions=y, and that an old wasm-opt on PATH breaks release links.
It documents exit and park in a page from EnableWasiBrowserRuntime,
with the declaration of the park import.

The wasi example still sent Arch users to wasi-libc++, which has no
exception-enabled libraries. It asks for a wasi-sdk 33 sysroot or the
Flatpak now, and names the browsers a page needs. AGENTS.md notes that
the runtime test needs node, so it runs in the native loop and not in
CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:10:41 +01:00
2a05b8e6fb
test(wasm): the cases the review found
WasmBuildRefusals checks that -flto in compileFlags and in linkFlags,
-fno-exceptions and a shared library are each refused with their
cause, and that none of them leaves bin/ or build/ behind. With the
check disabled, each of them failed on a missing std.cppm instead,
after the build directories had been made.

WasiSysrootWithoutExceptions also takes its sysroot through missing
and without the target's directory, each with a message of its own,
checks that a refused build leaves nothing behind, and that a static
library passes without libunwind.

WasmExceptions catches an exception thrown inside libc++.a (stoi).
vector::at throws from a header compiled into the fixture, so the
fixture also passed against the libc++ built without exceptions. Its C
source uses setjmp, which the old C flags did not compile. An
exception that escapes main has to end the run as wasmtime's uncaught
exception, where any nonzero exit used to pass, a trap or a timeout
included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:10:41 +01:00
45b9f5cbb7
fix(wasm): what the review found
-flto in a wasm configuration's compileFlags or linkFlags reached the
link, where LTO code generation runs without the backend options that
keep exception handling: every landing pad was dropped, and the build
succeeded with throws that escaped their catch blocks. -fno-exceptions
there made every `import std;` fail on the std module built with
exceptions, and a wasm shared library failed to link against the eh/
libc++, which is static archives built without -fPIC. Build() refuses
all three now, each with a line that says why, before it makes a
directory or starts a thread.

The sysroot check sat in BuildStdPcm, which the linter calls as well.
There the error only dropped the compile command, so lint ran no AST
rule on a wasm configuration and reported it clean. It also refused
static libraries, which never link, fired after the shader and
external threads had started, and called a missing sysroot, or one
without the target, "no C++ exception support". It is part of the same
early check now, which first says when the sysroot or the target's
directory is missing, and asks for libunwind only where something
links.

C sources got only the signal.h define. setjmp.h refused to compile in
C, and a C object given -wasm-enable-sjlj through compileFlags used the
legacy exception encoding next to C++ objects in the standard one, a
mix engines refuse to load. They get WasmCompileFlags now, like the C++
units, a variant pass's flags included.

The baseline probes include simd: V8 and SpiderMonkey switch SIMD off
on x86 CPUs without SSE4.1, which virtual machines often present, and
such an engine passed the exception-handling probe only to fail
compiling the module; the manifest tests expect it on every entry. The
Flatpak pins WASI_SDK_EXCEPTIONS=DUAL, which
wasi-sdk 34 defaults to only with clang 23 or newer, and checks that
the eh/ libunwind got installed: the wasm tests skip without it, so CI
would otherwise pass a Flatpak whose every wasm build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:10:41 +01:00
fec206839e
test(wasi-runtime): exit and park, under node
runtime.js had no test that runs it. WasiBrowserRuntimeExit runs the
real file under node, once per scenario, against a module assembled in
the harness. Between _start and the call that ends it the module has
the frames that caught the old exit: a destructor (catch_all_ref, then
throw_ref), a catch (...) and a noexcept function, each reporting when
it runs. An exit has to run none of them and record its status; a park
has to run none of them, nor the code after it, and leave the program
callable with its state intact; an exit from a later call has to end
the program too, a return from main has to give status 0, and a crash
or an exception nothing caught has to stay an error.

With the JS throw put back into proc_exit, the exit from main and the
exit from a later call both end in the noexcept frame's terminate.

It skips without node, or with a node whose engine lacks the
standardized exception handling. The Flatpak has no node, so CI does
not run it; the native loop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:55:32 +01:00
0dabce0b74
fix(wasi-runtime): end exit and park without running the program
runtime.js implemented proc_exit by throwing a JavaScript error through
the wasm stack, and the switch to -fwasm-exceptions made that error
catchable: every cleanup pad, catch (...) and noexcept terminate pad
catches a foreign exception. An exit therefore ran the destructors of
every frame it left, a catch (...) swallowed it and carried on, and
under a noexcept frame it ended in std::terminate. Crafter.Graphics
calls std::_Exit(0) in Window::StartSync to hand the thread to
requestAnimationFrame; once the exit's destructors had torn down
main's locals, every later frame read destroyed state.

proc_exit is now a function of a small helper module, bound directly
as the program's import, that reports to the runtime and then executes
unreachable. A trap is the one thing wasm code cannot catch, and raised
in wasm rather than from JS it does not depend on each engine keeping
a trap uncatchable when it passes back through a JS frame. Nothing of
the program runs on the way out and its shadow stack stays as it was,
which is what exit and _Exit require. window.crafter_wasi.exitCode
holds the status, also after a return from main, so that bridges which
call into the program on their own can stop.

Handing the thread to the browser for good is a separate import,
crafter_runtime.park. It ends the call the same way but leaves the
program alive, and main's locals valid for the calls that follow, so
StartSync keeps its contract that no caller code after it runs. Until
Crafter.Graphics moves to park, its _Exit behaves as before minus the
destructors: the runtime records an exit but cannot stop a bridge that
calls exports itself.

A WASI reactor build, where main returns and state lives in statics,
was the alternative. It breaks StartSync's promise never to return,
which the native targets rely on. JSPI would keep the whole stack
alive, but Safari does not have it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:55:21 +01:00
18413bfae4
docs: wasm targets and their C++ exceptions
The README described the mingw target and said nothing about wasm. It
now says where the WASI sysroot comes from, that C++ exceptions work as
on the other targets and what that takes (-fwasm-exceptions in the
standardized form, the sysroot's exception-enabled libc++, -lunwind
added to the link), which engines run the result, and that an exception
escaping main ends the program without calling std::terminate.

AGENTS.md says where the wasm flags live, that VariantId and the std
PCM directory hash them, and that the wasm tests skip where the
distro's sysroot lacks the eh/ libraries, so they run in the Flatpak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:55:00 +01:00
8862092b63
test(wasm): exceptions in a wasm build, and a sysroot without them
WasmExceptions builds a fixture for wasm32-wasip1 and runs it under
wasmtime. It throws through four frames whose destructors have to run,
catches the exception, catches one that vector::at throws, and with an
argument lets one escape main, which has to fail the run. With the
previous flags the fixture does not compile ("cannot use 'throw' with
exceptions disabled"), and each part of turning exceptions on fails it
in its own way, from a BMI built without them to the legacy encoding
wasmtime refuses.

WasiSysrootWithoutExceptions takes a scratch sysroot through three
states, no libunwind, libunwind under eh/ and libunwind in the
triple's own directory, and checks that only the first stops the
build, with the message naming the library it looked for. It needs no
working wasm toolchain, so it also runs where the others skip.

WasmHelloRuns and WasmExceptions treat a sysroot without the
exception-enabled libraries as missing and skip. The manifest tests
expect the exception-handling probe on every entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:54:48 +01:00
63c4040000
feat(wasm): compile C++ with WebAssembly exception handling
wasm builds were compiled with -fno-exceptions. A throw did not
compile, libc++'s own throws (vector::at, std::format, filesystem)
aborted the program, and code that relies on exceptions could not be
built for the browser at all.

Every wasm unit and the std module now get -fwasm-exceptions, and the
link adds -lunwind, which code built that way calls into and the driver
does not add. The flag also selects the libc++ under the sysroot's
lib/<triple>/eh, which wasi-sdk 33 and later ship next to the noeh
one. Those libraries use the standardized encoding (try_table, exnref)
while LLVM 23 still emits the legacy one by default, which wasmtime
refuses to load and which would put both in one binary, so the flags
ask for the standard form with -wasm-use-legacy-eh=false. That makes
the setjmp lowering loadable under wasmtime too.

The flags live in one constant, WasmBaselineFlags, which the compile
command and the std module both take through WasmCompileFlags: a BMI
is refused by a unit whose exception setting differs from its own.
VariantId and StdPcmDir hash it, so the first build after the update
starts fresh instead of importing PCMs built without exceptions, and
two releases sharing the Flatpak's cache don't take turns rebuilding
one std PCM. A sysroot without the exception-enabled libraries stops
the build with a line saying so, rather than at the link with "unable
to find library -lunwind".

The result needs an engine with exnref: Chrome 137, Firefox 131,
Safari 18.4, the bundled wasmtime. The runtime's exception-handling
probe tested the legacy encoding; it now tests try_table, every
variants.json entry carries it, and a browser without it is told which
feature it lacks instead of failing with a CompileError. There is no
switch back to -fno-exceptions: engines without exnref are old enough
not to keep a second build for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:54:39 +01:00
21 changed files with 923 additions and 81 deletions

View file

@ -117,7 +117,7 @@ Beyond the rules:
- There is no host-platform preprocessor split: the host is Linux. Target differences (mingw, wasm) branch on `cfg.target` strings at runtime, in the library as well as in `project.cpp` and tests.
- A build step fails when its command exits nonzero, never because it printed. Use `RunBuildCommand` or `RunCommandChecked`; warnings go to `Progress::Diagnostic`.
- Every output path goes through `Configuration::BinDir()`, `BuildDir()` and `PcmDir()`. Hand-rolled `path / "bin" / ...` clobbered variants before; do not add new ones.
- Anything that changes what gets compiled or bundled belongs in `VariantId()`. It already hashes type, debug, sysroot, defines, compile flags, wasm variant flags, uninterpreted project args and the toolchain (`Toolchain::Key()`).
- Anything that changes what gets compiled or bundled belongs in `VariantId()`. It already hashes type, debug, sysroot, defines, compile flags, the wasm baseline and variant flags, uninterpreted project args and the toolchain (`Toolchain::Key()`).
## Design rules
@ -191,5 +191,7 @@ Local builds are two scripts in `project.cpp`. Run them with the native binary:
- The bootstrap binary is compiled without `CRAFTER_BUILD_HAS_ASSET`, so `cfg.assets` only works in the self-hosted binary. Changing asset extensions means updating two maps in `Crafter.Build-Clang.cpp`: `assetOutput` and the transitive copy loop.
- A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone.
- A std module source only compiles against the headers of its own libc++ release, and a sysroot does not always ship one (Alpine). The Flatpak therefore carries the sources of the release next to the bundled one under `share/crafter-build/libc++-modules/<major>/`, one `libcxx-<major>-modules` manifest module each. When the bundled LLVM is bumped, keep the release it leaves behind as such a module: sysroots on the stable distro stay on it for months. The native loop has no such directory; stage one by hand under `share/crafter-build/` to test against a foreign sysroot.
- The wasm compile flags live in one place, `WasmBaselineFlags` in `Crafter.Build-Clang.cppm`: the std module and every unit have to agree on them, and `VariantId()` and the std PCM directory hash them, so a change rebuilds instead of mixing old and new BMIs. They enable C++ exceptions, so the link takes the exception-enabled libc++ and libunwind from the sysroot's `lib/<triple>/eh/` (wasi-sdk 33 and later). The Flatpak's sysroot has them and a distro's may not; where they are missing the wasm tests skip, so run those through the Flatpak. A browser page checks the engine with the `WasmBaselineProbes` named in `variants.json`.
- `WasiBrowserRuntimeExit` runs `wasi-runtime/runtime.js` under node and skips where there is none. The Flatpak has no node, so CI never runs it: after touching `runtime.js`, run the suite in the native loop with node on `PATH`.
- `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe-<target>-<march>-*/` rather than predicting the hash. The hash includes the compiler, so after a toolchain update the previous variant's directories stay in `bin/` and `build/` until `clean`, and such a glob matches both.
- The self-host build and `test` keep the dependency notes section of this very file. None of Crafter.Math, Crafter.Asset and Crafter.LintRules ships an `AGENTS.consumer.md` yet. Once one does, a build here can change AGENTS.md whenever that repository changes its notes, since all three are unpinned. The tree is then dirty, so the next build's `git describe --dirty` version differs, which is a new `crafter.build-lib` variant and a full rebuild. Commit the section to settle it.

View file

@ -187,6 +187,16 @@ For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sy
For `--target=x86_64-w64-mingw32` (Windows from Linux), the pipeline cross-compiles as long as `mingw-w64` is installed, with libstdc++ 15 or newer for its std module. clang finds it by the `x86_64-w64-mingw32-gcc` on `PATH`; `CRAFTER_MINGW_DIR` names another prefix (the directory holding `x86_64-w64-mingw32/` and `lib/gcc/`), and inside the Flatpak the host's `/usr` is used when it has mingw-w64. Without one the build stops with a message saying where it looked. Pair it with `--runner=cmd:wine` to run the result. Only the MinGW ABI is supported, there is no MSVC target and no Windows host. `-fuse-ld=lld` is auto-injected on every link, and `-lstdc++exp` is auto-linked for the mingw target so user projects don't need to declare those flags.
For `--target=wasm32-wasip1`, the WASI sysroot is the one beside the toolchain (the Flatpak bundles one) unless `CRAFTER_BUILD_WASI_SYSROOT` names another, and tests run under `wasmtime`. C++ exceptions work as on the other targets: every unit and the std module are compiled with `-fwasm-exceptions` in the standardized form of WebAssembly exception handling (exnref), against the exception-enabled libc++ in the sysroot's `lib/<triple>/eh/`, and `-lunwind` is linked automatically. A sysroot without that libc++ (wasi-sdk before 33) stops the build with one line saying so. The result runs on engines with that proposal: Chrome 137, Firefox 131 and Safari 18.4 or newer, and the wasmtime the Flatpak bundles. A page from `EnableWasiBrowserRuntime` checks first and names what an older browser lacks in the console, instead of failing to compile the module. An exception that escapes `main` leaves the program as an uncaught WebAssembly exception (wasmtime reports `thrown Wasm exception` and exits 1, a page gets a `WebAssembly.Exception`) without calling `std::terminate`, so a `std::set_terminate` handler does not run and nothing prints the exception's type or `what()`: wasm exception handling unwinds in a single phase, and nothing finds out that no handler is left until the stack is gone. Catch it in `main` to report it.
A wasm build refuses three things before it starts, each with a message that says why: `-flto` in `compileFlags` or `linkFlags` (LTO code generation drops the exception handling, [WebAssembly/wasi-sdk#629](https://github.com/WebAssembly/wasi-sdk/issues/629)), `-fno-exceptions` (the std module is built with exceptions, and a unit without them cannot import it) and a shared library (the exception-enabled libc++ is static archives only). `setjmp` and `longjmp` work in C and C++ sources, with `-lsetjmp` linked automatically like `-lunwind`; a `longjmp` across a `noexcept` function ends the program, because it is a wasm exception and the function's terminate handler catches it. Outside the Flatpak, a wasmtime older than 47 has exception handling off and needs `-W exceptions=y`, and a `wasm-opt` on `PATH` from a binaryen that cannot read the standardized encoding breaks release links, since clang runs it on optimized wasm links when it finds one.
In a page from `EnableWasiBrowserRuntime`, `exit` and `_Exit` end the program as they would anywhere else: none of its code runs on the way out, no destructor and no `catch (...)`, its status is in `window.crafter_wasi.exitCode`, and bridges that call into it on their own are expected to stop. A program that hands the thread to the browser for good and goes on serving calls, as Crafter.Graphics's `Window::StartSync()` does, parks instead of exiting. Parking never returns, runs nothing of the program either, and leaves `main`'s locals valid for the calls that follow:
```cpp
extern "C" [[noreturn]] __attribute__((import_module("crafter_runtime"), import_name("park"))) void Park();
```
### Configuring runners
Two layers, CLI wins over env, both override the per-test default of `Local()`:

View file

@ -4,9 +4,14 @@ Build a C++26 program for the `wasm32-wasi` target and run it in a browser.
## Prerequisites
- `wasi-libc`, `wasi-libc++`, `wasi-libc++abi` (Arch packages — provide
`/usr/share/wasi-sysroot/`, which `crafter-build` finds automatically).
Other distros: install the WASI SDK and set `cfg.sysroot` in `project.cpp`.
- A WASI sysroot with the exception-enabled libc++, `lib/wasm32-wasip1/eh/`
from wasi-sdk 33 or later: wasm builds compile C++ exceptions and link those
libraries. The Flatpak bundles one, which `crafter-build` uses as it is.
Elsewhere, point `CRAFTER_BUILD_WASI_SYSROOT` at a wasi-sdk sysroot, or set
`cfg.sysroot` in `project.cpp`. Arch's `wasi-libc++` packages have no such
libraries, and the build stops with a message saying so.
- For the browser: WebAssembly exception handling, in Chrome 137, Firefox 131,
Safari 18.4 or newer.
## Build

View file

@ -256,6 +256,15 @@ fs::path Crafter::StdPcmDir(const Configuration& config) {
// the consuming TUs see a target-feature mismatch. Suffix the cache dir
// with the variant flags when present.
std::string stdPcmKey = std::format("{}-{}-{}", config.target, config.march, GetToolchain().Key());
// The flags every wasm unit gets are compiled into the std PCM as well,
// and the importing units have to match them. Two releases that share a
// cache and a compiler, as the Flatpak's branches do, would otherwise
// take turns rebuilding this one std PCM, each time underneath the PCMs
// built against the previous one, and clang crashes on those rather than
// rejecting them.
if (config.target.starts_with("wasm32")) {
stdPcmKey += std::format("-{:08x}", static_cast<std::uint32_t>(std::hash<std::string_view>{}(WasmBaselineFlags)));
}
for (const std::string& f : config.wasmVariantFlags) {
stdPcmKey += "+";
for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c;
@ -263,6 +272,12 @@ fs::path Crafter::StdPcmDir(const Configuration& config) {
return GetCacheDir()/stdPcmKey;
}
std::string Crafter::WasmCompileFlags(const Configuration& config) {
std::string flags = std::format(" {}", WasmBaselineFlags);
for (const std::string& f : config.wasmVariantFlags) flags += std::format(" {}", f);
return flags;
}
fs::path Crafter::ExpectedOutput(const Configuration& config) {
const fs::path dir = config.BinDir();
const bool mingwTarget = config.target == "x86_64-w64-mingw32";
@ -317,15 +332,11 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
if (isWasm) {
// -mllvm is consumed by codegen but not the link driver, which is the
// same command line; quiet the unused-flag warning rather than split
// compile and link commands.
out.command += " -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL -Wno-unused-command-line-argument";
// Active variant pass (see the variant driver at the end of Build):
// extra codegen flags (e.g. -mrelaxed-simd) applied to every TU. Empty
// for the baseline build. Part of VariantId, so these objects/PCMs
// land in their own dir.
for (const std::string& f : config.wasmVariantFlags) {
out.command += std::format(" {}", f);
}
// compile and link commands. An active variant pass (see the variant
// driver at the end of Build) adds its codegen flags (e.g.
// -mrelaxed-simd) to every TU; they are part of VariantId, so those
// objects/PCMs land in their own dir.
out.command += std::format("{} -Wno-unused-command-line-argument", WasmCompileFlags(config));
}
if (config.target == "x86_64-w64-mingw32") {
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
@ -405,12 +416,14 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
// (cross-TU inlining) plus dead-section GC and safe identical-code folding.
// Default-on in Release, never Debug (keeps builds fast and debuggable).
// Excluded for wasm32 — its -mllvm/sjlj codegen flags don't compose with
// LTO here — and for nvcc .cu objects below (they can't emit LLVM bitcode,
// so they link in as ordinary objects alongside the bitcode ones). ThinLTO
// rather than monolithic -flto so link time and memory scale with arbitrary
// user project sizes. Compile-side flags (-flto, -ffunction/-fdata-sections)
// ride on `command`, which is reused as the link base; the link-only -Wl
// flags go on linkExtras so they don't warn on each -c compile.
// LTO here, and wasi-sdk lists exceptions under LTO as broken
// (WebAssembly/wasi-sdk#629) — and for nvcc .cu objects below (they can't
// emit LLVM bitcode, so they link in as ordinary objects alongside the
// bitcode ones). ThinLTO rather than monolithic -flto so link time and
// memory scale with arbitrary user project sizes. Compile-side flags
// (-flto, -ffunction/-fdata-sections) ride on `command`, which is reused
// as the link base; the link-only -Wl flags go on linkExtras so they
// don't warn on each -c compile.
out.useLto = !config.debug && !isWasm;
out.ltoCompileFlags = out.useLto ? " -flto=thin -ffunction-sections -fdata-sections" : "";
out.ltoLinkFlags = out.useLto ? " -flto=thin -Wl,--gc-sections -Wl,--icf=safe" : "";
@ -480,6 +493,54 @@ namespace {
walk(config, config.name);
return error;
}
// What a wasm32 configuration cannot be built with now that every unit
// has C++ exceptions, and what its sysroot has to have for that; empty
// for any other target and when nothing is wrong. Each would otherwise
// fail at the first compile or link with a message that does not say
// why, or not fail at all.
std::string WasmBuildError(const Configuration& config) {
if (!config.target.starts_with("wasm32")) return "";
for (const std::vector<std::string>* flags : { &config.compileFlags, &config.linkFlags }) {
for (const std::string& flag : *flags) {
// The compile has the backend options that keep exception
// handling; LTO code generation at the link does not, so it
// drops every landing pad and the build succeeds with throws
// that escape their catch blocks (WebAssembly/wasi-sdk#629).
if (flag.starts_with("-flto")) {
return std::format("{}: {} is not supported on wasm32: LTO code generation drops C++ exception handling (WebAssembly/wasi-sdk#629), so every throw would escape its catch", config.name, flag);
}
// The std module is built with exceptions, and a unit built
// without them cannot import it.
if (flag == "-fno-exceptions" || flag == "-fno-cxx-exceptions") {
return std::format("{}: {} is not supported on wasm32: wasm builds have C++ exceptions, the std module included, and a unit compiled without them cannot import std", config.name, flag);
}
}
}
// wasi-sdk's exception support does not cover shared libraries: the
// libc++ under lib/<triple>/eh is static archives built without -fPIC.
if (config.type == ConfigurationType::LibraryDynamic) {
return std::format("{}: wasm32 shared libraries are not supported: the exception-enabled libc++ they would link has static archives only", config.name);
}
const fs::path sysroot = config.sysroot;
if (!fs::is_directory(sysroot)) {
return std::format("no WASI sysroot at {}: the Flatpak bundles one, and CRAFTER_BUILD_WASI_SYSROOT selects another", sysroot.string());
}
const fs::path libDir = sysroot / "lib" / NormalizeTarget(config.target);
if (!fs::is_directory(libDir)) {
return std::format("the WASI sysroot at {} has nothing for {} (no {})", sysroot.string(), config.target, libDir.string());
}
// Only a link takes the exception-enabled libraries: a static
// library's objects compile against the headers alone. The driver
// looks in eh/ first and then in the triple's own directory, where a
// sysroot built with exceptions only (WASI_SDK_EXCEPTIONS=ON) has them.
// Without them the first link fails with "unable to find library
// -lunwind", which says nothing about why it was asked for.
if (config.type == ConfigurationType::Executable && !fs::exists(libDir / "eh" / "libunwind.a") && !fs::exists(libDir / "libunwind.a")) {
return std::format("the WASI sysroot at {} has no C++ exception support (no {}): wasm builds use WebAssembly exception handling and need the exception-enabled libc++ and libunwind that wasi-sdk 33 and later ship. The Flatpak bundles such a sysroot; CRAFTER_BUILD_WASI_SYSROOT selects another", sysroot.string(), (libDir / "eh" / "libunwind.a").string());
}
return "";
}
}
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
@ -540,6 +601,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
config.sysroot = GetToolchain().wasiSysroot.string();
}
// As early as the duplicate-module check, so a refused build leaves no
// directory behind and starts no thread, but after the sysroot is known.
if (std::string refused = WasmBuildError(config); !refused.empty()) {
return {std::move(refused), false, {}};
}
fs::path buildDir = config.BuildDir();
fs::path outputDir = config.BinDir();
@ -857,8 +923,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// Same target-aware setup as the C++ compile path (line 459-): wasm32
// rejects -march, silently ignores -mtune, and needs --sysroot to find
// wasi-libc headers. Build the prefix once so all C compiles share it.
const bool cIsWasm = config.target.starts_with("wasm32");
std::string cArchFlags = cIsWasm
std::string cArchFlags = isWasm
? std::string()
: std::format(" -march={} -mtune={}", config.march, config.mtune);
if (!config.sysroot.empty()) {
@ -867,11 +932,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// The C++ side gets it through GetBaseCommand.
cArchFlags += std::format(" --sysroot={}", GetToolchain().mingwSysroot.string());
}
if (cIsWasm) {
// Matches the C++ path's wasi flag set so any libc shim defines
// (e.g. _WASI_EMULATED_SIGNAL → signal.h shims) are visible to
// C dependencies that drag in signal.h transitively.
cArchFlags += " -D_WASI_EMULATED_SIGNAL";
if (isWasm) {
// The C++ path's wasm flags, all of them, a variant pass's included.
// With only the signal.h define, wasi-libc's setjmp.h refused to
// compile in C, and a C object given -wasm-enable-sjlj through
// compileFlags used LLVM's legacy exception encoding next to C++
// objects in the standard one, a mix engines refuse to load.
cArchFlags += WasmCompileFlags(config);
}
for (const fs::path& cFile : config.cFiles) {
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
@ -1139,6 +1206,17 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// compile so __once_callable et al resolve in static libstdc++.
linkExtras += " -lstdc++exp -static-libstdc++ -static-libgcc -Wl,-Bstatic -lpthread -Wl,-Bdynamic";
}
// Code built with -fwasm-exceptions calls into libunwind (the
// __cpp_exception tag, the landing-pad context, _Unwind_RaiseException),
// and -wasm-enable-sjlj turns setjmp and longjmp into calls into
// wasi-libc's libsetjmp (__wasm_setjmp, __wasm_longjmp, the __c_longjmp
// tag). The driver adds neither to the link the way it adds libc++. A
// static archive contributes only what something references, so a
// program without setjmp gets nothing from libsetjmp, and every sysroot
// that passes WasmBuildError ships both.
if (isWasm) {
linkExtras += " -lunwind -lsetjmp";
}
// Force a relink if the expected output is missing or older than any dep
// artifact. Missing covers: previous build produced a different outputName,
@ -1482,10 +1560,13 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
// Variant manifest. runtime.js fetches this, runs each entry's probes in
// order, and instantiates the first whose probes all pass — so the
// preferred (most feature-rich) variants come first and the baseline last
// as the universal fallback (empty probes always pass). A variant with a
// non-empty label maps to outputName.<label>.wasm (produced by Build's
// variant driver); the baseline maps to outputName.wasm. Even with no
// declared variants we still emit a one-entry manifest so the runtime has a
// as the fallback. Every entry carries WasmBaselineProbes besides its own:
// every file is built with the baseline flags, so an engine without
// exception handling can run none of them, and runtime.js tells it so
// instead of failing to compile the module. A variant with a non-empty
// label maps to outputName.<label>.wasm (produced by Build's variant
// driver); the baseline maps to outputName.wasm. Even with no declared
// variants we still emit a one-entry manifest so the runtime has a
// single, uniform code path.
auto jsonEscape = [](std::string_view s) {
std::string out;
@ -1500,12 +1581,14 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
std::ofstream v(variantsPath);
v << "[";
bool first = true;
auto emit = [&](const std::string& label, const std::vector<std::string>& probes) {
auto emit = [&](const std::string& label, const std::vector<std::string>& ownProbes) {
if (!first) v << ",";
first = false;
std::string url = label.empty()
? std::format("{}.wasm", cfg.outputName)
: std::format("{}.{}.wasm", cfg.outputName, label);
std::vector<std::string> probes = ownProbes;
probes.insert(probes.end(), WasmBaselineProbes.begin(), WasmBaselineProbes.end());
v << "{\"label\":\"" << jsonEscape(label) << "\""
<< ",\"url\":\"" << jsonEscape(url) << "\""
<< ",\"probes\":[";

View file

@ -818,19 +818,12 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string sysrootFlag = config.sysroot.empty()
? std::string()
: std::format(" --sysroot={}", config.sysroot);
// wasm32 rejects -march. wasi-libc++ headers require these baselines
// to compile: setjmp.h needs -mllvm -wasm-enable-sjlj (lowered to wasm
// EH); signal.h requires the emulation define; and EH itself isn't
// wired up so -fno-exceptions stays.
// wasm32 rejects -march, and the std module has to be compiled the way
// the units importing it are, a variant pass's flags (e.g.
// -mrelaxed-simd) included, or the BMI is not feature-compatible.
std::string archFlags = isWasm
? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL")
? WasmCompileFlags(config)
: std::format(" -march={} -mtune={}", config.march, config.mtune);
// Match the variant codegen flags the consuming TUs are built with
// (e.g. -mrelaxed-simd) so the std BMI is feature-compatible. wasm only;
// wasmVariantFlags is empty on every other path.
if (isWasm) {
for (const std::string& f : config.wasmVariantFlags) archFlags += std::format(" {}", f);
}
// non-wasm cross builds: the driver ranks the host toolchain's own
// libc++ headers (<install>/../include/c++/v1) above --sysroot, so
// the PCM would mix host libc++ with the foreign libc — force the

View file

@ -11,6 +11,35 @@ import :Toolchain;
import :Agents;
namespace fs = std::filesystem;
namespace Crafter {
// What every wasm32 translation unit is compiled with, C or C++, and the
// std module the C++ ones import. A BMI cannot be loaded by a unit whose
// options differ from the ones that wrote it, so GetCompileCommand and
// BuildStdPcm both take the set from here (through WasmCompileFlags), and
// VariantId and StdPcmDir hash it so that a crafter-build which changes
// it never reuses what the old set compiled.
//
// -fwasm-exceptions compiles C++ exceptions to WebAssembly exception
// handling. It also selects the sysroot's include/<triple>/eh and
// lib/<triple>/eh, the libc++ built with exceptions that wasi-sdk 33 and
// later ship next to the noeh/ one, so it has to reach the link too; the
// link reuses this command line, so it does. Those libraries use the
// standardized form of the proposal (try_table, exnref), while LLVM 23
// still defaults to the legacy one: wasmtime refuses to load that, and it
// would mix two encodings in one binary. -wasm-use-legacy-eh=false asks
// for the standard form, for setjmp as well. wasi-libc's setjmp.h does
// not compile without -wasm-enable-sjlj, which lowers setjmp to the same
// exception handling, and signal.h needs the emulation define.
inline constexpr std::string_view WasmBaselineFlags = "-fwasm-exceptions -mllvm -wasm-use-legacy-eh=false -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL";
// The wasi-runtime/runtime.js probes an engine has to pass to run anything
// built with WasmBaselineFlags; EnableWasiBrowserRuntime puts them on every
// entry of variants.json. Exception handling with exnref is the newest
// feature those flags need (Chrome 137, Firefox 131, Safari 18.4). SIMD128
// is older, but V8 and SpiderMonkey switch it off on x86 CPUs without
// SSE4.1, which virtual machines often present.
inline constexpr auto WasmBaselineProbes = std::to_array<std::string_view>({"simd", "exception-handling"});
}
export namespace Crafter {
struct BuildResult {
std::string result;
@ -38,8 +67,10 @@ export namespace Crafter {
// `label` emits an additional `outputName.<label>.wasm` alongside the
// baseline `outputName.wasm`; `probes` names the runtime feature-detect
// checks (see wasi-runtime/runtime.js's probe registry) that must all pass
// for a browser to be served this variant. The first variant whose probes
// pass wins; the baseline is the universal fallback.
// for a browser to be served this variant, on top of WasmBaselineProbes,
// which every build needs. The first variant whose probes pass wins; the
// baseline is the fallback, and an engine that fails even its probes gets
// an error naming them.
struct WasmVariant {
std::string label;
std::vector<std::string> flags;
@ -586,6 +617,16 @@ export namespace Crafter {
compileKey += "|W:";
compileKey += f;
}
// So does the set every wasm unit gets before those. Unkeyed, the
// release that turned exceptions on would have kept the objects
// and PCMs compiled without them: the first unit recompiled after
// the update would import BMIs built without exceptions, which
// clang rejects, and objects nothing recompiled would let
// exceptions pass through without running their destructors.
if (target.starts_with("wasm32")) {
compileKey += "|B:";
compileKey += WasmBaselineFlags;
}
// Sorted by ApplyStandardArgs, so flag order on the command line
// doesn't split the cache.
for (const std::string& a : projectArgs) {
@ -622,7 +663,8 @@ export namespace Crafter {
// Directory holding the std module PCM for this Configuration. Keyed on
// target+march because a BMI is only loadable by a TU with matching target
// features, and suffixed with the wasm variant flags for the same reason.
// features, and suffixed with the wasm flags (WasmBaselineFlags, then the
// variant's) for the same reason.
fs::path StdPcmDir(const Configuration& config);
// Where Build leaves this Configuration's linked artifact: the executable
@ -696,7 +738,11 @@ export namespace Crafter {
// open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need
// this and shouldn't carry the extra files. Call from project.cpp after
// outputName is set; index.html is generated against the current
// outputName so renaming the binary later requires another call.
// outputName so renaming the binary later requires another call. In that
// runtime exit and _Exit end the program without running any more of it;
// a program that hands the thread to the browser and keeps serving calls
// parks instead, through the crafter_runtime.park import (runtime.js,
// PROGRAM_END).
void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional
@ -705,7 +751,8 @@ export namespace Crafter {
// it only to engines that report relaxed-SIMD support (Chrome 114+, Firefox
// 120+) and falls back to the baseline outputName.wasm everywhere else
// (e.g. Safari, which still gates relaxed SIMD behind a flag as of mid
// 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted
// 2026). Both need what WasmBaselineProbes names, so an engine without
// exception handling gets neither but an error saying so. Idempotent. Call before EnableWasiBrowserRuntime so the emitted
// variants.json manifest includes it. A no-op for non-wasm targets at build
// time (no wasm is produced), but harmless to declare unconditionally.
void EnableWasiRelaxedSimdVariant(Configuration& cfg);
@ -739,3 +786,11 @@ export namespace Crafter {
// for-arg-in-args loop.
ArgQuery ApplyStandardArgs(Configuration& cfg, std::span<const std::string_view> args);
}
namespace Crafter {
// WasmBaselineFlags followed by the active variant pass's
// wasmVariantFlags, each with a leading space: the wasm part of every
// compile command and of the std module's, which have to agree flag for
// flag.
std::string WasmCompileFlags(const Configuration& config);
}

View file

@ -135,11 +135,22 @@ modules:
- -DWASI_SDK_INSTALL_TO_CLANG_RESOURCE_DIR=ON
- -DWASI_SDK_TARGETS=wasm32-wasip1;wasm32-wasip1-threads
- -DWASI_SDK_INCLUDE_TESTS=OFF
# crafter-build compiles every wasm unit with C++ exceptions and links
# the libc++ and libunwind built with them, from lib/<triple>/eh. The
# default is DUAL only when the driving clang is 23 or newer and OFF
# below that; said outright, an LLVM downgrade cannot quietly ship a
# sysroot that no wasm build links against.
- -DWASI_SDK_EXCEPTIONS=DUAL
- -DCMAKE_INSTALL_PREFIX=/app
post-install:
# wasi-sdk installs into <prefix>/share/wasi-sysroot; crafter-build
# looks there (Crafter.Build:Toolchain), so nothing to move.
- test -f /app/share/wasi-sysroot/share/libc++/v1/std.cppm
# The wasm tests skip when these are missing, as on a distro sysroot,
# so the Flatpak checks for them itself: without them its CI would
# pass while every wasm build failed.
- test -f /app/share/wasi-sysroot/lib/wasm32-wasip1/eh/libunwind.a
- test -f /app/share/wasi-sysroot/lib/wasm32-wasip1-threads/eh/libunwind.a
sources:
- type: git
url: https://github.com/WebAssembly/wasi-sdk.git

View file

@ -266,8 +266,12 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntimeExit").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmExceptions").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiSysrootWithoutExceptions").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmBuildRefusals").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() });
// LoadProject dlopens the synthesized project.so, which references
// Crafter:: symbols (HostTarget, Configuration ctors) that have to be

View file

@ -87,7 +87,10 @@ int main() {
}
// With no declared variants, variants.json is a single baseline entry
// pointing at outputName.wasm with no probes.
// pointing at outputName.wasm, with only the probes for what every wasm
// build needs, SIMD128 and exception handling, so that runtime.js can
// turn an engine without them away with a message rather than a
// CompileError.
{
std::ifstream vin(*variants);
std::string vjson{std::istreambuf_iterator<char>(vin), std::istreambuf_iterator<char>()};
@ -95,6 +98,10 @@ int main() {
std::println(std::cerr, "variants.json missing baseline url: {}", vjson);
return 1;
}
if (vjson.find("\"probes\":[\"simd\",\"exception-handling\"]") == std::string::npos) {
std::println(std::cerr, "variants.json baseline does not probe for SIMD and exception handling: {}", vjson);
return 1;
}
if (vjson.find("relaxed-simd") != std::string::npos) {
std::println(std::cerr, "variants.json unexpectedly mentions relaxed-simd: {}", vjson);
return 1;

View file

@ -0,0 +1,167 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Runs wasi-runtime/runtime.js (argv[2], copied to a .mjs so that node loads
// it as a module) once per scenario against the module assembled below, and
// prints one line per scenario for the test to check. Between _start and the
// call that ends it, the module has the frames C++ built with
// -fwasm-exceptions puts there: a destructor (catch_all_ref, then
// throw_ref), a catch (...) (catch_all) and a noexcept function (catch_all,
// then std::terminate). Each reports through env.trace when it runs, and on
// the way out of an exit or a park none of them may.
import { pathToFileURL } from "node:url";
const uleb = (n) => {
const out = [];
do {
const byte = n & 0x7f;
n >>>= 7;
out.push(n ? byte | 0x80 : byte);
} while (n);
return out;
};
const sleb = (n) => {
const out = [];
for (;;) {
const byte = n & 0x7f;
n >>= 7;
const done = (n === 0 && !(byte & 0x40)) || (n === -1 && (byte & 0x40));
out.push(done ? byte : byte | 0x80);
if (done) return out;
}
};
const vec = (items) => [...uleb(items.length), ...items.flat()];
const name = (text) => vec([...text].map((c) => [c.charCodeAt(0)]));
const section = (id, items) => {
const bytes = vec(items);
return [id, ...uleb(bytes.length), ...bytes];
};
const UNREACHABLE = 0x00, BLOCK = 0x02, IF = 0x04, ELSE = 0x05, THROW = 0x08, THROW_REF = 0x0a, END = 0x0b;
const RETURN = 0x0f, CALL = 0x10, TRY_TABLE = 0x1f, LOCAL_GET = 0x20, LOCAL_SET = 0x21, GLOBAL_GET = 0x23;
const GLOBAL_SET = 0x24, I32_CONST = 0x41, I32_EQ = 0x46;
const I32 = 0x7f, EXNREF = 0x69, NO_RESULT = 0x40, FUNC_TYPE = 0x60, CATCH_ALL = 0x02, CATCH_ALL_REF = 0x03;
const code = (locals, instructions) => {
const bytes = [...vec(locals), ...instructions, END];
return [...uleb(bytes.length), ...bytes];
};
// Types, imported functions, then the module's own.
const TAKES_I32 = 0, NOTHING = 1, GIVES_I32 = 2;
const PROC_EXIT = 0, PARK = 1, SCENARIO = 2, TRACE = 3;
const END_WITH = 4, NOEXCEPT = 5, CATCHES_ALL = 6, CLEANS_UP = 7, START = 8, FRAME = 9, QUIT = 10;
// What env.trace reports.
const DESTRUCTOR = 1, CATCH = 2, TERMINATE = 3, AFTER_PARK = 4;
// What env.scenario selects in _start.
const EXIT = 1, PARK_MAIN = 2, THROW_UNCAUGHT = 4, CRASH = 5;
const PARK_CODE = -1;
const ifScenario = (scenario, instructions) => [LOCAL_GET, 0, I32_CONST, ...sleb(scenario), I32_EQ, IF, NO_RESULT, ...instructions, END];
const module = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
...section(1, [
[FUNC_TYPE, ...vec([[I32]]), ...vec([])],
[FUNC_TYPE, ...vec([]), ...vec([])],
[FUNC_TYPE, ...vec([]), ...vec([[I32]])],
]),
...section(2, [
[...name("wasi_snapshot_preview1"), ...name("proc_exit"), 0x00, TAKES_I32],
[...name("crafter_runtime"), ...name("park"), 0x00, NOTHING],
[...name("env"), ...name("scenario"), 0x00, GIVES_I32],
[...name("env"), ...name("trace"), 0x00, TAKES_I32],
]),
...section(3, [[TAKES_I32], [TAKES_I32], [TAKES_I32], [TAKES_I32], [NOTHING], [GIVES_I32], [TAKES_I32]]),
...section(5, [[0x00, 0x01]]),
// One tag, for the exception nothing catches.
...section(13, [[0x00, TAKES_I32]]),
...section(6, [[I32, 0x01, I32_CONST, 0x00, END]]),
...section(7, [
[...name("memory"), 0x02, 0],
[...name("_start"), 0x00, START],
[...name("frame"), 0x00, FRAME],
[...name("quit"), 0x00, QUIT],
]),
...section(10, [
// END_WITH(code): park for PARK_CODE, else proc_exit(code).
code([], [LOCAL_GET, 0, I32_CONST, ...sleb(PARK_CODE), I32_EQ, IF, NO_RESULT, CALL, PARK, ELSE, LOCAL_GET, 0, CALL, PROC_EXIT, END]),
// NOEXCEPT(code): anything thrown through it is std::terminate.
code([], [
BLOCK, NO_RESULT, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL, 0]]), LOCAL_GET, 0, CALL, END_WITH, END, RETURN, END,
I32_CONST, TERMINATE, CALL, TRACE, UNREACHABLE,
]),
// CATCHES_ALL(code): try { NOEXCEPT(code); } catch (...) {}
code([], [
BLOCK, NO_RESULT, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL, 0]]), LOCAL_GET, 0, CALL, NOEXCEPT, END, RETURN, END,
I32_CONST, CATCH, CALL, TRACE,
]),
// CLEANS_UP(code): a frame with a destructor around CATCHES_ALL(code).
code([[...uleb(1), EXNREF]], [
BLOCK, EXNREF, TRY_TABLE, NO_RESULT, ...vec([[CATCH_ALL_REF, 0]]), LOCAL_GET, 0, CALL, CATCHES_ALL, END, RETURN, END,
LOCAL_SET, 1, I32_CONST, DESTRUCTOR, CALL, TRACE, LOCAL_GET, 1, THROW_REF,
]),
// _start: what env.scenario says; anything else returns from main.
code([[...uleb(1), I32]], [
CALL, SCENARIO, LOCAL_SET, 0,
...ifScenario(EXIT, [I32_CONST, 3, CALL, CLEANS_UP]),
...ifScenario(PARK_MAIN, [I32_CONST, 42, GLOBAL_SET, 0, I32_CONST, ...sleb(PARK_CODE), CALL, CLEANS_UP, I32_CONST, AFTER_PARK, CALL, TRACE]),
...ifScenario(THROW_UNCAUGHT, [I32_CONST, 9, THROW, 0]),
...ifScenario(CRASH, [UNREACHABLE]),
]),
// frame(): the state _start left, as a later frame callback reads it.
code([], [GLOBAL_GET, 0]),
// quit(code): an exit from a later call, through the same frames.
code([], [LOCAL_GET, 0, CALL, CLEANS_UP]),
]),
]);
// The standardized encoding (try_table) is what the module and runtime.js
// both depend on; an older node has only the legacy one.
const exnrefProbe = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11]);
if (!WebAssembly.validate(exnrefProbe)) {
console.log("SKIP: this node has no WebAssembly exception handling");
process.exit(0);
}
if (!WebAssembly.validate(module)) {
console.log("FAIL: the fixture module does not validate");
process.exit(1);
}
// What runtime.js expects of a page: no variants.json or files.json, so it
// loads CRAFTER_WASM_URL.
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
window.CRAFTER_WASM_URL = "fixture.wasm";
globalThis.fetch = async (url) => {
if (String(url).split("?")[0] === "fixture.wasm") return new Response(module, { headers: { "Content-Type": "application/wasm" } });
return new Response(null, { status: 404 });
};
const runtime = pathToFileURL(process.argv[2]).href;
const run = async (label, scenario, after = () => "") => {
const trace = [];
window.crafter_webbuild_env = { scenario: () => scenario, trace: (what) => { trace.push(what); } };
delete window.crafter_wasi;
let outcome = "ended";
try {
// A query makes it a module of its own, run afresh.
await import(`${runtime}?scenario=${scenario}`);
} catch (e) {
outcome = `rejected with ${e?.constructor?.name}`;
}
const wasi = window.crafter_wasi;
console.log(`${label}: ${outcome}, exitCode=${wasi?.exitCode} parked=${wasi?.parked} trace=[${trace}]${after(wasi, trace)}`);
};
await run("exit", EXIT);
await run("park", PARK_MAIN, (wasi, trace) => {
const frame = wasi.instance.exports.frame();
let quit = "returned";
try {
wasi.instance.exports.quit(7);
} catch (e) {
quit = `threw ${e.constructor.name}`;
}
return `; frame()=${frame}; quit(7) ${quit}, exitCode=${wasi.exitCode} trace=[${trace}]`;
});
await run("return", 3);
await run("uncaught exception", THROW_UNCAUGHT);
await run("crash", CRASH);

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// The browser runtime has to end a call into the program without running any
// more of it: when the program exits (exit, _Exit, a nonzero return from
// main), and when it parks to hand the thread to the browser for good, which
// Crafter::Window::StartSync does on DOM. runtime.js used to throw a
// JavaScript error out of proc_exit, and a program built with
// -fwasm-exceptions catches that. An exit then ran the destructors of every
// frame it left, a catch (...) swallowed it and carried on, and a noexcept
// function turned it into std::terminate, while the page went on calling
// into a program whose state those destructors had torn down.
//
// fixture/harness.mjs runs the real runtime.js under node against a module
// with the frames that catch between _start and the call that ends it: a
// destructor, a catch (...) and a noexcept function, each reporting when it
// runs. It checks that an exit runs none of them and records its status;
// that a park runs none of them either, nor the code after it, and leaves the
// program callable with its state intact; that an exit from a later call
// ends the program too; that returning from main ends it with status 0; and
// that a crash or an exception nothing caught still surfaces as an error.
// Skips without node, or with a node whose engine lacks exception handling.
// The Flatpak has no node, so this one runs in the native loop.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
}
int main() {
if (std::system("which node > /dev/null 2>&1") != 0) {
std::println(std::cerr, "SKIP: node not on PATH");
return 77;
}
const fs::path scratch = fs::temp_directory_path() / "crafter-build-wasi-browser-runtime-exit";
fs::remove_all(scratch);
fs::create_directories(scratch);
// node runs a .js file as a CommonJS script, which cannot await at its
// top level the way runtime.js does.
const fs::path runtime = scratch / "runtime.mjs";
fs::copy_file(fs::current_path() / "wasi-runtime" / "runtime.js", runtime);
const fs::path harness = fs::current_path() / "tests" / "WasiBrowserRuntimeExit" / "fixture" / "harness.mjs";
const auto run = RunCommandWithTimeout(std::format("node {} {}", ShellQuote(harness.string()), ShellQuote(runtime.string())), std::chrono::seconds(60));
fs::remove_all(scratch);
if (run.output.contains("SKIP:")) {
std::println(std::cerr, "{}", run.output);
return 77;
}
Check(run.exitCode == 0, std::format("the harness ran to the end (exit {})", run.exitCode));
Check(run.output.contains("exit: ended, exitCode=3 parked=false trace=[]"), "an exit runs no destructor, catch (...) or terminate, and its status is recorded");
Check(run.output.contains("park: ended, exitCode=null parked=true trace=[]; frame()=42"), "a park runs none of them nor the code after it, and the program stays callable with its state");
Check(run.output.contains("quit(7) threw RuntimeError, exitCode=7 trace=[]"), "an exit from a later call ends the program the same way");
Check(run.output.contains("return: ended, exitCode=0 parked=false trace=[]"), "returning from main ends the program with status 0");
Check(run.output.contains("uncaught exception: rejected with Exception, exitCode=null"), "an exception nothing caught is not taken for an exit");
Check(run.output.contains("crash: rejected with RuntimeError, exitCode=null"), "a crash is not taken for an exit");
if (failures > 0) {
std::println(std::cerr, "{} assertions failed; harness output:\n{}", failures, run.output);
return 1;
}
return 0;
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
int main() {
return 0;
}

View file

@ -0,0 +1,110 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// A WASI sysroot without exception support has to stop a wasm build with one
// line that says so. wasm builds compile with -fwasm-exceptions and link the
// libc++ and libunwind that wasi-sdk 33 and later ship under lib/<triple>/eh;
// a sysroot that lacks them (an older wasi-sdk, one built without its
// exceptions option) otherwise compiles and then fails the first link with
// "unable to find library -lunwind", which does not say why it was asked
// for. A sysroot that is not there at all, or has nothing for the target,
// gets a message of its own rather than that one. The checks run before
// anything is made, so a refused build leaves no bin/ behind, and they need
// no working wasm toolchain.
//
// A scratch sysroot is taken through these states: missing; without
// lib/wasm32-wasip1; without libunwind, for an executable and for a static
// library, which links nothing and passes; with libunwind under eh/; with
// libunwind in the triple's own directory, which the driver searches after
// eh/. The cases that pass the check fail later on the sysroot's empty std
// module source, which is not what is under test. The cache is redirected
// into the scratch directory as well: the std PCM cache is not keyed on the
// sysroot, and a PCM built from the fake source must not land where the real
// wasm tests read theirs.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
BuildResult BuildAgainst(const fs::path& project, const fs::path& sysroot, ConfigurationType type = ConfigurationType::Executable) {
Configuration app;
app.path = project;
app.name = "hello";
app.outputName = "hello";
app.target = "wasm32-wasip1";
app.sysroot = sysroot.string();
app.type = type;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
return Build(app, depResults, depMutex);
}
}
int main() {
// Build() reports a missing compiler before it gets to the sysroot.
if (const Toolchain& tc = GetToolchain(); !tc.error.empty()) {
std::println(std::cerr, "SKIP: {}", tc.error);
return 77;
}
const fs::path scratch = fs::temp_directory_path() / "crafter-build-wasi-sysroot-without-exceptions";
fs::remove_all(scratch);
fs::create_directories(scratch);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
const fs::path project = scratch / "project";
fs::copy(fs::current_path() / "tests" / "WasiSysrootWithoutExceptions" / "fixture", project, fs::copy_options::recursive);
const fs::path sysroot = scratch / "sysroot";
const fs::path libDir = sysroot / "lib" / "wasm32-wasip1";
{
const BuildResult r = BuildAgainst(project, sysroot);
Check(r.result.contains(std::format("no WASI sysroot at {}", sysroot.string())), std::format("a missing sysroot is named as missing: {}", r.result));
}
fs::create_directories(sysroot / "share" / "libc++" / "v1");
std::ofstream(sysroot / "share" / "libc++" / "v1" / "std.cppm");
{
const BuildResult r = BuildAgainst(project, sysroot);
Check(r.result.contains(std::format("has nothing for wasm32-wasip1 (no {})", libDir.string())), std::format("a sysroot without the target's directory says so: {}", r.result));
}
fs::create_directories(libDir / "eh");
constexpr std::string_view missing = "has no C++ exception support";
{
const BuildResult r = BuildAgainst(project, sysroot);
Check(r.result.contains(missing), std::format("without libunwind the build stops on the sysroot: {}", r.result));
Check(r.result.contains((libDir / "eh" / "libunwind.a").string()), std::format("the message names the library it looked for: {}", r.result));
Check(!fs::exists(project / "bin") && !fs::exists(project / "build"), "a refused build leaves no bin/ or build/ behind");
}
Check(!BuildAgainst(project, sysroot, ConfigurationType::LibraryStatic).result.contains(missing), "a static library links nothing and passes without libunwind");
std::ofstream(libDir / "eh" / "libunwind.a");
Check(!BuildAgainst(project, sysroot).result.contains(missing), "libunwind under eh/ passes the check");
fs::remove(libDir / "eh" / "libunwind.a");
std::ofstream(libDir / "libunwind.a");
Check(!BuildAgainst(project, sysroot).result.contains(missing), "libunwind in the triple's directory passes the check");
fs::remove_all(scratch);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
int main() {
return 0;
}

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// What a wasm32 build refuses before it starts, now that every wasm unit has
// C++ exceptions, each with a message naming the cause:
// - -flto in compileFlags or linkFlags. LTO code generation drops the
// exception handling, and the build used to succeed with every throw
// escaping its catch (WebAssembly/wasi-sdk#629).
// - -fno-exceptions. The std module is built with exceptions, and every
// `import std;` failed on a BMI configuration mismatch.
// - A shared library. The exception-enabled libc++ is static archives
// only, and the link failed on a relocation that wants -fPIC.
// None of them leaves bin/ or build/ behind, and none needs a working wasm
// toolchain. The sysroot given does not exist, so a refusal that did not
// fire would show up as the missing sysroot instead.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
struct Refusal {
std::string_view what;
std::vector<std::string> compileFlags;
std::vector<std::string> linkFlags;
ConfigurationType type;
std::string_view expected;
};
}
int main() {
// Build() reports a missing compiler before it gets to any of this.
if (const Toolchain& tc = GetToolchain(); !tc.error.empty()) {
std::println(std::cerr, "SKIP: {}", tc.error);
return 77;
}
const fs::path scratch = fs::temp_directory_path() / "crafter-build-wasm-build-refusals";
fs::remove_all(scratch);
fs::create_directories(scratch);
const fs::path project = scratch / "project";
fs::copy(fs::current_path() / "tests" / "WasmBuildRefusals" / "fixture", project, fs::copy_options::recursive);
const std::array refusals = {
Refusal{ "-flto in compileFlags", { "-flto=thin" }, {}, ConfigurationType::Executable, "-flto=thin is not supported on wasm32: LTO code generation drops C++ exception handling" },
Refusal{ "-flto in linkFlags", {}, { "-flto" }, ConfigurationType::Executable, "-flto is not supported on wasm32" },
Refusal{ "-fno-exceptions", { "-fno-exceptions" }, {}, ConfigurationType::Executable, "-fno-exceptions is not supported on wasm32" },
Refusal{ "a shared library", {}, {}, ConfigurationType::LibraryDynamic, "wasm32 shared libraries are not supported" },
};
for (const Refusal& refusal : refusals) {
Configuration app;
app.path = project;
app.name = "hello";
app.outputName = "hello";
app.target = "wasm32-wasip1";
app.sysroot = (scratch / "no-sysroot").string();
app.type = refusal.type;
app.compileFlags = refusal.compileFlags;
app.linkFlags = refusal.linkFlags;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
const BuildResult r = Build(app, depResults, depMutex);
Check(r.result.contains(refusal.expected), std::format("{} is refused with its cause: {}", refusal.what, r.result));
Check(!fs::exists(project / "bin") && !fs::exists(project / "build"), std::format("refusing {} leaves no bin/ or build/ behind", refusal.what));
}
fs::remove_all(scratch);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <setjmp.h>
#include <stdint.h>
static jmp_buf target;
static void JumpBack(int32_t value) {
longjmp(target, value);
}
// setjmp's value may only be compared or switched on, never stored.
int32_t JumpRoundTrip(void) {
switch (setjmp(target)) {
case 0:
JumpBack(7);
return 0;
case 7:
return 7;
default:
return -1;
}
}

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
// jump.c, compiled as C.
extern "C" std::int32_t JumpRoundTrip();
namespace {
std::int32_t unwound = 0;
struct Guard {
~Guard() { ++unwound; }
};
void Throw(std::int32_t depth) {
Guard guard;
if (depth == 0) throw std::runtime_error("thrown in wasm");
Throw(depth - 1);
}
}
int main(int argc, char** argv) {
// With an argument, nothing catches: the run has to fail.
if (argc > 1) Throw(0);
try {
Throw(3);
} catch (const std::runtime_error& e) {
std::println("caught: {} after {} destructors", e.what(), unwound);
}
try {
const std::vector<std::int32_t> empty;
std::println("unreachable: {}", empty.at(1));
} catch (const std::out_of_range&) {
std::println("caught: out_of_range from libc++");
}
// vector::at throws from a header, compiled into this unit; stoi throws
// inside libc++.a, which therefore has to be the exception-enabled one.
try {
std::println("unreachable: {}", std::stoi("not a number"));
} catch (const std::invalid_argument&) {
std::println("caught: invalid_argument from inside libc++");
}
std::println("longjmp came back with {}", JumpRoundTrip());
return 0;
}

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// C++ exceptions in a wasm build, run under wasmtime: the fixture throws
// through frames whose destructors have to run, catches the exception,
// catches one from a libc++ header (vector::at) and one thrown inside
// libc++.a itself (stoi), and with an argument lets one escape main, which
// has to end the run as an uncaught exception rather than exit 0, trap or
// hang. Its C source uses setjmp, which compiles and links next to the C++
// objects only when C gets the same wasm flags, and links at all only
// because the build adds libsetjmp: the configuration asks for nothing.
//
// wasm builds used to be compiled with -fno-exceptions, so a throw did not
// compile and libc++'s own throws aborted. Each part of turning them on fails
// in its own way, and this is the test that sees it: a std module or a unit
// built without exceptions (clang rejects the BMI), a link without libunwind
// (undefined __cpp_exception), the libc++ built without exceptions (stoi
// aborts), or LLVM's default legacy encoding, which wasmtime refuses to load.
// Skips like WasmHelloRuns when there is no exception-enabled WASI sysroot or
// no wasmtime; inside the Flatpak both are bundled.
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
}
int main() {
const Toolchain& tc = GetToolchain();
const fs::path libDir = tc.wasiSysroot / "lib" / "wasm32-wasip1";
if (!fs::exists(libDir / "eh" / "libunwind.a") && !fs::exists(libDir / "libunwind.a")) {
std::println(std::cerr, "SKIP: no exception-enabled WASI sysroot at {}", tc.wasiSysroot.string());
return 77;
}
if (std::system("which wasmtime > /dev/null 2>&1") != 0) {
std::println(std::cerr, "SKIP: wasmtime not on PATH");
return 77;
}
Configuration cfg;
cfg.path = fs::current_path() / "tests" / "WasmExceptions" / "fixture";
cfg.name = "wasm-exceptions";
cfg.outputName = "wasm-exceptions";
cfg.target = "wasm32-wasip1";
cfg.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.cFiles = { cfg.path / "jump" };
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
const BuildResult r = Build(cfg, depResults, depMutex);
if (!r.result.empty()) {
std::println(std::cerr, "build failed: {}", r.result);
return 1;
}
const fs::path wasm = ExpectedOutput(cfg);
const auto caught = RunCommandWithTimeout(std::format("wasmtime {}", wasm.string()), std::chrono::seconds(60));
Check(caught.exitCode == 0, std::format("the run that catches everything exits 0 (exit {}): {}", caught.exitCode, caught.output));
Check(caught.output.contains("caught: thrown in wasm after 4 destructors"), std::format("the exception is caught after unwinding all four frames: {}", caught.output));
Check(caught.output.contains("caught: out_of_range from libc++"), std::format("an exception from a libc++ header is catchable: {}", caught.output));
Check(caught.output.contains("caught: invalid_argument from inside libc++"), std::format("an exception from inside libc++.a is catchable: {}", caught.output));
Check(caught.output.contains("longjmp came back with 7"), std::format("setjmp in a C source works next to the C++ objects: {}", caught.output));
const auto escaped = RunCommandWithTimeout(std::format("wasmtime {} uncaught", wasm.string()), std::chrono::seconds(60));
Check(escaped.exitCode != 0 && !escaped.timedOut && !escaped.crashed && escaped.output.contains("thrown Wasm exception"), std::format("an exception escaping main ends the run as an uncaught exception (exit {}): {}", escaped.exitCode, escaped.output));
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -11,12 +11,15 @@ using namespace Crafter;
// wasmtime. The other wasm tests only inspect generated files; this is the
// one that proves the sysroot, the std module for wasm and wasm-ld all work
// together. It skips when the sysroot or wasmtime is missing, so a
// development box without them is not a failure. Inside the Flatpak both are
// bundled, and there the skip would be the failure to look for.
// development box without them is not a failure; a sysroot without the
// exception-enabled libraries counts as missing, since wasm builds link them
// (WasiSysrootWithoutExceptions covers that message). Inside the Flatpak both
// are bundled, and there the skip would be the failure to look for.
int main() {
const Toolchain& tc = GetToolchain();
if (!fs::exists(tc.wasiSysroot / "include")) {
std::println(std::cerr, "SKIP: no WASI sysroot at {}", tc.wasiSysroot.string());
const fs::path libDir = tc.wasiSysroot / "lib" / "wasm32-wasip1";
if (!fs::exists(libDir / "eh" / "libunwind.a") && !fs::exists(libDir / "libunwind.a")) {
std::println(std::cerr, "SKIP: no exception-enabled WASI sysroot at {}", tc.wasiSysroot.string());
return 77;
}
if (std::system("which wasmtime > /dev/null 2>&1") != 0) {

View file

@ -97,6 +97,11 @@ int main() {
Check(j.find("\"label\":\"relaxed-simd\"") != std::string::npos, "manifest has relaxed-simd label");
Check(j.find("\"wvhelper.relaxed-simd.wasm\"") != std::string::npos, "manifest has labelled url");
Check(j.find("\"relaxed-simd\"") != std::string::npos, "manifest has relaxed-simd probe");
// Every file is built with SIMD128 and exceptions, so every entry
// needs them besides its own features; the baseline needs
// nothing else.
Check(j.find("\"probes\":[\"relaxed-simd\",\"simd\",\"exception-handling\"]") != std::string::npos, "relaxed-simd entry also probes for the baseline's features");
Check(j.find("\"url\":\"wvhelper.wasm\",\"probes\":[\"simd\",\"exception-handling\"]") != std::string::npos, "baseline entry probes for its own features only");
// baseline fallback entry.
Check(j.find("\"wvhelper.wasm\"") != std::string::npos, "manifest has baseline url");
// The relaxed-simd variant must precede the baseline so the runtime

View file

@ -176,11 +176,53 @@ function markDirty(path) {
scheduleFlush();
}
// ─── leaving the program ───────────────────────────────────────────────
// exit and _Exit (proc_exit) end the program. park hands the thread to the
// browser for good while the program goes on serving the calls bridges make
// into it (Crafter::Window::StartSync on DOM): the import
// crafter_runtime.park, `void park(void)`, which never returns. Either way
// the current call into the program has to end without running any more of
// its code: not the destructors of the frames it leaves, not a catch (...),
// not the std::terminate of a noexcept function. Built with
// -fwasm-exceptions, every one of those catches a JavaScript exception
// thrown through it, so the way out cannot be a throw from JS. A trap is
// the one thing wasm code cannot catch, and this module raises it in wasm
// itself, bound directly as the program's import; a trap raised from JS
// would depend on each engine keeping it uncatchable when it passes back
// into wasm. It reports first, through host.ended, which has returned by
// the time it traps:
// (import "host" "ended" (func $ended (param $how i32) (param $code i32)))
// (func (export "exit") (param $code i32)
// i32.const 1 local.get $code call $ended unreachable)
// (func (export "park")
// i32.const 2 i32.const 0 call $ended unreachable)
// The program's own state is left as it is, its shadow stack included, so
// the locals of a parked main stay valid for the calls that follow.
const PROGRAM_END = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x0d, 0x03, 0x60, 0x02, 0x7f, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x00, 0x00,
0x02, 0x0e, 0x01, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x05, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x00, 0x00,
0x03, 0x03, 0x02, 0x01, 0x02,
0x07, 0x0f, 0x02, 0x04, 0x65, 0x78, 0x69, 0x74, 0x00, 0x01, 0x04, 0x70, 0x61, 0x72, 0x6b, 0x00, 0x02,
0x0a, 0x15, 0x02,
0x09, 0x00, 0x41, 0x01, 0x20, 0x00, 0x10, 0x00, 0x00, 0x0b,
0x09, 0x00, 0x41, 0x02, 0x41, 0x00, 0x10, 0x00, 0x00, 0x0b,
]);
const ENDED_BY_EXIT = 1;
const ENDED_BY_PARK = 2;
class Wasi {
#encodedStdin;
#envEncodedStrings;
#argEncodedStrings;
instance;
// The program's exit status once it has ended, through exit or _Exit or
// by returning from main; null while it runs or is parked. A bridge that
// calls into the program on its own, such as a frame loop, stops once
// this is set: nothing of an ended program may run.
exitCode = null;
// True once the program has parked (see PROGRAM_END).
parked = false;
#fdTable;
#nextFd;
#decoder;
@ -194,6 +236,27 @@ class Wasi {
this.#nextFd = 100; // stay clear of stdio + preopens
this.#decoder = new TextDecoder();
this.bind();
const end = new WebAssembly.Instance(new WebAssembly.Module(PROGRAM_END), {
host: { ended: (how, code) => this.ended(how, code) },
}).exports;
// Own properties, so instantiation takes these wasm functions as the
// program's imports rather than a JS method.
this.proc_exit = end.exit;
this.park = end.park;
}
// Called by PROGRAM_END just before its trap ends the call into the
// program. An exit after a park ends the program all the same.
ended(how, code) {
if (how === ENDED_BY_PARK) {
this.parked = true;
} else if (how === ENDED_BY_EXIT) {
console.log(`[wasi] proc_exit(${code})`);
this.exitCode = code;
// The last chance for anything written this session to reach
// OPFS before the page is torn down.
opfsFlushAll();
}
}
bind() {
@ -805,22 +868,6 @@ class Wasi {
sizePointerBuffer[0] = envByteLength;
return 0;
}
proc_exit(code) {
// Throw a sentinel so the wasm stack unwinds back to runtime.js
// WITHOUT executing the `unreachable` instruction wasi-libc emits
// after __wasi_proc_exit (which is declared noreturn). Trapping
// there would mark the wasm call as crashed and may interrupt any
// browser-side render loop that called _Exit on purpose (e.g.
// Crafter::Window::StartSync on DOM, which hands the loop to rAF
// and then exits main without running static destructors).
console.log(`[wasi] proc_exit(${code})`);
// Trigger a final flush so anything written this session has the
// best chance of reaching OPFS before the page is torn down.
opfsFlushAll();
const e = new Error(`wasi proc_exit(${code})`);
e.crafterWasiExit = code;
throw e;
}
}
// ─── wasm feature-detect probes ────────────────────────────────────────
@ -829,21 +876,28 @@ class Wasi {
// GoogleChromeLabs/wasm-feature-detect, inlined so we ship no dependency. A
// variant in variants.json lists the probe names that must all pass for that
// variant to be served; an unknown name is treated as unsupported (we fall
// through to a less demanding variant, ultimately the baseline). Keep the keys
// in sync with the `probes` a WasmVariant declares in Crafter.Build.
// through to a less demanding variant, ultimately the baseline, and to an
// error naming what is missing when not even the baseline fits). Keep the
// keys in sync with the `probes` a WasmVariant declares in Crafter.Build, and
// with WasmBaselineProbes, which every entry carries.
const WASM_PROBES = {
// relaxed SIMD: i8x16.relaxed_swizzle (Chrome 114+, Firefox 120+; Safari
// still flag-gated as of mid-2026).
"relaxed-simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,15,1,13,0,65,1,253,15,65,2,253,15,253,128,2,11])),
// fixed-width SIMD128 (v128.const + i8x16.popcnt) — the current baseline,
// here for completeness / future baseline shifts.
// fixed-width SIMD128 (v128.const + i8x16.popcnt), which every build
// uses (-msimd128). V8 and SpiderMonkey switch it off on x86 CPUs without
// SSE4.1, so it is a baseline probe even though every engine with
// exception handling has it.
"simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,10,1,8,0,65,0,253,15,253,98,11])),
// tail calls (return_call).
"tail-call": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,6,1,4,0,18,0,11])),
// bulk memory ops (memory.init).
"bulk-memory": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,3,1,0,1,10,14,1,12,0,65,0,65,0,65,0,252,10,0,0,11])),
// legacy exception handling (try/catch).
"exception-handling": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,8,1,6,0,6,64,25,11,11])),
// exception handling in its standardized form (try_table, exnref; Chrome
// 137, Firefox 131, Safari 18.4), which every crafter-build wasm uses for
// C++ exceptions. Probing the legacy try/catch encoding instead says
// nothing about this one: V8 accepts it with exnref switched off.
"exception-handling": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,8,1,6,0,31,64,0,11,11])),
// threads/atomics — also requires SharedArrayBuffer (cross-origin isolated).
"threads": () => {
try {
@ -866,6 +920,9 @@ function variantSupported(probes) {
// Pick the wasm URL: prefer the variants.json manifest (first entry whose
// probes all pass), else fall back to the single baked window.CRAFTER_WASM_URL.
// When the manifest has no entry this engine can run, not even the baseline,
// say which features it lacks: loading the baseline anyway would only fail
// with a CompileError about an unknown opcode.
async function pickWasmUrl() {
let variants = null;
try {
@ -881,6 +938,9 @@ async function pickWasmUrl() {
return v.url;
}
}
const baseline = variants[variants.length - 1];
const missing = (baseline.probes || []).filter((name) => !variantSupported([name]));
throw new Error(`[wasi] ${baseline.url} needs WebAssembly ${missing.join(", ")}, which this browser does not support; a current Chrome, Firefox or Safari does`);
}
return window.CRAFTER_WASM_URL;
}
@ -970,6 +1030,7 @@ if (window.crafter_webbuild_env_ready) {
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {
wasi_snapshot_preview1: wasi,
env: window.crafter_webbuild_env,
crafter_runtime: { park: wasi.park },
});
wasi.instance = instance;
// Alias under both names — `crafter_wasi` is the canonical, but the older
@ -980,12 +1041,12 @@ window.crafter_webbuild_wasi = wasi;
try {
instance.exports._start();
// main returned 0; any other status reaches proc_exit.
wasi.exitCode = 0;
opfsFlushAll();
} catch (e) {
if (e && typeof e.crafterWasiExit === "number") {
// Clean exit from std::_Exit / __wasi_proc_exit. The wasm instance
// remains valid; any rAF callback registered before exit keeps
// running. (See Crafter::Window::StartSync DOM impl.)
} else {
throw e;
}
// proc_exit and park end _start with PROGRAM_END's trap after saying
// which they were. Any other error, a crash or a C++ exception that
// nothing caught, is the program failing.
if (wasi.exitCode === null && !wasi.parked) throw e;
}