Skip to content

Commit 6230791

Browse files
committed
test: audit native unwinding and document missing guest traces
1 parent 2ffa27f commit 6230791

14 files changed

Lines changed: 676 additions & 1 deletion

File tree

‎.github/workflows/shared_continuations.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,14 @@ jobs:
6868
toolchain: nightly
6969
components: miri
7070
- run: cargo miri test --locked -p tinywasm-c-api --features external-host-calls --lib
71+
72+
native-unwind:
73+
name: Native unwind (GDB, C/C++/Rust)
74+
runs-on: ubuntu-24.04
75+
env:
76+
WASM_EXT_TEST_ONLY: "1"
77+
steps:
78+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
79+
- run: sudo apt-get update && sudo apt-get install -y gdb
80+
- run: python3 crates/c-api/tests/continuations/run.py --runtime tinywasm --root . --build build/native-unwind
81+
- run: python3 crates/c-api/tests/continuations/debug/run.py --runtime tinywasm --include crates/c-api/include --library build/native-unwind/runtime/release/libtinywasm.a --build build/native-unwind/client --debugger gdb

‎crates/c-api/tests/continuations/FEATURE_COMPAT.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,6 @@ fairness budget. AOT stack suspension, general reference-valued C calls, shared
105105
memory, arbitrary component graphs and watchOS deployment need separate work.
106106
All schedulers, component bindings, transport and fixtures remain consumer code
107107
intended for later out-of-tree extraction.
108+
109+
See the [mixed-stack audit](debug/README.md) for native debugger/crash-report
110+
coverage and the remaining guest-frame and async-ancestry requirements.

‎crates/c-api/tests/continuations/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,6 @@ to match the runtime. WAMR fast-interpreter SIMD is unavailable on Windows.
9191
Regenerate the portable fixtures with `wasm-tools parse gc.wat -o gc.wasm`
9292
and `wasm-tools parse simd.wat -o simd.wasm`.
9393
See [FEATURE_COMPAT.md](FEATURE_COMPAT.md) for results and remaining boundaries.
94+
95+
See the [mixed-stack audit](debug/README.md) for native debugger/crash-report
96+
coverage and the remaining guest-frame and async-ancestry requirements.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Mixed native and guest stack audit
2+
3+
Full mixed guest/native crash reporting is a readiness requirement for this RFC,
4+
not a capability established by the continuation and HTTP tests.
5+
6+
The fixture calls Wasm `root -> middle -> leaf -> example:native.call`, then C,
7+
C++, Rust and optionally Swift. It stops inside the native provider, parks the
8+
continuation, and traps after resumption. An early-trap case checks failure
9+
before the first yield. The ordinary-call mode also accepts a WAMR AOT module.
10+
Provider functions use `noinline` so this test can assert native ABI unwind order;
11+
this is not a requirement to disable inlining or tail dispatch in the runtime.
12+
13+
## Observed behavior
14+
15+
On macOS arm64, optimized builds recover the native C/C++/Rust/Swift chain in
16+
LLDB and actual Apple `.ips` reports. Matching dSYM UUIDs are checked. After
17+
yield, the native backtrace contains the scheduler/driver: the guest continuation
18+
has no native stack. A physical backtrace must not invent the old frames.
19+
20+
WAMR previously returned an empty C trap trace for continuation failures even
21+
with `WAMR_BUILD_DUMP_CALL_STACK=1`. The initial error path discarded the trace;
22+
the resumed error path also released frames before capturing them. Both now
23+
reuse the ordinary trap mechanism. Tests require all three guest function
24+
indices for initial, resumed and ordinary failures. This requires the existing
25+
opt-in call-stack feature; the shared C header has not changed.
26+
27+
TinyWasm still returns an empty `wasm_trap_trace`. Its optimized C adapter invokes
28+
external callbacks after returning from the interpreter, so the native backtrace
29+
does not contain an active interpreter frame either. The fixture records this
30+
limitation, not a successful guest-stack qualification.
31+
32+
WAMR ordinary AOT calls preserve native unwinding and the opt-in logical trap
33+
trace, but the tested default AOT build shows an unnamed generated-code address
34+
in LLDB. It does not provide named guest source frames. The separate experimental
35+
`WAMR_BUILD_DEBUG_AOT` compiler configuration requires LLDB development libraries,
36+
which were unavailable in the local LLVM 21 installation. Its DWARF registration
37+
path remains unqualified by this audit.
38+
39+
The fast interpreter's reported offsets are internal compiled-bytecode offsets;
40+
module offsets may be zero. They must not be presented to a Wasm DWARF symbolizer
41+
as original Wasm instruction offsets. Function identity is useful; full source
42+
locations require a retained mapping to the exact original module.
43+
44+
The [recorded observations](observed.json) include native backtraces, minimal
45+
Apple crash receipts, source hashes and the unchanged disabled artifacts.
46+
47+
## Required out-of-tree diagnostic contract
48+
49+
1. Associate each loaded module with its content hash, component/instance identity,
50+
original Wasm/debug sidecar, and native image UUID or build ID. Preserve the
51+
component linker's mapping from lowered core functions to original modules.
52+
2. Provide a bounded, versioned, read-only guest-frame snapshot at host-call and
53+
suspension boundaries, with explicit offset coordinate systems and truncation.
54+
TinyWasm needs a public capture seam; its current `guest-debug` by-index APIs
55+
are insufficient. WAMR's allocating trap-trace API is not a fatal-signal API.
56+
3. Keep async ancestry in the external scheduler: task/call generation, parent,
57+
import identity, and active/pending/completed/cancelled state. Distinguish
58+
concurrent streams and reused slots. Show an explicit async boundary rather
59+
than treating a resumed callback as a continuous native stack.
60+
4. Capture no borrowed guest/native pointers across a yield. Publish immutable
61+
or coherently versioned records for debugger/crash readers; invalidate them on
62+
cancellation and teardown. Do not allocate, acquire runtime locks, invoke
63+
callbacks, format strings or re-enter the engine from a fatal-signal handler.
64+
5. Preserve native unwind metadata and archive matching dSYMs/ELF debug files.
65+
Keep inline/tail-call semantics honest. C++ exceptions, Rust panics and Swift
66+
errors must be caught/translated before crossing the C ABI; stack inspection
67+
does not authorize exception propagation through it.
68+
6. Compile diagnostic state and capture work out of embedded builds. Use existing
69+
runtime frames rather than a second stack updated on every instruction. Bound
70+
enabled memory and measure capture cost at native-call boundaries, including
71+
high-frequency GPU/audio imports. Disabling diagnostics must retain the LTO
72+
binary/RSS/native-call baseline; don't sacrifice tail dispatch for prettier
73+
native traces.
74+
75+
Native Apple reports alone do not automatically include interpreter/async
76+
records. An out-of-tree reporting integration must attach or reconstruct them.
77+
Apple's [CrashReportExtension](https://developer.apple.com/documentation/crashreportextension)
78+
can inspect a crashed process out of process on its supported platforms; its
79+
documented availability is iOS/iPadOS/macOS 27, not watchOS. It must not become a
80+
runtime dependency or the only diagnostic path. GDB/LLDB readers and an external
81+
crash-context attachment remain necessary for other targets.
82+
83+
These requirements are intentionally not a new public diagnostics ABI in this
84+
patch. Named guest frames, source mappings and async ancestry remain gates before
85+
claiming a complete `Wasm -> component/module -> native provider` crash stack.
86+
87+
## Reproduce
88+
89+
Build the shared runtime fixture with `run.py --runtime wamr --root /path/to/wamr
90+
--build /tmp/runtime --call-stacks`, or the equivalent TinyWasm command without
91+
`--call-stacks`. Use one compiler family for native LTO objects and the final
92+
linker. For WAMR source-level runtime debugging, additionally build its archive
93+
with `RelWithDebInfo` and `-fno-omit-frame-pointer`. The default build flags stay
94+
unchanged.
95+
96+
Run this directory's `run.py` with `--runtime`, `--include`, `--library`,
97+
`--build` and `--debugger lldb` or `--debugger gdb`. WAMR's include directory must
98+
contain its C API header under the shared name `wasm.h`, as the parent runner
99+
arranges. Add `--swift` on macOS. Without it, a C bridge connects Rust to the
100+
checkpoint; this is the Linux GDB CI configuration.
101+
102+
`--apple-crash` deliberately traps the standalone fixture, checks a new Apple
103+
`.ips` report for the native frame order and matching dSYM UUID, and symbolicates
104+
its image addresses with `atos`. Run it only when intentionally producing a test
105+
crash. The script records a minimal receipt, not the report's machine identifiers.
106+
`--ordinary --guest /path/to/guest.aot` checks ordinary AOT calls; compile
107+
`guest.wasm` with `--enable-dump-call-stack` and matching GC/bounds-check settings.
108+
109+
The runner writes `debugger.txt`, `guest_trace.txt`, and `receipt.json`; Apple
110+
crash tests also write `apple_symbolicated.txt`. It explicitly records
111+
`full_mixed_guest_stack_qualified: false`, even when native unwinding passes.
112+
113+
References: [Apple symbolication](https://developer.apple.com/documentation/xcode/adding-identifiable-symbol-names-to-a-crash-report),
114+
[GDB JIT interface](https://sourceware.org/gdb/current/onlinedocs/gdb.html/JIT-Interface.html),
115+
[LLVM debugging generated code](https://llvm.org/docs/DebuggingJITedCode.html).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* Copyright (C) 2026 Matt Hargett. All rights reserved.
2+
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception */
3+
extern int
4+
native_checkpoint(int);
5+
__attribute__((noinline)) int
6+
native_bridge(int value)
7+
{
8+
return native_checkpoint(value) + 1;
9+
}
152 Bytes
Binary file not shown.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
;; Copyright (C) 2026 Matt Hargett. All rights reserved.
2+
;; SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
3+
(module $stack_audit
4+
(import "example:native" "call" (func $native_call (param i32) (result i32)))
5+
(func $leaf (param i32) (result i32)
6+
local.get 0 i32.eqz if unreachable end
7+
local.get 0 call $native_call drop unreachable)
8+
(func $middle (param i32) (result i32) local.get 0 call $leaf i32.const 1 i32.add)
9+
(func $root (export "run") (param i32) (result i32) local.get 0 call $middle i32.const 1 i32.add))
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* Copyright (C) 2026 Matt Hargett. All rights reserved.
2+
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception */
3+
#include "wasm.h"
4+
#include "wasm_ext.h"
5+
#include <stdio.h>
6+
#include <stdlib.h>
7+
#include <string.h>
8+
#ifdef _MSC_VER
9+
#include <intrin.h>
10+
#define NOINLINE __declspec(noinline)
11+
#define CRASH() __debugbreak()
12+
#else
13+
#define NOINLINE __attribute__((noinline))
14+
#define CRASH() __builtin_trap()
15+
#endif
16+
#define CHECK(x) \
17+
do { \
18+
if (!(x)) { \
19+
fprintf(stderr, "failed: %s\n", #x); \
20+
exit(1); \
21+
} \
22+
} while (0)
23+
#ifdef STACK_AUDIT_NATIVE
24+
extern int
25+
native_cpp(int value);
26+
#else
27+
static int
28+
native_cpp(int value)
29+
{
30+
return value + 4;
31+
}
32+
#endif
33+
static int crash_enabled;
34+
NOINLINE int
35+
native_checkpoint(int value)
36+
{
37+
if (crash_enabled)
38+
CRASH();
39+
return value + 1;
40+
}
41+
NOINLINE void
42+
parked_checkpoint(void)
43+
{
44+
static volatile unsigned visits;
45+
visits++;
46+
}
47+
static NOINLINE wasm_ext_status_t
48+
native_import(void *env, const wasm_val_vec_t *args, wasm_val_vec_t *results,
49+
wasm_trap_t **trap)
50+
{
51+
(void)env;
52+
(void)results;
53+
(void)trap;
54+
CHECK(native_cpp(args->data[0].of.i32) == args->data[0].of.i32 + 4);
55+
return WASM_EXT_PENDING;
56+
}
57+
static NOINLINE wasm_trap_t *
58+
sync_import(void *env, const wasm_val_vec_t *args, wasm_val_vec_t *results)
59+
{
60+
(void)env;
61+
results->data[0].kind = WASM_I32;
62+
results->data[0].of.i32 = native_cpp(args->data[0].of.i32);
63+
return NULL;
64+
}
65+
int
66+
main(int argc, char **argv)
67+
{
68+
CHECK(argc >= 2);
69+
crash_enabled = argc > 2 && !strcmp(argv[2], "crash");
70+
int ordinary = argc > 2 && !strcmp(argv[2], "ordinary");
71+
int early = argc > 2 && !strcmp(argv[2], "early");
72+
wasm_engine_t *engine = wasm_engine_new();
73+
CHECK(engine);
74+
wasm_store_t *store = wasm_store_new(engine);
75+
CHECK(store);
76+
FILE *f = fopen(argv[1], "rb");
77+
CHECK(f);
78+
CHECK(!fseek(f, 0, SEEK_END));
79+
long n = ftell(f);
80+
CHECK(n > 0);
81+
rewind(f);
82+
wasm_byte_vec_t bytes;
83+
wasm_byte_vec_new_uninitialized(&bytes, (size_t)n);
84+
CHECK(fread(bytes.data, 1, bytes.size, f) == bytes.size);
85+
fclose(f);
86+
wasm_module_t *module = wasm_module_new(store, &bytes);
87+
CHECK(module);
88+
wasm_byte_vec_delete(&bytes);
89+
wasm_functype_t *type =
90+
wasm_functype_new_1_1(wasm_valtype_new_i32(), wasm_valtype_new_i32());
91+
wasm_func_t *host =
92+
ordinary ? wasm_func_new_with_env(store, type, sync_import, NULL, NULL)
93+
: wasm_ext_func_new(store, type, native_import, NULL, NULL);
94+
CHECK(host);
95+
wasm_functype_delete(type);
96+
wasm_extern_t *items[] = { wasm_func_as_extern(host) };
97+
wasm_extern_vec_t imports = WASM_ARRAY_VEC(items);
98+
wasm_trap_t *trap = NULL;
99+
wasm_instance_t *instance =
100+
wasm_instance_new(store, module, &imports, &trap);
101+
CHECK(instance && !trap);
102+
wasm_extern_vec_t exports;
103+
wasm_instance_exports(instance, &exports);
104+
wasm_val_t in[] = { WASM_I32_VAL(20) }, out[] = { WASM_I32_VAL(0) };
105+
wasm_val_vec_t args = WASM_ARRAY_VEC(in), results = WASM_ARRAY_VEC(out);
106+
wasm_ext_call_t *call = NULL;
107+
if (ordinary) {
108+
trap = wasm_func_call(wasm_extern_as_func(exports.data[0]), &args,
109+
&results);
110+
CHECK(trap);
111+
}
112+
else {
113+
if (early)
114+
in[0].of.i32 = 0;
115+
call = wasm_ext_call_new(wasm_extern_as_func(exports.data[0]), &args);
116+
CHECK(call);
117+
int status = wasm_ext_call_resume(call, NULL, &results, &trap);
118+
if (!early) {
119+
CHECK(status == WASM_EXT_PENDING);
120+
parked_checkpoint();
121+
status = wasm_ext_call_resume(call, &args, &results, &trap);
122+
}
123+
CHECK(status == WASM_EXT_ERROR && trap);
124+
}
125+
wasm_frame_vec_t frames;
126+
wasm_trap_trace(trap, &frames);
127+
printf("guest_trace_count=%zu\n", frames.size);
128+
#ifdef EXPECT_GUEST_TRACE
129+
CHECK(frames.size == 3);
130+
for (size_t i = 0; i < 3; i++)
131+
CHECK(wasm_frame_func_index(frames.data[i]) == i + 1);
132+
#endif
133+
for (size_t i = 0; i < frames.size; i++)
134+
printf("guest_frame index=%u function_offset=%zu module_offset=%zu\n",
135+
wasm_frame_func_index(frames.data[i]),
136+
wasm_frame_func_offset(frames.data[i]),
137+
wasm_frame_module_offset(frames.data[i]));
138+
wasm_frame_vec_delete(&frames);
139+
wasm_trap_delete(trap);
140+
if (call)
141+
wasm_ext_call_delete(call);
142+
wasm_extern_vec_delete(&exports);
143+
wasm_instance_delete(instance);
144+
wasm_func_delete(host);
145+
wasm_module_delete(module);
146+
wasm_store_delete(store);
147+
wasm_engine_delete(engine);
148+
return 0;
149+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* Copyright (C) 2026 Matt Hargett. All rights reserved.
2+
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception */
3+
extern "C" int
4+
native_rust(int);
5+
extern "C" __attribute__((noinline)) int
6+
native_cpp(int value)
7+
{
8+
volatile int result = native_rust(value);
9+
return result + 1;
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (C) 2026 Matt Hargett. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
3+
unsafe extern "C" {
4+
fn native_bridge(value: i32) -> i32;
5+
}
6+
#[unsafe(no_mangle)]
7+
#[inline(never)]
8+
pub extern "C" fn native_rust(value: i32) -> i32 {
9+
std::hint::black_box(unsafe { native_bridge(value) }) + 1
10+
}

0 commit comments

Comments
 (0)