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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 06:10:41 +01:00
commit 45b9f5cbb7
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 103 additions and 42 deletions

View file

@ -493,6 +493,54 @@ namespace {
walk(config, config.name); walk(config, config.name);
return error; 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) { BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
@ -553,6 +601,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (config.sysroot.empty() && config.target.starts_with("wasm32")) { if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
config.sysroot = GetToolchain().wasiSysroot.string(); 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 buildDir = config.BuildDir();
fs::path outputDir = config.BinDir(); fs::path outputDir = config.BinDir();
@ -870,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 // Same target-aware setup as the C++ compile path (line 459-): wasm32
// rejects -march, silently ignores -mtune, and needs --sysroot to find // rejects -march, silently ignores -mtune, and needs --sysroot to find
// wasi-libc headers. Build the prefix once so all C compiles share it. // wasi-libc headers. Build the prefix once so all C compiles share it.
const bool cIsWasm = config.target.starts_with("wasm32"); std::string cArchFlags = isWasm
std::string cArchFlags = cIsWasm
? std::string() ? std::string()
: std::format(" -march={} -mtune={}", config.march, config.mtune); : std::format(" -march={} -mtune={}", config.march, config.mtune);
if (!config.sysroot.empty()) { if (!config.sysroot.empty()) {
@ -880,11 +932,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// The C++ side gets it through GetBaseCommand. // The C++ side gets it through GetBaseCommand.
cArchFlags += std::format(" --sysroot={}", GetToolchain().mingwSysroot.string()); cArchFlags += std::format(" --sysroot={}", GetToolchain().mingwSysroot.string());
} }
if (cIsWasm) { if (isWasm) {
// Matches the C++ path's wasi flag set so any libc shim defines // The C++ path's wasm flags, all of them, a variant pass's included.
// (e.g. _WASI_EMULATED_SIGNAL → signal.h shims) are visible to // With only the signal.h define, wasi-libc's setjmp.h refused to
// C dependencies that drag in signal.h transitively. // compile in C, and a C object given -wasm-enable-sjlj through
cArchFlags += " -D_WASI_EMULATED_SIGNAL"; // 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) { for (const fs::path& cFile : config.cFiles) {
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string()); files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());

View file

@ -804,17 +804,6 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string stdCppm; std::string stdCppm;
if (isWasm) { if (isWasm) {
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot); stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
// wasm builds link the sysroot's exception-enabled libc++, which
// -fwasm-exceptions in WasmBaselineFlags selects, and the
// libunwind Build adds. A sysroot without them (wasi-sdk before
// 33, or one built without its exceptions option) otherwise stops
// at the first link with "unable to find library -lunwind", which
// says nothing about why it was asked for. The driver looks in eh/
// first and then in the triple's own directory.
const fs::path libDir = fs::path(config.sysroot) / "lib" / NormalizeTarget(config.target);
if (!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", config.sysroot, (libDir / "eh" / "libunwind.a").string());
}
} else if (config.sysroot.empty()) { } else if (config.sysroot.empty()) {
stdCppm = GetToolchain().stdCppm.string(); stdCppm = GetToolchain().stdCppm.string();
} else { } else {

View file

@ -12,12 +12,12 @@ import :Agents;
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace Crafter { namespace Crafter {
// What every wasm32 C++ translation unit is compiled with, and the std // What every wasm32 translation unit is compiled with, C or C++, and the
// module it imports. A BMI cannot be loaded by a unit whose options differ // std module the C++ ones import. A BMI cannot be loaded by a unit whose
// from the ones that wrote it, so GetCompileCommand and BuildStdPcm both // options differ from the ones that wrote it, so GetCompileCommand and
// take the set from here (through WasmCompileFlags), and VariantId and // BuildStdPcm both take the set from here (through WasmCompileFlags), and
// StdPcmDir hash it so that a crafter-build which changes it never reuses // VariantId and StdPcmDir hash it so that a crafter-build which changes
// what the old set compiled. // it never reuses what the old set compiled.
// //
// -fwasm-exceptions compiles C++ exceptions to WebAssembly exception // -fwasm-exceptions compiles C++ exceptions to WebAssembly exception
// handling. It also selects the sysroot's include/<triple>/eh and // handling. It also selects the sysroot's include/<triple>/eh and
@ -34,9 +34,10 @@ namespace Crafter {
// The wasi-runtime/runtime.js probes an engine has to pass to run anything // The wasi-runtime/runtime.js probes an engine has to pass to run anything
// built with WasmBaselineFlags; EnableWasiBrowserRuntime puts them on every // built with WasmBaselineFlags; EnableWasiBrowserRuntime puts them on every
// entry of variants.json. Exception handling with exnref is the newest // entry of variants.json. Exception handling with exnref is the newest
// feature those flags need (Chrome 137, Firefox 131, Safari 18.4), so an // feature those flags need (Chrome 137, Firefox 131, Safari 18.4). SIMD128
// engine that has it has SIMD128 and the rest as well. // is older, but V8 and SpiderMonkey switch it off on x86 CPUs without
inline constexpr auto WasmBaselineProbes = std::to_array<std::string_view>({"exception-handling"}); // SSE4.1, which virtual machines often present.
inline constexpr auto WasmBaselineProbes = std::to_array<std::string_view>({"simd", "exception-handling"});
} }
export namespace Crafter { export namespace Crafter {
@ -750,7 +751,8 @@ export namespace Crafter {
// it only to engines that report relaxed-SIMD support (Chrome 114+, Firefox // it only to engines that report relaxed-SIMD support (Chrome 114+, Firefox
// 120+) and falls back to the baseline outputName.wasm everywhere else // 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 // (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 // variants.json manifest includes it. A no-op for non-wasm targets at build
// time (no wasm is produced), but harmless to declare unconditionally. // time (no wasm is produced), but harmless to declare unconditionally.
void EnableWasiRelaxedSimdVariant(Configuration& cfg); void EnableWasiRelaxedSimdVariant(Configuration& cfg);

View file

@ -135,11 +135,22 @@ modules:
- -DWASI_SDK_INSTALL_TO_CLANG_RESOURCE_DIR=ON - -DWASI_SDK_INSTALL_TO_CLANG_RESOURCE_DIR=ON
- -DWASI_SDK_TARGETS=wasm32-wasip1;wasm32-wasip1-threads - -DWASI_SDK_TARGETS=wasm32-wasip1;wasm32-wasip1-threads
- -DWASI_SDK_INCLUDE_TESTS=OFF - -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 - -DCMAKE_INSTALL_PREFIX=/app
post-install: post-install:
# wasi-sdk installs into <prefix>/share/wasi-sysroot; crafter-build # wasi-sdk installs into <prefix>/share/wasi-sysroot; crafter-build
# looks there (Crafter.Build:Toolchain), so nothing to move. # looks there (Crafter.Build:Toolchain), so nothing to move.
- test -f /app/share/wasi-sysroot/share/libc++/v1/std.cppm - 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: sources:
- type: git - type: git
url: https://github.com/WebAssembly/wasi-sdk.git url: https://github.com/WebAssembly/wasi-sdk.git

View file

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

View file

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

View file

@ -876,15 +876,18 @@ class Wasi {
// GoogleChromeLabs/wasm-feature-detect, inlined so we ship no dependency. A // 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 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 // 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 // through to a less demanding variant, ultimately the baseline, and to an
// in sync with the `probes` a WasmVariant declares in Crafter.Build, and with // error naming what is missing when not even the baseline fits). Keep the
// WasmBaselineProbes, which every entry carries. // keys in sync with the `probes` a WasmVariant declares in Crafter.Build, and
// with WasmBaselineProbes, which every entry carries.
const WASM_PROBES = { const WASM_PROBES = {
// relaxed SIMD: i8x16.relaxed_swizzle (Chrome 114+, Firefox 120+; Safari // relaxed SIMD: i8x16.relaxed_swizzle (Chrome 114+, Firefox 120+; Safari
// still flag-gated as of mid-2026). // 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])), "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, // fixed-width SIMD128 (v128.const + i8x16.popcnt), which every build
// here for completeness / future baseline shifts. // 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])), "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 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])), "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])),