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>
This commit is contained in:
parent
eefd1df3eb
commit
63c4040000
4 changed files with 124 additions and 37 deletions
|
|
@ -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" : "";
|
||||
|
|
@ -1139,6 +1152,12 @@ 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 the driver does not add it to the link the way it adds libc++.
|
||||
if (isWasm) {
|
||||
linkExtras += " -lunwind";
|
||||
}
|
||||
|
||||
// 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 +1501,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 +1522,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\":[";
|
||||
|
|
|
|||
|
|
@ -804,6 +804,17 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
|||
std::string stdCppm;
|
||||
if (isWasm) {
|
||||
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()) {
|
||||
stdCppm = GetToolchain().stdCppm.string();
|
||||
} else {
|
||||
|
|
@ -818,19 +829,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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,34 @@ import :Toolchain;
|
|||
import :Agents;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter {
|
||||
// What every wasm32 C++ translation unit is compiled with, and the std
|
||||
// module it imports. 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), so an
|
||||
// engine that has it has SIMD128 and the rest as well.
|
||||
inline constexpr auto WasmBaselineProbes = std::to_array<std::string_view>({"exception-handling"});
|
||||
}
|
||||
|
||||
export namespace Crafter {
|
||||
struct BuildResult {
|
||||
std::string result;
|
||||
|
|
@ -38,8 +66,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 +616,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 +662,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
|
||||
|
|
@ -739,3 +780,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -830,7 +830,8 @@ class Wasi {
|
|||
// 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.
|
||||
// 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).
|
||||
|
|
@ -842,8 +843,11 @@ const WASM_PROBES = {
|
|||
"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 +870,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 +888,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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue