brintos

brintos / hwjs-cc public Read only

0
0

HwjsCoroutinize refuses invoke-form callee-suspend on the noreturn-pump arm (__cxa_throw, __abort_message) — C++ exceptions AND plain libc++ STL unlinkable on wasm32-hwjs (first witnesses of EXCLUSION-LEDGER #10) #10

Open dmitry opened this issue · 0 comments
D dmitry commented

Enabling libc++/libc++abi for wasm32-hwjs (Emcraft BRINTOS-105) produces the first real witnesses of the refusal class EXCLUSION-LEDGER.md §A #10 records as "0 in the glibc hello merged closure … The floor fires only for the unhandled arms, none observed."

The floor now fires on real code, and it blocks every C++ exception — and, via the default terminate path's demangler, plain STL with no exceptions at all.

Reproduction

Toolchain: superproject a31fd65, hwjs-cc @ 23953656 (plugin attests dirty=0), glibc arm (wasmcc-glibc), HWJS_CORO_DRIVE_FLAT=1. libc++/libc++abi/libunwind built from the pinned llvm-project as uniform-bitcode archives (standalone runtimes cmake build, -emit-llvm, -mexception-handling -fwasm-exceptions -mllvm -wasm-enable-eh), so they join the whole-program merge like the glibc pool.

// c.cpp
#include <cstdio>
#include <stdexcept>
int main(){ try { throw std::runtime_error("x"); }
  catch(const std::exception& e){ printf("C-OK %s\n", e.what()); } return 0; }
$ wasmc++-glibc c.o libc++.a libc++abi.a libunwind.a -o c.wasm
fatal error: error in backend: [hwjs-coro] REFUSE fn=main
  reason=invoke-form-callee-suspend-unsupported callee=__cxa_throw arm=noreturn-pump

Witnesses (two, independent)

# caller → callee shape
1 main__cxa_throw the canonical C++ throw
2 __cxa_demangle__abort_message inside libc++abi itself

Characterisation

Four discriminating link probes, identical toolchain:

case shape result
A STL only, no throw, no try REFUSED__cxa_demangle__abort_message
B try/catch present, nothing thrown LINK OK
C throw + catch REFUSEDmain__cxa_throw
D throw, no enclosing try REFUSED__cxa_demangle__abort_message

The class is exactly an invoke whose callee is a noreturn function that can suspend. Case A is the important one: this is not confined to programs that use exceptions — the terminate handler drags __cxa_demangle into any libc++ closure, so std::string/std::vector alone hit it.

Why the ledger's stated lift is necessary but (we think) not sufficient

The ledger names the lift as "extend the invoke-aware insertion to that arm (the noreturn pump needs the descend on the normal edge too)".

The mechanical half is indeed small and local. In CoroutinizePass.cpp the flat noreturn pump does:

IRBuilder<> AB(CS->getNextNode());   // null for an InvokeInst — it is a terminator

and contInsertPt — the invoke-aware insertion point that already splits the critical normal-dest edge — sits ~10 lines above (:10276) and is what the value/void cascade arms already use. The refusal is selected purely by CascadeArm = !NoReturnPump && !ManualDrive && (...) at :10366.

The semantic half looks like the F54/F55 problem restated for C++ EH, which is why we are not sending a patch. If __cxa_throw is descended into as a coroutine, its eventual __builtin_wasm_throw unwinds the engine stack, while the catch handler lives in a frame parked in the arena — the same reason F8's supersession note gives for C setjmp/longjmp ("an EH longjmp … cannot reach a setjmp frame parked in the arena"), which moved SjLj onto coro-reify. Note also that for a noreturn callee clang emits the invoke's normal dest as unreachable, so "the descend on the normal edge" is placing the pump on an edge the front end considers dead.

F7 proved C++ EH composes with coroutine lowering — but with an in-module __cxa_* that never suspends. Real libc++abi reaches glibc malloc (__cxa_allocate_exception), so it is suspend-marked and takes the pump. That gap between the F7 fixture and the real runtime is what these probes expose.

Candidate directions (maintainer's call)

  1. Lift the arm — route the noreturn pump's descend through contInsertPt onto the normal-dest edge, as the ledger says. Needs an answer for the engine-stack vs parked-frame question above before it can be trusted; otherwise it risks turning a loud refuse into a silently-escaping throw.
  2. Make the C++ EH entry points non-suspending — e.g. allocate exceptions from a static emergency pool rather than glibc malloc, so __cxa_throw is never suspend-marked, the invoke stays ordinary, and F7's already-proven shape (throw on the engine stack, catch in a live frame) applies unchanged. This looks like the smaller semantic step, and libc++abi already carries a fallback_malloc for the allocation path.

Workaround in use

An exceptions-OFF libc++ (LIBCXX_ENABLE_EXCEPTIONS=OFF, LIBCXXABI_ENABLE_EXCEPTIONS=OFF, plus LIBCXXABI_NON_DEMANGLING_TERMINATE=ON to drop witness #2) links and runs correctly on target:

STL-JOIN brintOS wasm32-hwjs libc++
STL-SUM 55 STL-MAX 10
STL-MAP 3 6
STL-OK

(std::string, std::vector, std::map, <algorithm> with a lambda comparator, <numeric> — all values correct.) So libc++ itself is sound on wasm32-hwjs; the blocker is confined to the suspending-noreturn-via-invoke shape. Exceptions remain unavailable, which blocks C++ packages that need them (our immediate driver is apt).

Not sending a patch

No fix branch: the mechanical insertion-point change is easy to write and easy to get silently wrong, and choosing between the two directions above is a design decision on this pass's suspend/EH model rather than a bug fix we can verify from outside. Happy to build and boot-test any candidate on the C/A/D witnesses above.

Aside (separate, non-blocking)

libunwind/src/config.h selects _LIBUNWIND_EXPORT = __declspec(dllexport) under #if !defined(__ELF__) && !defined(__MACH__) && !defined(_AIX) — i.e. it treats "not ELF" as Windows. No wasm triple defines __ELF__ (checked: wasm32-hwjs-linux-gnu, wasm32-unknown-emscripten, wasm32-unknown-unknown, wasm32-wasi), so a stock cmake libunwind build for wasm always lands on the Windows arm and fails with "'__declspec' attributes are not enabled". Upstream never sees it because Emscripten builds libunwind through its own build system. LIBUNWIND_HIDE_SYMBOLS=ON is the correct knob for a static build and sidesteps it — noted only in case it bites someone else.