Compare commits

..
21 changed files with 81 additions and 923 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, the wasm baseline and 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, wasm variant flags, uninterpreted project args and the toolchain (`Toolchain::Key()`).
## Design rules
@ -191,7 +191,5 @@ 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,16 +187,6 @@ 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,14 +4,9 @@ Build a C++26 program for the `wasm32-wasi` target and run it in a browser.
## Prerequisites
- 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.
- `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`.
## Build

View file

@ -256,15 +256,6 @@ 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;
@ -272,12 +263,6 @@ 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";
@ -332,11 +317,15 @@ 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. 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));
// 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);
}
}
if (config.target == "x86_64-w64-mingw32") {
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
@ -416,14 +405,12 @@ 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 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.
// 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.
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" : "";
@ -493,54 +480,6 @@ 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) {
@ -601,11 +540,6 @@ 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();
@ -923,7 +857,8 @@ 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.
std::string cArchFlags = isWasm
const bool cIsWasm = config.target.starts_with("wasm32");
std::string cArchFlags = cIsWasm
? std::string()
: std::format(" -march={} -mtune={}", config.march, config.mtune);
if (!config.sysroot.empty()) {
@ -932,13 +867,11 @@ 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 (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);
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";
}
for (const fs::path& cFile : config.cFiles) {
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
@ -1206,17 +1139,6 @@ 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,
@ -1560,13 +1482,10 @@ 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 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
// 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
// single, uniform code path.
auto jsonEscape = [](std::string_view s) {
std::string out;
@ -1581,14 +1500,12 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
std::ofstream v(variantsPath);
v << "[";
bool first = true;
auto emit = [&](const std::string& label, const std::vector<std::string>& ownProbes) {
auto emit = [&](const std::string& label, const std::vector<std::string>& probes) {
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,12 +818,19 @@ 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, 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.
// 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.
std::string archFlags = isWasm
? WasmCompileFlags(config)
? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL")
: 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,35 +11,6 @@ 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;
@ -67,10 +38,8 @@ 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, 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.
// for a browser to be served this variant. The first variant whose probes
// pass wins; the baseline is the universal fallback.
struct WasmVariant {
std::string label;
std::vector<std::string> flags;
@ -617,16 +586,6 @@ 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) {
@ -663,8 +622,7 @@ 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 flags (WasmBaselineFlags, then the
// variant's) for the same reason.
// features, and suffixed with the wasm variant flags for the same reason.
fs::path StdPcmDir(const Configuration& config);
// Where Build leaves this Configuration's linked artifact: the executable
@ -738,11 +696,7 @@ 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. 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).
// outputName so renaming the binary later requires another call.
void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional
@ -751,8 +705,7 @@ 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). 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
// 2026). 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);
@ -786,11 +739,3 @@ 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,22 +135,11 @@ 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,12 +266,8 @@ 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,10 +87,7 @@ int main() {
}
// With no declared variants, variants.json is a single baseline entry
// 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.
// pointing at outputName.wasm with no probes.
{
std::ifstream vin(*variants);
std::string vjson{std::istreambuf_iterator<char>(vin), std::istreambuf_iterator<char>()};
@ -98,10 +95,6 @@ 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

@ -1,167 +0,0 @@
// 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

@ -1,75 +0,0 @@
// 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

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

View file

@ -1,110 +0,0 @@
// 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

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

View file

@ -1,85 +0,0 @@
// 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

@ -1,24 +0,0 @@
// 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

@ -1,46 +0,0 @@
// 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

@ -1,84 +0,0 @@
// 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,15 +11,12 @@ 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; 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.
// 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.
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());
if (!fs::exists(tc.wasiSysroot / "include")) {
std::println(std::cerr, "SKIP: no WASI sysroot at {}", tc.wasiSysroot.string());
return 77;
}
if (std::system("which wasmtime > /dev/null 2>&1") != 0) {

View file

@ -97,11 +97,6 @@ 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,53 +176,11 @@ 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;
@ -236,27 +194,6 @@ 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() {
@ -868,6 +805,22 @@ 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 ────────────────────────────────────────
@ -876,28 +829,21 @@ 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, 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.
// through to a less demanding variant, ultimately the baseline). Keep the keys
// in sync with the `probes` a WasmVariant declares in Crafter.Build.
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), 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.
// fixed-width SIMD128 (v128.const + i8x16.popcnt) — the current baseline,
// here for completeness / future baseline shifts.
"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])),
// 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])),
// 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])),
// threads/atomics — also requires SharedArrayBuffer (cross-origin isolated).
"threads": () => {
try {
@ -920,9 +866,6 @@ 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 {
@ -938,9 +881,6 @@ 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;
}
@ -1030,7 +970,6 @@ 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
@ -1041,12 +980,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) {
// 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;
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;
}
}