Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
17 changes: 17 additions & 0 deletions docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions host/test/sjlj-noexcept-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
81 changes: 81 additions & 0 deletions programs/sigchld_sjlj.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#include <errno.h>
#include <setjmp.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <unistd.h>

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);
}
89 changes: 89 additions & 0 deletions programs/sjlj_noexcept_boundary.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <setjmp.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

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;
}
Loading