diff --git a/project.cpp b/project.cpp index 8a68830..fdc399c 100644 --- a/project.cpp +++ b/project.cpp @@ -268,6 +268,8 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("WasiBrowserRuntime").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("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() }); // LoadProject dlopens the synthesized project.so, which references // Crafter:: symbols (HostTarget, Configuration ctors) that have to be diff --git a/tests/WasiBrowserRuntime/main.cpp b/tests/WasiBrowserRuntime/main.cpp index 103de24..63a1c2a 100644 --- a/tests/WasiBrowserRuntime/main.cpp +++ b/tests/WasiBrowserRuntime/main.cpp @@ -87,7 +87,9 @@ int main() { } // With no declared variants, variants.json is a single baseline entry - // pointing at outputName.wasm with no probes. + // pointing at outputName.wasm, with only the probe for what every wasm + // build needs: exception handling, so that runtime.js can turn an engine + // without it away with a message rather than a CompileError. { std::ifstream vin(*variants); std::string vjson{std::istreambuf_iterator(vin), std::istreambuf_iterator()}; @@ -95,6 +97,10 @@ int main() { std::println(std::cerr, "variants.json missing baseline url: {}", vjson); return 1; } + if (vjson.find("\"probes\":[\"exception-handling\"]") == std::string::npos) { + std::println(std::cerr, "variants.json baseline does not probe for 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; diff --git a/tests/WasiSysrootWithoutExceptions/fixture/main.cpp b/tests/WasiSysrootWithoutExceptions/fixture/main.cpp new file mode 100644 index 0000000..97eb9af --- /dev/null +++ b/tests/WasiSysrootWithoutExceptions/fixture/main.cpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +int main() { + return 0; +} diff --git a/tests/WasiSysrootWithoutExceptions/main.cpp b/tests/WasiSysrootWithoutExceptions/main.cpp new file mode 100644 index 0000000..46a66bf --- /dev/null +++ b/tests/WasiSysrootWithoutExceptions/main.cpp @@ -0,0 +1,95 @@ +// 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//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. The check runs before the std module is built, so the first case +// needs no working wasm toolchain. +// +// A scratch sysroot is taken through three states: no libunwind; libunwind +// under eh/; libunwind in the triple's own directory, which the driver +// searches after eh/. The last two pass the check and 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) { + Configuration app; + app.path = project; + app.name = "hello"; + app.outputName = "hello"; + app.target = "wasm32-wasip1"; + app.sysroot = sysroot.string(); + app.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + std::unordered_map> 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"; + fs::create_directories(libDir / "eh"); + fs::create_directories(sysroot / "share" / "libc++" / "v1"); + std::ofstream(sysroot / "share" / "libc++" / "v1" / "std.cppm"); + + 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)); + } + + 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; +} diff --git a/tests/WasmExceptions/fixture/main.cpp b/tests/WasmExceptions/fixture/main.cpp new file mode 100644 index 0000000..4774ba3 --- /dev/null +++ b/tests/WasmExceptions/fixture/main.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; + +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 empty; + std::println("unreachable: {}", empty.at(1)); + } catch (const std::out_of_range&) { + std::println("caught: out_of_range from libc++"); + } + return 0; +} diff --git a/tests/WasmExceptions/main.cpp b/tests/WasmExceptions/main.cpp new file mode 100644 index 0000000..5ce413a --- /dev/null +++ b/tests/WasmExceptions/main.cpp @@ -0,0 +1,77 @@ +// 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 that libc++ throws itself (vector::at), and with an argument +// lets one escape main, which has to fail the run rather than exit 0. +// +// 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), 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 ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + std::unordered_map> 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("libc++'s own exception is catchable: {}", caught.output)); + + const auto escaped = RunCommandWithTimeout(std::format("wasmtime {} uncaught", wasm.string()), std::chrono::seconds(60)); + Check(escaped.exitCode != 0, std::format("an exception escaping main fails the run: {}", escaped.output)); + + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +} diff --git a/tests/WasmHelloRuns/main.cpp b/tests/WasmHelloRuns/main.cpp index 58a910f..a3c660f 100644 --- a/tests/WasmHelloRuns/main.cpp +++ b/tests/WasmHelloRuns/main.cpp @@ -11,12 +11,15 @@ using namespace Crafter; // wasmtime. The other wasm tests only inspect generated files; this is the // one that proves the sysroot, the std module for wasm and wasm-ld all work // together. It skips when the sysroot or wasmtime is missing, so a -// development box without them is not a failure. Inside the Flatpak both are -// bundled, and there the skip would be the failure to look for. +// development box without them is not a failure; a sysroot without the +// exception-enabled libraries counts as missing, since wasm builds link them +// (WasiSysrootWithoutExceptions covers that message). Inside the Flatpak both +// are bundled, and there the skip would be the failure to look for. int main() { const Toolchain& tc = GetToolchain(); - if (!fs::exists(tc.wasiSysroot / "include")) { - std::println(std::cerr, "SKIP: no WASI sysroot at {}", tc.wasiSysroot.string()); + const fs::path libDir = tc.wasiSysroot / "lib" / "wasm32-wasip1"; + if (!fs::exists(libDir / "eh" / "libunwind.a") && !fs::exists(libDir / "libunwind.a")) { + std::println(std::cerr, "SKIP: no exception-enabled WASI sysroot at {}", tc.wasiSysroot.string()); return 77; } if (std::system("which wasmtime > /dev/null 2>&1") != 0) { diff --git a/tests/WasmVariants/main.cpp b/tests/WasmVariants/main.cpp index 06f291f..27ab0a9 100644 --- a/tests/WasmVariants/main.cpp +++ b/tests/WasmVariants/main.cpp @@ -97,6 +97,10 @@ 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 exceptions, so every entry needs them + // besides its own features; the baseline needs nothing else. + Check(j.find("\"probes\":[\"relaxed-simd\",\"exception-handling\"]") != std::string::npos, "relaxed-simd entry also probes for exception handling"); + Check(j.find("\"url\":\"wvhelper.wasm\",\"probes\":[\"exception-handling\"]") != std::string::npos, "baseline entry probes for exception handling 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