diff --git a/project.cpp b/project.cpp index bd0c0a6..83f70d6 100644 --- a/project.cpp +++ b/project.cpp @@ -271,6 +271,7 @@ extern "C" Configuration CrafterBuildProject(std::span a 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 diff --git a/tests/WasiSysrootWithoutExceptions/main.cpp b/tests/WasiSysrootWithoutExceptions/main.cpp index 46a66bf..b74fac2 100644 --- a/tests/WasiSysrootWithoutExceptions/main.cpp +++ b/tests/WasiSysrootWithoutExceptions/main.cpp @@ -12,16 +12,20 @@ using namespace Crafter; // 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. +// 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 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. +// 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); @@ -35,14 +39,14 @@ namespace { } } - BuildResult BuildAgainst(const fs::path& project, const fs::path& sysroot) { + 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 = ConfigurationType::Executable; + app.type = type; std::array ifaces = {}; std::array impls = { "main" }; app.GetInterfacesAndImplementations(ifaces, impls); @@ -68,16 +72,27 @@ int main() { const fs::path sysroot = scratch / "sysroot"; const fs::path libDir = sysroot / "lib" / "wasm32-wasip1"; - fs::create_directories(libDir / "eh"); + { + 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"); diff --git a/tests/WasmBuildRefusals/fixture/main.cpp b/tests/WasmBuildRefusals/fixture/main.cpp new file mode 100644 index 0000000..97eb9af --- /dev/null +++ b/tests/WasmBuildRefusals/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/WasmBuildRefusals/main.cpp b/tests/WasmBuildRefusals/main.cpp new file mode 100644 index 0000000..42e8fee --- /dev/null +++ b/tests/WasmBuildRefusals/main.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// What a wasm32 build refuses before it starts, now that every wasm unit has +// C++ exceptions, each with a message naming the cause: +// - -flto in compileFlags or linkFlags. LTO code generation drops the +// exception handling, and the build used to succeed with every throw +// escaping its catch (WebAssembly/wasi-sdk#629). +// - -fno-exceptions. The std module is built with exceptions, and every +// `import std;` failed on a BMI configuration mismatch. +// - A shared library. The exception-enabled libc++ is static archives +// only, and the link failed on a relocation that wants -fPIC. +// None of them leaves bin/ or build/ behind, and none needs a working wasm +// toolchain. The sysroot given does not exist, so a refusal that did not +// fire would show up as the missing sysroot instead. +namespace { + std::int32_t failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } + + struct Refusal { + std::string_view what; + std::vector compileFlags; + std::vector 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 ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + std::unordered_map> 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; +} diff --git a/tests/WasmExceptions/fixture/jump.c b/tests/WasmExceptions/fixture/jump.c new file mode 100644 index 0000000..76e3146 --- /dev/null +++ b/tests/WasmExceptions/fixture/jump.c @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#include +#include + +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; + } +} diff --git a/tests/WasmExceptions/fixture/main.cpp b/tests/WasmExceptions/fixture/main.cpp index 4774ba3..e068a18 100644 --- a/tests/WasmExceptions/fixture/main.cpp +++ b/tests/WasmExceptions/fixture/main.cpp @@ -3,6 +3,9 @@ import std; +// jump.c, compiled as C. +extern "C" std::int32_t JumpRoundTrip(); + namespace { std::int32_t unwound = 0; @@ -31,5 +34,13 @@ int main(int argc, char** argv) { } 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; } diff --git a/tests/WasmExceptions/main.cpp b/tests/WasmExceptions/main.cpp index 5ce413a..e06b2a9 100644 --- a/tests/WasmExceptions/main.cpp +++ b/tests/WasmExceptions/main.cpp @@ -8,17 +8,20 @@ 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. +// 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. // // 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. +// (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; @@ -51,6 +54,10 @@ int main() { std::array ifaces = {}; std::array impls = { "main" }; cfg.GetInterfacesAndImplementations(ifaces, impls); + cfg.cFiles = { cfg.path / "jump" }; + // setjmp and longjmp live in libsetjmp, which the build does not link + // on its own. + cfg.linkFlags.push_back("-lsetjmp"); std::unordered_map> depResults; std::mutex depMutex; @@ -64,10 +71,12 @@ int main() { 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)); + 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, std::format("an exception escaping main fails the run: {}", escaped.output)); + 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);