Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,11 @@ jobs:

iwasm --stack-size=819200000 --heap-size=800000000 hello.wasm

- name: Set up wasm-tools for EH fixtures
uses: bytecodealliance/actions/wasm-tools/setup@v1
with:
version: "1.243.0"

- name: Test WASI pthread backend and threaded GC (WAMR)
shell: bash
run: python3 dev/test_wasm_wasi_threads.py
Expand Down
11 changes: 10 additions & 1 deletion dev/build_iwasm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ IWASM_BUILD_ID=$(
printf '%s\n' "${WAMR_VERSION}" "$(uname -s)" "$(uname -m)" \
"${LLGO_WINDOWS_ABI:-}" "${MINGW_PREFIX:-}" "${CC:-}" "${CXX:-}" \
"$(git hash-object "${SCRIPT_DIR}/build_iwasm.sh")" \
"$(git hash-object "${SCRIPT_DIR}/patches/wamr-2.4.5-threaded-eh.patch")" \
"$(git hash-object "${SCRIPT_DIR}/patches/wamr-2.4.5-posix-signal.patch")" \
"$(git hash-object "${SCRIPT_DIR}/patches/wamr-2.4.5-mingw.patch")" \
| git hash-object --stdin
)
Expand Down Expand Up @@ -55,6 +57,13 @@ cd "${TEMP_DIR}"
echo "Cloning wasm-micro-runtime ${WAMR_VERSION}..."
git clone --branch "${WAMR_VERSION}" --depth 1 https://github.com/wasm-micro-runtime/wasm-micro-runtime.git

# Keep exception propagation within one interpreter thread until it escapes
# the Wasm invocation. Otherwise a caught longjmp can terminate sibling Gs.
git -C wasm-micro-runtime apply \
"${SCRIPT_DIR}/patches/wamr-2.4.5-threaded-eh.patch"
git -C wasm-micro-runtime apply \
"${SCRIPT_DIR}/patches/wamr-2.4.5-posix-signal.patch"

CMAKE_GENERATOR_ARGS=()
case "$(uname -s)" in
Darwin)
Expand Down Expand Up @@ -145,7 +154,7 @@ cmake "${CMAKE_GENERATOR_ARGS[@]}" \
-D WAMR_BUILD_SHARED_MEMORY=1 \
-D WAMR_BUILD_LIB_WASI_THREADS=1 \
-D WAMR_BUILD_LIB_PTHREAD=1 \
-D CMAKE_BUILD_TYPE=Debug \
-D CMAKE_BUILD_TYPE=Release \
-D WAMR_BUILD_DEBUG_INTERP=0 \
..

Expand Down
72 changes: 72 additions & 0 deletions dev/patches/wamr-2.4.5-posix-signal.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
Backport of https://github.com/wasm-micro-runtime/wasm-micro-runtime/pull/5119
Install process-wide signal handlers once while retaining per-thread stacks.

diff --git a/core/shared/platform/common/posix/posix_thread.c b/core/shared/platform/common/posix/posix_thread.c
index 80b7d654..f9350a21 100644
--- a/core/shared/platform/common/posix/posix_thread.c
+++ b/core/shared/platform/common/posix/posix_thread.c
@@ -596,6 +596,8 @@ mask_signals(int how)

static struct sigaction prev_sig_act_SIGSEGV;
static struct sigaction prev_sig_act_SIGBUS;
+static pthread_once_t signal_handler_once = PTHREAD_ONCE_INIT;
+static int signal_handler_install_result = -1;

/*
* ASAN is not designed to work with custom stack unwind or other low-level
@@ -654,10 +656,30 @@ signal_callback(int sig_num, siginfo_t *sig_info, void *sig_ucontext)
}
}

+static void
+install_signal_handler_once(void)
+{
+ struct sigaction sig_act;
+
+ memset(&sig_act, 0, sizeof(sig_act));
+ sig_act.sa_sigaction = signal_callback;
+ sig_act.sa_flags = SA_SIGINFO | SA_NODEFER;
+#if WASM_DISABLE_STACK_HW_BOUND_CHECK == 0
+ sig_act.sa_flags |= SA_ONSTACK;
+#endif
+ sigemptyset(&sig_act.sa_mask);
+ if (sigaction(SIGSEGV, &sig_act, &prev_sig_act_SIGSEGV) != 0)
+ return;
+ if (sigaction(SIGBUS, &sig_act, &prev_sig_act_SIGBUS) != 0) {
+ sigaction(SIGSEGV, &prev_sig_act_SIGSEGV, NULL);
+ return;
+ }
+ signal_handler_install_result = 0;
+}
+
int
os_thread_signal_init(os_signal_handler handler)
{
- struct sigaction sig_act;
#if WASM_DISABLE_STACK_HW_BOUND_CHECK == 0
stack_t sigalt_stack_info;
uint32 map_size = SIG_ALT_STACK_SIZE;
@@ -693,18 +715,11 @@ os_thread_signal_init(os_signal_handler handler)
}
#endif

- memset(&prev_sig_act_SIGSEGV, 0, sizeof(struct sigaction));
- memset(&prev_sig_act_SIGBUS, 0, sizeof(struct sigaction));
-
- /* Install signal handler */
- sig_act.sa_sigaction = signal_callback;
- sig_act.sa_flags = SA_SIGINFO | SA_NODEFER;
-#if WASM_DISABLE_STACK_HW_BOUND_CHECK == 0
- sig_act.sa_flags |= SA_ONSTACK;
-#endif
- sigemptyset(&sig_act.sa_mask);
- if (sigaction(SIGSEGV, &sig_act, &prev_sig_act_SIGSEGV) != 0
- || sigaction(SIGBUS, &sig_act, &prev_sig_act_SIGBUS) != 0) {
+ /* sigaction is process-wide, although the alternate stack and handler
+ * context above are per-thread. Reinstalling here would make the previous
+ * action point back to signal_callback after the first worker starts. */
+ if (pthread_once(&signal_handler_once, install_signal_handler_once) != 0
+ || signal_handler_install_result != 0) {
os_printf("Failed to register signal handler\n");
goto fail3;
}
28 changes: 28 additions & 0 deletions dev/patches/wamr-2.4.5-threaded-eh.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
WAMR 2.4.5: do not broadcast catchable intra-Wasm exceptions to other threads.
LLGo regression: internal/build/testdata/wasm-wasi-goexit-defer.
The terminal uncaught-exception path still publishes a cluster-wide trap.

diff --git a/core/iwasm/interpreter/wasm_interp_classic.c b/core/iwasm/interpreter/wasm_interp_classic.c
index 9f7e03e2..61fccd76 100644
--- a/core/iwasm/interpreter/wasm_interp_classic.c
+++ b/core/iwasm/interpreter/wasm_interp_classic.c
@@ -1933,6 +1933,19 @@ wasm_interp_call_func_bytecode(WASMModuleInstance *module,
*((int32 *)(prev_frame->sp)) = exception_tag_index;
prev_frame->sp++;

+ /* Keep a catchable exception inside this Wasm
+ * invocation. Publishing it through
+ * wasm_set_exception would cancel every thread in
+ * the cluster before the caller can catch it. */
+ if (prev_frame->ip) {
+ FREE_FRAME(exec_env, frame);
+ wasm_exec_env_set_cur_frame(exec_env,
+ prev_frame);
+ RECOVER_CONTEXT(prev_frame);
+ exception_tag_index = POP_I32();
+ goto find_a_catch_handler;
+ }
+
/* mark frame as raised exception */
wasm_set_exception(module,
"uncaught wasm exception");
87 changes: 72 additions & 15 deletions dev/test_wasm_wasi_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shutil
import subprocess
import tempfile
import time


ROOT = pathlib.Path(__file__).resolve().parent.parent
Expand All @@ -15,7 +16,7 @@


def run_probe(env, directory, name, fixture, tags, marker, timeout,
max_threads=8, expected_exit=0):
max_threads=8, expected_exit=0, args=(), runs=1):
module = pathlib.Path(directory) / f"{name}.wasm"
command = [LLGO, "build", "-target", "wasi"]
if tags:
Expand All @@ -27,21 +28,23 @@ def run_probe(env, directory, name, fixture, tags, marker, timeout,
env=env,
timeout=180,
)
result = subprocess.run(
[IWASM, f"--max-threads={max_threads}", "--stack-size=1048576",
"--heap-size=0", "--dir=" + str(ROOT), "--dir=/tmp", str(module)],
capture_output=True,
text=True,
timeout=timeout,
)
print(result.stdout, end="")
print(result.stderr, end="")
allowed_exits = (expected_exit,) if isinstance(expected_exit, int) else expected_exit
if result.returncode not in allowed_exits or (
marker not in result.stdout.splitlines()
and marker not in result.stderr.splitlines()
):
raise SystemExit(f"WAMR {name} probe failed with exit code {result.returncode}")
for attempt in range(runs):
result = subprocess.run(
[IWASM, f"--max-threads={max_threads}", "--stack-size=1048576",
"--heap-size=0", "--dir=" + str(ROOT), "--dir=/tmp", str(module), *args],
capture_output=True,
text=True,
timeout=timeout,
)
print(result.stdout, end="")
print(result.stderr, end="")
markers = (marker,) if isinstance(marker, str) else marker
lines = result.stdout.splitlines() + result.stderr.splitlines()
if result.returncode not in allowed_exits or any(m not in lines for m in markers):
raise SystemExit(
f"WAMR {name} probe {attempt + 1}/{runs} failed "
f"with exit code {result.returncode}")


def run_llgo(env, args, marker, timeout=180):
Expand Down Expand Up @@ -94,6 +97,33 @@ def main():
# WAMR can translate the terminal Wasm exception to process status 1
# instead of preserving the guest's status 2. Both are nonzero exits.
deadlock_exits = (1, 2)
# LLVM lowers both Go defer/Goexit and C setjmp/longjmp through legacy
# Wasm EH. A caught exception must not terminate unrelated pthreads.
for tags in ("nogc", ""):
suffix = tags or "gc"
run_probe(env, directory, f"deferred-goexit-{suffix}",
"wasm-wasi-goexit-defer", tags,
"wasi worker defer ok", 30, runs=10)
for mode in ("init", "main"):
run_probe(env, directory, f"{mode}-defer-{suffix}",
"wasm-wasi-goexit-defer", tags,
(f"wasi {mode} defer ok",
"fatal error: no goroutines (main called runtime.Goexit) - deadlock!"),
30, expected_exit=deadlock_exits, args=(mode,))
run_probe(env, directory, f"uncaught-{suffix}",
"wasm-wasi-goexit-defer", tags,
"panic: wasi uncaught sentinel", 30,
expected_exit=deadlock_exits, args=("uncaught",))
# A raw exception escaping the Wasm entry point must still fail. Go's
# unrecovered panic exits explicitly, so it cannot test this boundary.
uncaught = pathlib.Path(directory) / "uncaught-eh.wasm"
subprocess.run([os.environ.get("WASM_TOOLS", "wasm-tools"), "parse",
str(ROOT / "internal/build/testdata/wasm-wasi-goexit-defer/uncaught.wat"),
"-o", str(uncaught)], check=True, timeout=30)
result = subprocess.run([IWASM, str(uncaught)], capture_output=True,
text=True, timeout=30)
if result.returncode == 0 or "uncaught wasm exception" not in result.stdout + result.stderr:
raise SystemExit(f"WAMR swallowed an escaping exception: {result}")
run_probe(env, directory, "main-goexit", "wasm-wasi-main-goexit", "nogc",
"fatal error: no goroutines (main called runtime.Goexit) - deadlock!",
30, expected_exit=deadlock_exits)
Expand Down Expand Up @@ -122,6 +152,33 @@ def main():
"wasi threaded filesystem ok")
run_llgo(env, ["test", "-target", "wasi", "-emulator",
str(ROOT / "test/std/errors")], "PASS")
# Compiling test/go on a cold CI runner and running the GC race are
# separate budgets. Verbose, inherited output identifies a slow test
# immediately instead of discarding it when subprocess.run times out.
module = pathlib.Path(directory) / "runtime-gc-tests.wasm"
started = time.monotonic()
subprocess.run([LLGO, "test", "-c", "-target", "wasi", "-o", str(module),
str(ROOT / "test/go")], env=env, check=True, timeout=300)
print(f"WAMR GC test compilation: {time.monotonic() - started:.2f}s", flush=True)
# The two startup shapes each race 20 goroutines against continuous
# full GC. The classic interpreter takes over two minutes per shape
# on CI. Give each an independent runtime/deadline, and keep finalizer,
# callback GC and first-use symbol lookup in a third invocation. All
# cases and repetition counts remain enabled; a stalled case still
# fails within 300 seconds with its last active test visible.
runtime_cases = (
("finalizers/callback/symbols",
"^(TestRuntimeSetFinalizer.*|TestReflectMakeFuncGoroutineGC|TestRuntimeFuncInfoConcurrentFirstUse)$"),
("startup-pointer", "^TestReflectMakeFuncGoroutineStartup$/^pointer_argument$"),
("startup-zero", "^TestReflectMakeFuncGoroutineStartup$/^zero_arguments$"),
)
for name, pattern in runtime_cases:
started = time.monotonic()
subprocess.run([IWASM, "--max-threads=128", "--stack-size=1048576", "--heap-size=0",
"--dir=" + str(ROOT), "--dir=/tmp", str(module), "-test.v",
"-test.run=" + pattern],
env=env, check=True, timeout=300)
print(f"WAMR GC test {name}: {time.monotonic() - started:.2f}s", flush=True)
run_llgo(env, ["test", "-target", "wasi", "-emulator", "-run",
"^TestPoolAfterGC$", str(ROOT / "test/std/sync")], "PASS",
timeout=300)
Expand Down
30 changes: 30 additions & 0 deletions dev/wasm-eh-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,33 @@ provided panic/recover, Asyncify suspension, Go/C/JS callbacks, final DWARF,
and the chosen runtime all pass together. [WAMR's documented EH support](https://github.com/bytecodealliance/wasm-micro-runtime/blob/main/doc/build_wamr.md)
is currently limited to legacy EH in its classic interpreter, so the browser
comparison alone does not change the W32/WAMR execution contract.

## Threaded WAMR regression

`python3 dev/test_wasm_wasi_threads.py` also tests the supported W32 legacy EH
path with the WAMR built by `dev/build_iwasm.sh`. It repeats cross-function
panic/recover, C `setjmp`/`longjmp`, and deferred worker `Goexit` with GC on and
off. Main/init `Goexit` must execute the defer and report deadlock; an
unrecovered worker panic and a raw Wasm exception escaping `_start` must fail.

WAMR 2.4.5 previously called `wasm_set_exception` while transferring a caught
exception to its Wasm caller. With threads enabled, that publishes a
cluster-wide termination signal before the caller can catch the exception.
Sibling threads can then exit early or leave a channel waiter hung. The local
interpreter patch unwinds directly to a Wasm caller and preserves the terminal
exception path when the exception escapes to the native invocation boundary.
The POSIX signal-handler backport from WAMR #5119 is applied separately.

On macOS arm64, the same deferred-Goexit artifact passed 40/50 runs with stock
WAMR 2.4.5 (six hangs and four premature successful exits), 44/50 with the
signal-handler fix alone on WAMR main, and 100/100 with both fixes on 2.4.5.
The threaded acceptance suite and the expanded GC/nogc EH probes passed with
both fixes. These finite runs establish regression coverage, not a guarantee
that every WAMR threading issue is resolved.

The browser comparison was rerun on 2026-09-29 with the pinned LLGo Binaryen
`llgo-v132.3`: all six C++ encoding/optimization variants passed in Node and
Chrome, as did the Go baseline and the Go/C++ catch-status wrappers at O0/O2.
This keeps the supported boundary above: browser encoding is unchanged,
and WAMR uses legacy EH. Whole-module exnref across Go/Asyncify remains outside
the supported contract.
75 changes: 75 additions & 0 deletions dev/wasm-wasi-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# WAMR threads and exception validation

The W32 backend keeps one goroutine per pthread. The following checks qualify
that backend; they do not introduce an M:N scheduler on native or WASI targets.

## Fixed failure modes

- WAMR 2.4.5's classic interpreter briefly broadcast thread termination while
propagating a catchable exception to a Wasm caller. The interpreter patch
keeps that propagation local. The original deferred-Goexit artifact passed
40/50 runs on stock WAMR and 100/100 with the patch. Escaping exceptions still
fail the invocation. Encoding comparisons and the supported Go/C++ boundary
are recorded in [wasm-eh-comparison.md](wasm-eh-comparison.md).
- A pthread condition wait can block while reacquiring a mutex held by a Go
thread already stopped for collection. Merely polling the condition every
20 ms did not resolve that cycle: finalizer tests repeatedly skipped GC and
exceeded their three-second deadlines. Runtime mutex/condition waits now
publish the suspended Go caller's roots before entering C and prevent a
return to Go until collection finishes. This includes the timer scheduler.
Blocked callers no longer wake every 20 ms: normal conditions wait for a
signal, and timers wait for their actual deadline. Arbitrary user C calls
retain the bounded-wait/skip-collection behavior.
- The initial thread parked after Goexit must unregister its roots, since it
will never execute another Go safepoint. A worker now verifies that GC still
advances after initial Goexit with the timer service active.
- Repeated explicit GC must allow waiting allocations and resumed threads to
progress. The allocator uses the same GC-safe pthread mutex instead of
spinning through repeated host calls, and the next collection waits for the
previous rendezvous to finish. Compiler polls and runtime symbol-table
initialization waiters acknowledge collection requests. Arbitrary user C
calls can still prevent collection. The 32-thread/1000-round concurrent
function-info test timed out at 100 seconds with the spin lock; the blocking
allocator completed it in 10.72 seconds (nogc comparison: 7.26 seconds) on
macOS arm64. These are local regression timings, not cross-host benchmarks.
- Small programs begin with a 1 MiB GC arena; subsequent arenas double up to
32 MiB. This avoids sweeping a nearly empty 32 MiB arena on every explicit
collection. WAMR is built in Release mode, with the same classic interpreter,
exception and thread features used by the acceptance tests.
- Reflection's whole-program type-name lookup compares encoded names without
allocating a temporary string for each extra-star type. This preserves the
existing type identity and name rules while reducing GC contention.

Safepoint checks use an atomic epoch read before acquiring the rendezvous
mutex. Heap scans carry the known segment through block-state operations;
address and block lookup use binary search, including out-of-order libc arenas.
The host metadata regression covers all 128 segment slots, gaps, sentinels,
metadata exclusion and preservation of marked objects during sweep.

The focused `test/go` regression compiles once with a 300-second deadline.
It then runs three groups in fresh WAMR invocations: finalizers/callback GC/
function-info, the 20 pointer-argument startup races, and the 20 zero-argument
startup races. Each invocation has a 300-second deadline and verbose output.
On Linux CI the startup groups took 134 and 145 seconds in one combined run,
while another run took 160 seconds for the pointer group and exceeded the
combined deadline in the zero-argument group. Separate deadlines retain every
case and repetition, distinguish slow interpreter work from a stalled test,
and keep first-use checks independent of earlier test initialization.

## Reproducible checks

```sh
bash dev/build_iwasm.sh
go test ./internal/build -run '^TestWASIGCWaitsSuspendGoAcrossMutexReacquisition$'
LLGO="$PWD/.bin/llgo" IWASM=/path/to/patched/iwasm python3 dev/test_wasm_wasi_threads.py
LLGO_WASI_THREADS=1 go run ./dev/wasmstdlib -full -profile W32-WASI \
-shard 9 -shards 16 -llgo "$PWD/.bin/llgo" -report w32-shard9.json
```

The pthread regression exercises both mutex waits and condition-variable
reacquisition, including a waiter woken while collection is still active.
The WAMR acceptance driver covers GC/nogc exception isolation, main/init/worker
Goexit, finalizers, reflection with concurrent GC, retained roots, heap growth,
uncooperative C, timers, filesystems, selected standard-library tests and a
GOROOT sentinel. The full package audit remains a separate gate; passing this
focused suite is not a claim of complete standard-library compatibility.
Loading
Loading