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
14 changes: 14 additions & 0 deletions docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ separate `-lunwind`.
to wasm-EH `try_table` / `catch_ref` instructions. Without it, catch
handlers are dead-code-eliminated and `throw` hangs at runtime.

The Wasm SjLj lowering represents `longjmp` with a dedicated exception tag.
With C++ exceptions enabled, a `noexcept` function containing a
`setjmp`/`sigsetjmp` landing pad can intercept that tag in its generated
termination handler before the landing pad consumes the jump. C++ event loops
that establish a landing pad and then invoke code that can jump back to it must
leave that polling function non-`noexcept` on Wasm. This is a toolchain
portability boundary, not a change to POSIX `longjmp` semantics.

Seeing the SjLj catch in a linked module is not sufficient evidence that this
combination is safe. Clang can emit both the local longjmp landing and a nested
`catch_all` termination region for `noexcept`; exception dispatch selects the
nearer termination region first. This ordering is present in clang's linked
output before fork instrumentation and remains after instrumentation.

### Building static libraries

```bash
Expand Down
63 changes: 54 additions & 9 deletions packages/registry/dinit/build-dinit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,47 @@ fi

cd "$SRC_DIR"

# --- Apply target portability patches ---
# Dasynq wakes its pselect backend by siglongjmp'ing out of an SA_SIGINFO
# handler. Kandelo's Wasm SjLj lowering represents that jump with a dedicated
# exception tag. When C++ Wasm EH is enabled, pull_events' upstream noexcept
# boundary catches the tag and calls std::terminate before the local sigsetjmp
# landing pad can run. Removing that boundary preserves the POSIX control flow;
# the jump is still consumed by the landing pad in the same function.
PATCH_DIR="$SCRIPT_DIR/patches"
PATCH_SET=(
"0001-wasm-sjlj-pselect-noexcept.patch"
)

echo "==> Applying dinit wasm32 portability patches..."
for patch_name in "${PATCH_SET[@]}"; do
patch_file="$PATCH_DIR/$patch_name"
if patch --reverse --dry-run -p1 < "$patch_file" >/dev/null 2>&1; then
echo " $patch_name already applied"
elif patch --forward --dry-run -p1 < "$patch_file" >/dev/null 2>&1; then
patch -p1 < "$patch_file"
else
echo "ERROR: $patch_name does not apply and is not already present" >&2
exit 1
fi
done

PSELECT_HEADER="$SRC_DIR/dasynq/include/dasynq/pselect.h"
if grep -q 'void pull_events(bool do_wait) noexcept' "$PSELECT_HEADER"; then
echo "ERROR: Dasynq pselect pull_events still has the incompatible noexcept boundary" >&2
exit 1
fi
if ! grep -q 'WebAssembly lowers siglongjmp' "$PSELECT_HEADER"; then
echo "ERROR: Dasynq pselect Wasm SjLj compatibility patch is missing" >&2
exit 1
fi

HOST_CXX="${CXX_FOR_BUILD:-c++}"
if [ -n "${NIX_CC_FOR_BUILD:-}" ] \
&& [ -x "$NIX_CC_FOR_BUILD/bin/c++" ]; then
HOST_CXX="$NIX_CC_FOR_BUILD/bin/c++"
fi

# --- Configure ---
# dinit's build is driven by mconfig (a make-included config file). We
# generate one by hand for the cross-compile rather than running
Expand All @@ -123,20 +164,24 @@ cat > mconfig <<EOF
CXX = wasm32posix-c++
CC = wasm32posix-cc

# Host toolchain — used by build/tools/mconfig-gen and any other
# generator binary that runs on the developer machine. The default
# c++ resolves to clang++ on macOS or g++ on Linux.
CXX_FOR_BUILD = c++
# Host toolchain — used by build/tools/mconfig-gen and any other generator
# binary that runs on the developer machine. Prefer Nix's declared build
# compiler when the dev shell exposes one; otherwise use the caller's compiler
# or the platform c++ default.
CXX_FOR_BUILD = $HOST_CXX
CXXFLAGS_FOR_BUILD = -std=c++14 -O1
CPPFLAGS_FOR_BUILD =
LDFLAGS_FOR_BUILD =

# Target flags. dinit uses C++ exceptions in its client code (dinitctl,
# dinit-monitor) so we cannot disable them. Add libc++ include path
# explicitly since the wasm32posix toolchain does not auto-include it;
# the library is picked up at link time via -lc++ -lc++abi.
# Target flags. dinit uses C++ exceptions in both its supervisor and client
# tools, so compile all targets with the WebAssembly exception model. Without
# -fwasm-exceptions, clang emits Wasm throw instructions but no catch handlers,
# and expected service-description or connection errors escape to the host as
# WebAssembly.Exception. Add the libc++ include path explicitly since the
# wasm32posix toolchain does not auto-include it; the library is picked up at
# link time via -lc++ -lc++abi.
CPPFLAGS = -D_POSIX_C_SOURCE=200809L -isystem $SYSROOT/include/c++/v1
CXXFLAGS = -std=c++14 -O2 -Wall -Wextra
CXXFLAGS = -std=c++14 -O2 -Wall -Wextra -fwasm-exceptions
CFLAGS = -O2 -Wall

# Link flags (target). Explicit -L because the SDK wrapper does not
Expand Down
6 changes: 3 additions & 3 deletions packages/registry/dinit/build.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
script_path = "packages/registry/dinit/build-dinit.sh"
repo_url = "https://github.com/brandonpayton/kandelo.git"
commit = "8c53383229fab78f97b098c3207a655159c03041"
revision = 3
repo_url = "https://github.com/Automattic/kandelo.git"
commit = "UNPUBLISHED"
revision = 5

[binary]
index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml"
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
--- a/dasynq/include/dasynq/pselect.h
+++ b/dasynq/include/dasynq/pselect.h
@@ -225,7 +225,12 @@ template <class Base> class pselect_events : public signal_events<Base, false>
//
// do_wait - if false, returns immediately if no events are
// pending.
- void pull_events(bool do_wait) noexcept
+ //
+ // WebAssembly lowers siglongjmp to a dedicated exception tag. A C++
+ // noexcept boundary adds a nearer catch-all termination region even
+ // though the compiled function still contains its sigsetjmp landing.
+ // The pselect signal path must allow the tag to reach that landing.
+ void pull_events(bool do_wait)
{
struct timespec ts;
struct timespec *wait_ts = nullptr;
177 changes: 177 additions & 0 deletions packages/registry/dinit/test/dinit-sigchld-sjlj.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
findRepoRoot,
resolveBinary,
tryResolveBinary,
} from "../../../../host/src/binary-resolver";
import { NodeKernelHost } from "../../../../host/src/node-kernel-host";
import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs";
import { writeVfsBinary } from "../../../../host/src/vfs/image-helpers";
import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper";
import { addDinitInit } from "../../../../images/vfs/scripts/dinit-image-helpers";

const fixture = resolveBinary("programs/dinit_sigchld_sjlj.wasm");
const noexceptFixture = resolveBinary(
"programs/dinit_sjlj_noexcept_boundary.wasm",
);
const rawNoexceptFixture = join(
findRepoRoot(),
"local-binaries/test-fixtures/dinit_sjlj_noexcept_boundary_uninstrumented.wasm",
);
const dinit = tryResolveBinary("programs/dinit/dinit.wasm");
const TERMINATED_BY_SIGABRT = 128 + 6;
const TERMINATED_BY_SIGTERM = 128 + 15;

function arrayBuffer(bytes: Buffer | Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}

describe("dinit Wasm exception and SjLj compatibility", () => {
it("keeps the negative control structurally independent of fork instrumentation", () => {
const rawModule = new WebAssembly.Module(readFileSync(rawNoexceptFixture));
const instrumentedModule = new WebAssembly.Module(
readFileSync(noexceptFixture),
);
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([
["clang-linked", rawNoexceptFixture],
["fork-instrumented", noexceptFixture],
])(
"terminates at the %s noexcept boundary before landing",
async (_, programPath) => {
const result = await runCentralizedProgram({
programPath,
argv: ["dinit_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 across a non-noexcept boundary", async () => {
const result = await runCentralizedProgram({
programPath: noexceptFixture,
argv: ["dinit_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");
});

it("reaps SIGCHLD after siglongjmp resumes the pselect landing pad", async () => {
const result = await runCentralizedProgram({
programPath: fixture,
argv: ["dinit_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");
});
});

describe.skipIf(!dinit)("dinit SIGCHLD supervision", () => {
it("completes a scripted service after reaping its child", async () => {
const rootfs = readFileSync(resolveBinary("rootfs.vfs"));
const fs = MemoryFileSystem.fromImage(
new Uint8Array(rootfs.buffer, rootfs.byteOffset, rootfs.byteLength),
);
addDinitInit(
fs,
[
{
name: "child",
type: "scripted",
command: "/bin/echo child-exited",
restart: false,
},
],
{ boot: false },
);
writeVfsBinary(
fs,
"/bin/echo",
readFileSync(resolveBinary("programs/echo.wasm")),
0o755,
);

let output = "";
let resolveReady!: () => void;
const ready = new Promise<void>((resolve) => {
resolveReady = resolve;
});
const decoder = new TextDecoder();
const onOutput = (_pid: number, data: Uint8Array) => {
output += decoder.decode(data);
if (output.includes("[ OK ] child")) resolveReady();
};

const host = new NodeKernelHost({
maxWorkers: 4,
rootfsImage: await fs.saveImage(),
onStdout: onOutput,
onStderr: onOutput,
});
await host.init(arrayBuffer(readFileSync(resolveBinary("kernel.wasm"))));

let dinitPid = -1;
const spawn = host.spawn(
arrayBuffer(readFileSync(dinit!)),
["/sbin/dinit", "--container", "-p", "/tmp/dinitctl", "child"],
{
onStarted: (pid) => {
dinitPid = pid;
},
},
);
void spawn.catch(() => {});
let timeout: ReturnType<typeof setTimeout> | undefined;
let terminatedStatus: number | undefined;
try {
await Promise.race([
ready,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`Dinit child-reap timeout\n${output}`)),
10_000,
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
if (dinitPid > 0) {
await host.terminateProcess(dinitPid, TERMINATED_BY_SIGTERM);
terminatedStatus = await spawn;
}
await host.destroy();
}

expect(terminatedStatus).toBe(TERMINATED_BY_SIGTERM);
expect(output).toContain("[ OK ] child");
expect(output).not.toContain("libc++abi: terminating");
}, 30_000);
});
21 changes: 21 additions & 0 deletions packages/registry/dinit/test/dinitctl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { tryResolveBinary } from "../../../../host/src/binary-resolver";
import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper";

const dinitctlBinary = tryResolveBinary("programs/dinit/dinitctl.wasm");

describe.skipIf(!dinitctlBinary)("dinitctl", () => {
it("reports a missing control socket as an ordinary process error", async () => {
const socketPath = "/tmp/kandelo-dinitctl-missing.sock";
const result = await runCentralizedProgram({
programPath: dinitctlBinary!,
argv: ["dinitctl", "-p", socketPath, "list"],
timeout: 10_000,
});

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(`connecting to socket: ${socketPath}`);
expect(result.stderr).not.toContain("WebAssembly.Exception");
expect(result.stderr).not.toContain("libc++abi: terminating");
});
});
Loading
Loading