Automattic / Automattic/kandelo

Wasm setjmp/longjmp aborts inside noexcept functions

Open
#918 1 comment 0 reactions 0 assignees View on GitHub
bug
Dominant language
TypeScript
Stars
31
Forks
15
Avg merge
11h 7m
Merged PRs (30d)
80

Description

## Summary

A C++ program built with Kandelo's SDK can abort when a `setjmp`/`sigsetjmp`
landing point is protected by a `noexcept` function. A matching
`longjmp`/`siglongjmp` should resume at that landing point. With LLVM 21's
WebAssembly SjLj lowering, it is instead represented as an internal Wasm
exception, and the generated `noexcept` handler calls `std::terminate()` before
LLVM's generated longjmp handler can consume it.

This first showed up in dinit v0.19.4: a scripted child exited normally,
Kandelo correctly delivered `SIGCHLD`, and dinit aborted while trying to return
from its signal handler to the event loop. PR #907 carries a narrow dinit source
patch so the current package works, but the underlying SDK/toolchain behavior
can affect other C++ software.

This is a known Kandelo platform/toolchain bug, not a dinit-only bug and not a
failure in Kandelo's `SIGCHLD` delivery.

## User-visible failure

The concrete dinit path is:

1. dinit starts a scripted service such as `/bin/true`.
2. The child exits with status 0.
3. Kandelo delivers `SIGCHLD` to dinit.
4. dasynq's signal handler records the `siginfo_t` and deliberately calls
`siglongjmp()`.
5. dasynq expects control to resume at the `sigsetjmp()` inside
`pselect_events::pull_events()` so it can process and reap the child.
6. The Wasm build instead prints `libc++abi: terminating` and exits through
`SIGABRT` before reaching that landing point.

Upstream dasynq documents this signal strategy and contains both sides of the
transfer:

- [`pull_events(bool) noexcept` and its local `sigsetjmp`](https://github.com/davmac314/dinit/blob/029f54d007386c815aee6b6d458d691c3bf95e15/dasynq/include/dasynq/pselect.h#L218-L277)
- [the signal handler's `siglongjmp`](https://github.com/davmac314/dinit/blob/029f54d007386c815aee6b6d458d691c3bf95e15/dasynq/include/dasynq/signal.h#L78-L88)

The same `pull_events() noexcept` declaration is still present on dinit's
upstream `master` branch as of this report.

## Expected behavior

For a valid use of `setjmp`/`longjmp` that does not skip required non-trivial
C++ destructors:

- a matching longjmp must reach its active setjmp landing point;
- `noexcept` must still terminate a genuine escaping C++ exception;
- an unmatched longjmp must continue outward to the frame that owns its jump
buffer; and
- `sigsetjmp`/`siglongjmp` must continue saving and restoring the POSIX signal
mask.

`noexcept` describes C++ exception propagation. It should not, by itself, turn
this valid C control transfer into a fatal C++ exception. This issue does not
ask Kandelo to support longjmps that are already undefined in C++, such as a
transfer that improperly bypasses non-trivial automatic-object destruction.

## Root cause

There are three pieces to the interaction:

1. The [SDK always enables Wasm exception handling and LLVM's Wasm SjLj
pass](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/sdk/src/lib/flags.ts#L4-L20).
2. Kandelo's `sigsetjmp`/`siglongjmp` macros [save or restore the signal mask,
then delegate to literal `setjmp`/`longjmp`](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/libc/musl-overlay/include/setjmp.h#L29-L48), because those are the names LLVM recognizes.
3. Kandelo's Wasm libc runtime implements `__wasm_longjmp` by [throwing the
dedicated `C_LONGJMP` tag](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/libc/musl-overlay/src/setjmp/wasm32/rt.c#L73-L88).

LLVM's intended Wasm SjLj lowering creates a dedicated `C_LONGJMP` catch,
checks the jump buffer with `__wasm_setjmp_test`, and either resumes locally or
rethrows toward an outer landing point. That design is described directly in
[`WebAssemblyLowerEmscriptenEHSjLj.cpp`](https://github.com/llvm/llvm-project/blob/llvmorg-21.1.7/llvm/lib/Target/WebAssembly/WebAssemblyLowerEmscriptenEHSjLj.cpp#L194-L225).

The bad interaction is with Clang's lowering of `noexcept` to a terminate
catch-all. With LLVM/Clang 21.1.7, the affected function ends up with the
terminate catch-all taking the internal longjmp tag before the generated
`C_LONGJMP` handler can test it. In the minimized build:

- the non-`noexcept` control gets the expected `C_LONGJMP` landing path and
resumes successfully;
- the otherwise identical `noexcept` build reaches the signal handler, then
terminates before printing the landing marker; and
- both the raw clang-linked module and the fork-instrumented module fail in the
same way, ruling out Kandelo's fork instrumenter as the cause.

LLVM 21.1.7 emits no warning for the pattern under `-Wall -Wextra
-Wexceptions`, or even `-Weverything`.

## Reproduction and existing evidence

The original fix and end-to-end evidence are in PR #907:

- [dinit fix commit](https://github.com/Automattic/kandelo/commit/0f464442db59397fab7098ae154d7e9640cc345c)
- [one-line compatibility patch](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/packages/registry/dinit/patches/0001-wasm-sjlj-pselect-noexcept.patch)
- [package-build rationale and flags](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/packages/registry/dinit/build-dinit.sh#L104-L170)
- [scripted-service and real-C++-error integration coverage](https://github.com/Automattic/kandelo/blob/0f464442db59397fab7098ae154d7e9640cc345c/packages/registry/dinit/test/dinit-scripted-service.test.ts#L58-L159)

The investigation also produced smaller fixtures, preserved in an earlier
standalone commit:

- [minimal `siglongjmp`/`noexcept` negative and permissive control](https://github.com/Automattic/kandelo/blob/d2cf206a654e1804b59ee1960473f608a6df0f5f/programs/dinit_sjlj_noexcept_boundary.cpp)
- [positive `SIGCHLD` plus `pselect`/reap fixture](https://github.com/Automattic/kandelo/blob/d2cf206a654e1804b59ee1960473f608a6df0f5f/programs/dinit_sigchld_sjlj.cpp)
- [raw, fork-instrumented, positive, and end-to-end assertions](https://github.com/Automattic/kandelo/blob/d2cf206a654e1804b59ee1960473f608a6df0f5f/packages/registry/dinit/test/dinit-sigchld-sjlj.test.ts)

Those focused assertions pass as tests of the currently known behavior on the
pinned LLVM/Clang 21.1.7 toolchain: the two negative controls reliably abort,
while removing only the conflicting `noexcept` boundary lets the landing point
run. They document the bug; they do not mean the negative behavior is correct.

## Current workaround

PR #907 removes `noexcept` only from dasynq's `pull_events(bool)` in Kandelo's
dinit package. It does **not** disable C++ exceptions and does not change
Kandelo's signal semantics.

The only callers in dinit's current event-loop path, `run()` and `poll()`,
remain `noexcept`. A genuine uncaught C++ exception therefore still terminates
one frame later. dinit's intended `try`/`catch` paths also remain active; the
regression test verifies that a malformed service description is caught and
reported normally.

This is an acceptable package compatibility patch while the platform bug is
open, but it is not the general fix. We should not special-case `SIGCHLD`, defer
signal delivery, disable C++ exceptions, or broadly strip `noexcept` from
package sources.

## Can code instrumentation fix this?

Probably, yes—but the safest corrective instrumentation would live in or next
to LLVM's Wasm SjLj lowering, not as a source-text rewrite.

A compiler pass could make the internal `C_LONGJMP` route take priority over a
`noexcept` terminate catch-all, call `__wasm_setjmp_test`, and then either jump
to the matching landing point or rethrow the longjmp tag. The ordinary C++
exception tag must continue to hit `std::terminate`. This is conceptually
possible because LLVM already generates almost all of that control flow for
non-`noexcept` functions.

However, a Kandelo-only corrective pass would duplicate delicate compiler
logic. It would need to handle nested jump buffers, outer-frame targets,
optimization, `try_table`/`catch_ref`, wasm32 and wasm64, and interaction with
later fork instrumentation. A post-link Wasm rewrite is even less attractive
because source types and much of the EH intent have already been erased.

The preferred order is therefore:

1. Add a minimal executable regression fixture that distinguishes the internal
longjmp tag from a real C++ exception.
2. Add an SDK warning or pre-lowering IR check so package builds flag the
hazardous pattern now.
3. Add a final-artifact audit as defense in depth for inputs that bypass the
normal compiler wrapper.
4. Fix or carry a patch to LLVM's Wasm SjLj lowering, then require that compiler
version in the SDK.
5. Rebuild affected packages and remove source patches such as dinit's only
after the fixed toolchain passes the runtime tests.

### Near-term SDK detection

An AST/libTooling warning can flag a resolved `setjmp` call inside an explicitly
or implicitly non-throwing C++ function. It will also see Kandelo's `sigsetjmp`
macro after expansion. This is easy to explain at the source line, but it can
have false positives when no longjmp is reachable while that frame is active.

A pre-lowering LLVM IR check is more precise. The known bad shape is a
non-throwing function containing `setjmp` whose unwind path goes to terminate
instead of the Wasm SjLj handler. We can introduce this as an advisory SDK
diagnostic, scan the package registry, and later make it fatal in maintained
package CI.

A post-link audit can look for `__wasm_setjmp` registration plus a terminate
catch-all without a covering `C_LONGJMP`/`__wasm_setjmp_test` route. This is
useful for publication checks, but optimization and stripped names make it too
brittle to be the primary detector.

## Completion criteria

- [ ] Add a small maintained regression program covering `setjmp` and
Kandelo's `sigsetjmp` macro, at `-O0` and `-O2`.
- [ ] Prove on Node.js and in a real browser that a valid matching longjmp
resumes through the affected `noexcept` shape.
- [ ] Prove separately that a genuine C++ exception escaping `noexcept` still
terminates.
- [ ] Cover both wasm32 and wasm64, or document a real architecture boundary.
- [ ] Add an SDK diagnostic for the known hazardous compiler shape, with a
narrow suppression mechanism and a registry-wide baseline.
- [ ] Add a deterministic final-artifact guard if it can be made robust across
optimization and fork instrumentation.
- [ ] File the minimized compiler bug upstream and either consume the upstream
fix or carry a narrowly reviewed LLVM patch in Kandelo's pinned
toolchain.
- [ ] Update the SDK/porting documentation while affected LLVM versions remain
supported.
- [ ] Rebuild dinit and remove its source patch only after the fixed toolchain
passes the minimal, dinit, Node.js, browser, and real-C++-exception tests.

## Scope notes

- Signal delivery and child reaping are not the defective layer here; they are
the trigger that made the compiler interaction visible.
- The current dinit workaround changes package bytes but not Kandelo's kernel
ABI.
- A future compiler/runtime change must still be evaluated for artifact and ABI
compatibility before publishing rebuilt packages.
- No duplicate Kandelo issue or matching upstream LLVM issue was found during
the initial audit.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with programs/dinit_sjlj_noexcept_boundary.cpp and programs/dinit_sigchld_sjlj.cpp, then run packages/registry/dinit/test/dinit-sigchld-sjlj.test.ts on the pinned LLVM/Clang 21.1.7 toolchain. Read sdk/src/lib/flags.ts and the Wasm SjLj lowering and runtime files named in the report. Done requires a maintained regression across optimization levels and targets, preserved genuine noexcept termination, an SDK diagnostic, and a narrowly reviewed compiler or toolchain fix.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, node.js, typescript, wasm
Domain
build-system, compilers, documentation, testing
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.