diff --git a/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts b/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts new file mode 100644 index 0000000000..580f572f52 --- /dev/null +++ b/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findRepoRoot, resolveBinary } from "../../../host/src/binary-resolver"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const browserKernelModulePath = resolve( + __dirname, + "../../../host/src/browser-kernel-host.ts", +); +const repoRoot = findRepoRoot(); + +const fixturePaths = { + rawWasm32: join( + repoRoot, + "local-binaries/test-fixtures/wasm32/sjlj_noexcept_boundary.raw.wasm", + ), + rawWasm64: join( + repoRoot, + "local-binaries/test-fixtures/wasm64/sjlj_noexcept_boundary.raw.wasm", + ), + instrumented: resolveBinary("programs/sjlj_noexcept_boundary.wasm"), + sigchld: resolveBinary("programs/sigchld_sjlj.wasm"), +}; + +test("Chromium preserves the SjLj controls and positive SIGCHLD path", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL).href; + const browserKernelModuleUrl = asViteFsUrl(browserKernelModulePath); + const fixtureUrls = Object.fromEntries( + Object.entries(fixturePaths).map(([name, path]) => [name, asViteFsUrl(path)]), + ); + + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + const results = await page.evaluate( + async ({ browserKernelModuleUrl, fixtureUrls }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const kernel = new BrowserKernel({ + maxWorkers: 4, + onStdout: (data: Uint8Array) => { + stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += decoder.decode(data); + }, + }); + let initialized = false; + + const run = async (url: string, argv: string[]) => { + stdout = ""; + stderr = ""; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`fixture fetch failed: ${response.status} ${url}`); + } + const exitCode = await kernel.spawn(await response.arrayBuffer(), argv); + return { exitCode, stdout, stderr }; + }; + + try { + await kernel.initFromImage({ vfsImage: "default" }); + initialized = true; + return { + rawWasm32: await run(fixtureUrls.rawWasm32, [ + "sjlj_noexcept_boundary", + "--noexcept", + ]), + instrumented: await run(fixtureUrls.instrumented, [ + "sjlj_noexcept_boundary", + "--noexcept", + ]), + permissive: await run(fixtureUrls.instrumented, [ + "sjlj_noexcept_boundary", + "--permissive", + ]), + sigchld: await run(fixtureUrls.sigchld, ["sigchld_sjlj"]), + rawWasm64: await run(fixtureUrls.rawWasm64, [ + "sjlj_noexcept_boundary", + "--noexcept", + ]), + }; + } finally { + if (initialized) await kernel.destroy(); + } + }, + { browserKernelModuleUrl, fixtureUrls }, + ); + + for (const control of [ + results.rawWasm32, + results.instrumented, + results.rawWasm64, + ]) { + expect(control.exitCode).toBe(128 + 6); + expect(control.stderr).toContain("HANDLER: siglongjmp"); + expect(control.stderr).toContain("libc++abi: terminating"); + expect(control.stdout).not.toContain("LANDING: siglongjmp resumed"); + } + + expect(results.permissive).toMatchObject({ exitCode: 0 }); + expect(results.permissive.stdout).toContain("LANDING: siglongjmp resumed"); + expect(results.sigchld).toMatchObject({ exitCode: 0 }); + expect(results.sigchld.stdout).toContain( + "PASS: SIGCHLD siglongjmp resumed at pselect landing pad", + ); +}); diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index a18448a68d..143dc8bbaa 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -118,6 +118,23 @@ separate `-lunwind`. to wasm-EH `try_table` / `catch_ref` instructions. Without it, catch handlers are dead-code-eliminated and `throw` hangs at runtime. +#### LLVM 21 SjLj and `noexcept` limitation + +Kandelo's pinned LLVM 21.1.7 toolchain lowers `longjmp` and `siglongjmp` to an +internal Wasm exception. If that transfer crosses a C++ `noexcept` frame, +Clang's generated termination handler can intercept the internal tag before +the matching `setjmp` or `sigsetjmp` landing consumes it. The process then +calls `std::terminate()` even when the C control transfer itself is valid. + +This is a known SDK/toolchain limitation tracked in +[issue #918](https://github.com/Automattic/kandelo/issues/918), not a change to +POSIX signal or `longjmp` semantics. It is present in raw clang-linked wasm32 +and wasm64 modules and remains present after Kandelo's wasm32 fork +instrumentation. Until the pinned compiler is fixed, code that establishes a +jump landing and calls work that can jump back across the current frame must +not mark that crossed frame `noexcept`. Keep the workaround scoped to that +boundary; do not disable C++ exceptions, signal delivery, or child reaping. + ### Building static libraries ```bash diff --git a/host/test/sjlj-noexcept-boundary.test.ts b/host/test/sjlj-noexcept-boundary.test.ts new file mode 100644 index 0000000000..53fe0be083 --- /dev/null +++ b/host/test/sjlj-noexcept-boundary.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findRepoRoot, resolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = findRepoRoot(); +const rawWasm32Fixture = join( + repoRoot, + "local-binaries/test-fixtures/wasm32/sjlj_noexcept_boundary.raw.wasm", +); +const rawWasm64Fixture = join( + repoRoot, + "local-binaries/test-fixtures/wasm64/sjlj_noexcept_boundary.raw.wasm", +); +const instrumentedFixture = resolveBinary( + "programs/sjlj_noexcept_boundary.wasm", +); +const sigchldFixture = resolveBinary("programs/sigchld_sjlj.wasm"); +const TERMINATED_BY_SIGABRT = 128 + 6; + +describe("LLVM Wasm SjLj across a noexcept boundary", () => { + it("keeps the raw wasm32 control independent of fork instrumentation", () => { + const rawModule = new WebAssembly.Module(readFileSync(rawWasm32Fixture)); + const instrumentedModule = new WebAssembly.Module( + readFileSync(instrumentedFixture), + ); + const exportNames = (module: WebAssembly.Module) => + WebAssembly.Module.exports(module).map(({ name }) => name); + + expect(exportNames(rawModule)).not.toContain("wpk_fork_state"); + expect(exportNames(instrumentedModule)).toContain("wpk_fork_state"); + }); + + it.each([ + ["raw wasm32", rawWasm32Fixture], + ["fork-instrumented wasm32", instrumentedFixture], + ["raw wasm64", rawWasm64Fixture], + ])("documents the pinned LLVM failure in the %s control", async (_, path) => { + const result = await runCentralizedProgram({ + programPath: path, + argv: ["sjlj_noexcept_boundary", "--noexcept"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(TERMINATED_BY_SIGABRT); + expect(result.stderr).toContain("HANDLER: siglongjmp"); + expect(result.stderr).toContain("libc++abi: terminating"); + expect(result.stdout).not.toContain("LANDING: siglongjmp resumed"); + }); + + it("resumes the same SjLj tag when it does not cross noexcept", async () => { + const result = await runCentralizedProgram({ + programPath: instrumentedFixture, + argv: ["sjlj_noexcept_boundary", "--permissive"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("HANDLER: siglongjmp"); + expect(result.stdout).toContain("LANDING: siglongjmp resumed"); + expect(result.stderr).not.toContain("libc++abi: terminating"); + }); +}); + +describe("SIGCHLD SjLj control", () => { + it("resumes pselect and reaps the child after SIGCHLD", async () => { + const result = await runCentralizedProgram({ + programPath: sigchldFixture, + argv: ["sigchld_sjlj"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "PASS: SIGCHLD siglongjmp resumed at pselect landing pad", + ); + expect(result.stderr).not.toContain("libc++abi: terminating"); + }); +}); diff --git a/programs/sigchld_sjlj.c b/programs/sigchld_sjlj.c new file mode 100644 index 0000000000..27cbd8dfe7 --- /dev/null +++ b/programs/sigchld_sjlj.c @@ -0,0 +1,81 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +static sigjmp_buf signal_landing; + +static void sigchld_handler(int signo, siginfo_t *info, void *context) +{ + (void)info; + (void)context; + if (signo == SIGCHLD) { + siglongjmp(signal_landing, 1); + } +} + +static int wait_for_sigchld(pid_t child, const sigset_t *original_mask) +{ + if (sigsetjmp(signal_landing, 1) == 0) { + sigset_t wait_mask = *original_mask; + sigdelset(&wait_mask, SIGCHLD); + int result = pselect(0, NULL, NULL, NULL, NULL, &wait_mask); + fprintf(stderr, "pselect returned without siglongjmp: %d (%s)\n", + result, strerror(errno)); + return 1; + } + + int status = 0; + pid_t waited; + do { + waited = waitpid(child, &status, 0); + } while (waited == -1 && errno == EINTR); + + if (sigprocmask(SIG_SETMASK, original_mask, NULL) != 0) { + fprintf(stderr, "sigprocmask restore: %s\n", strerror(errno)); + return 1; + } + if (waited != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fputs("waitpid did not reap the expected clean child\n", stderr); + return 1; + } + + puts("PASS: SIGCHLD siglongjmp resumed at pselect landing pad"); + return 0; +} + +int main(void) +{ + sigset_t blocked_mask; + sigset_t original_mask; + sigemptyset(&blocked_mask); + sigaddset(&blocked_mask, SIGCHLD); + if (sigprocmask(SIG_BLOCK, &blocked_mask, &original_mask) != 0) { + fprintf(stderr, "sigprocmask block: %s\n", strerror(errno)); + return 1; + } + + struct sigaction action = {0}; + action.sa_sigaction = sigchld_handler; + action.sa_flags = SA_SIGINFO; + sigfillset(&action.sa_mask); + if (sigaction(SIGCHLD, &action, NULL) != 0) { + fprintf(stderr, "sigaction: %s\n", strerror(errno)); + return 1; + } + + pid_t child = fork(); + if (child == -1) { + fprintf(stderr, "fork: %s\n", strerror(errno)); + return 1; + } + if (child == 0) { + _exit(0); + } + + return wait_for_sigchld(child, &original_mask); +} diff --git a/programs/sjlj_noexcept_boundary.cpp b/programs/sjlj_noexcept_boundary.cpp new file mode 100644 index 0000000000..a7b63d757c --- /dev/null +++ b/programs/sjlj_noexcept_boundary.cpp @@ -0,0 +1,89 @@ +#include +#include +#include +#include +#include +#include +#include + +static sigjmp_buf signal_landing; + +static void signal_handler(int signo) +{ + static const char marker[] = "HANDLER: siglongjmp\n"; + if (signo == SIGUSR1) { + (void)write(STDERR_FILENO, marker, sizeof(marker) - 1); + siglongjmp(signal_landing, 1); + } +} + +// LLVM 21 lowers noexcept to a catch-all termination region. With Wasm SjLj, +// that region intercepts the internal longjmp exception before the enclosing +// sigsetjmp landing can consume it. See issue #918. +__attribute__((noinline)) static void raise_from_noexcept() noexcept +{ + if (raise(SIGUSR1) != 0) { + std::fprintf(stderr, "raise: %s\n", std::strerror(errno)); + } +} + +__attribute__((noinline)) static void raise_from_permissive_boundary() +{ + if (raise(SIGUSR1) != 0) { + std::fprintf(stderr, "raise: %s\n", std::strerror(errno)); + } +} + +#ifndef KANDELO_SJLJ_NO_FORK_ANCHOR +// The test never selects this branch. Its kernel_fork import makes the wasm32 +// program a real input to fork-instrument, so the saved raw module and normal +// program exercise distinct pre- and post-instrumentation artifacts. +__attribute__((noinline)) static int fork_instrumentation_anchor() +{ + pid_t child = fork(); + if (child == -1) { + return 1; + } + if (child == 0) { + _exit(0); + } + + int status = 0; + return waitpid(child, &status, 0) == child && WIFEXITED(status) + && WEXITSTATUS(status) == 0 + ? 0 + : 1; +} +#endif + +int main(int argc, char **argv) +{ +#ifndef KANDELO_SJLJ_NO_FORK_ANCHOR + if (argc == 2 && std::strcmp(argv[1], "--fork-instrumentation-anchor") == 0) { + return fork_instrumentation_anchor(); + } +#endif + + struct sigaction action = {}; + action.sa_handler = signal_handler; + sigfillset(&action.sa_mask); + if (sigaction(SIGUSR1, &action, nullptr) != 0) { + std::fprintf(stderr, "sigaction: %s\n", std::strerror(errno)); + return 1; + } + + if (sigsetjmp(signal_landing, 1) == 0) { + if (argc == 2 && std::strcmp(argv[1], "--permissive") == 0) { + raise_from_permissive_boundary(); + } else { + raise_from_noexcept(); + } + static const char unexpected[] = "FAIL: raise returned past signal handler\n"; + (void)write(STDERR_FILENO, unexpected, sizeof(unexpected) - 1); + return 2; + } + + static const char landed[] = "LANDING: siglongjmp resumed\n"; + (void)write(STDOUT_FILENO, landed, sizeof(landed) - 1); + return 0; +} diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index 68bbf342a2..ee5253d474 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -18,7 +18,8 @@ GLUE_DIR="$REPO_ROOT/libc/glue" # last-write-wins across arches. OUT_DIR_32="$REPO_ROOT/local-binaries/programs/wasm32" OUT_DIR_64="$REPO_ROOT/local-binaries/programs/wasm64" -mkdir -p "$OUT_DIR_32" "$OUT_DIR_64" +TEST_FIXTURE_DIR="$REPO_ROOT/local-binaries/test-fixtures" +mkdir -p "$OUT_DIR_32" "$OUT_DIR_64" "$TEST_FIXTURE_DIR" find_llvm_bin() { if [ -n "${LLVM_BIN:-}" ] && [ -x "$LLVM_BIN/clang" ]; then @@ -161,6 +162,16 @@ build_cpp_program() { -lc++ -lc++abi \ -o "$wasm" + # Preserve a real pre-instrumentation control for issue #918. The source + # contains an unreachable-at-test-time fork branch solely so the normal + # output is transformed below. A raw module with kernel_fork but without + # wpk_fork_* exports is test evidence, not a distributable program, so it + # lives outside the resolver's programs tree. + if [ "$name" = "sjlj_noexcept_boundary" ]; then + mkdir -p "$TEST_FIXTURE_DIR/wasm32" + cp "$wasm" "$TEST_FIXTURE_DIR/wasm32/${name}.raw.wasm" + fi + # Phase 7: fork support comes from wasm-fork-instrument. The tool is # a no-op for modules without `kernel.kernel_fork`, so it's safe to # run unconditionally — programs without fork stay byte-identical @@ -169,21 +180,35 @@ build_cpp_program() { mv "$wasm.instr" "$wasm" } +ensure_libcxx_in_sysroot() { + local arch="$1" + local sysroot="$2" + if [ -f "$sysroot/lib/libc++.a" ] && \ + [ -f "$sysroot/lib/libc++abi.a" ] && \ + [ -d "$sysroot/include/c++/v1" ]; then + return + fi + + echo "==> Resolving libcxx for $arch C++ programs..." + local host_triple + local libcxx_prefix + host_triple="$(rustc -vV | awk '/^host/ {print $2}')" + (cd "$REPO_ROOT" && cargo run -p xtask --target "$host_triple" --quiet -- \ + build-deps --arch "$arch" resolve libcxx >/dev/null) + libcxx_prefix="$(cd "$REPO_ROOT" && cargo run -p xtask \ + --target "$host_triple" --quiet -- build-deps --arch "$arch" path libcxx)" + ln -sf "$libcxx_prefix/lib/libc++.a" "$sysroot/lib/libc++.a" + ln -sf "$libcxx_prefix/lib/libc++abi.a" "$sysroot/lib/libc++abi.a" + mkdir -p "$sysroot/include/c++" + rm -rf "$sysroot/include/c++/v1" + ln -sfn "$libcxx_prefix/include/c++/v1" "$sysroot/include/c++/v1" +} + # Resolve libcxx and symlink its outputs into the sysroot if there are # any .cpp programs to build. Skip the resolver entirely when libc++.a # is already present so repeat runs are fast. if ls "$REPO_ROOT/programs/"*.cpp >/dev/null 2>&1; then - if [ ! -f "$SYSROOT/lib/libc++.a" ]; then - echo "==> Resolving libcxx for C++ programs..." - HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" - (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps resolve libcxx >/dev/null) - LIBCXX_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet -- build-deps path libcxx)" - ln -sf "$LIBCXX_PREFIX/lib/libc++.a" "$SYSROOT/lib/libc++.a" - ln -sf "$LIBCXX_PREFIX/lib/libc++abi.a" "$SYSROOT/lib/libc++abi.a" - mkdir -p "$SYSROOT/include/c++" - rm -rf "$SYSROOT/include/c++/v1" - ln -sfn "$LIBCXX_PREFIX/include/c++/v1" "$SYSROOT/include/c++/v1" - fi + ensure_libcxx_in_sysroot wasm32 "$SYSROOT" fi echo "Building user programs..." @@ -301,6 +326,24 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then "$CC" "${CFLAGS64[@]}" "$wait_lifecycle_src" "${LINK_FLAGS64[@]}" \ -o "$REPO_ROOT/examples/wait_lifecycle_test.wasm64.wasm" fi + + # Fork continuation instrumentation is currently a wasm32 artifact + # contract. Still cover the compiler's architecture-independent SjLj / + # noexcept ordering on wasm64 with a raw fixture that omits the dormant + # fork anchor. Keep it in the test-only tree for symmetry with wasm32. + sjlj_noexcept_src="$REPO_ROOT/programs/sjlj_noexcept_boundary.cpp" + if [ -f "$sjlj_noexcept_src" ]; then + ensure_libcxx_in_sysroot wasm64 "$SYSROOT64" + mkdir -p "$TEST_FIXTURE_DIR/wasm64" + echo " Compiling sjlj_noexcept_boundary (raw wasm64 test fixture)..." + wasm64posix-c++ \ + -O2 \ + -fwasm-exceptions \ + -DKANDELO_SJLJ_NO_FORK_ANCHOR \ + "$sjlj_noexcept_src" \ + -lc++ -lc++abi \ + -o "$TEST_FIXTURE_DIR/wasm64/sjlj_noexcept_boundary.raw.wasm" + fi fi echo "Programs built."