Resolve wasm traps to unit source

This commit is contained in:
udo
2026-07-18 10:58:08 +00:00
parent eda124b15f
commit 495a8ae5f0
13 changed files with 338 additions and 17 deletions
+7 -1
View File
@@ -46,6 +46,8 @@ UCE also requires two non-vendored dependencies. WASI SDK is load-bearing at run
- `/opt/wasi-sdk/bin/clang++`
- `/opt/wasi-sdk/bin/wasm-ld`
- `/opt/wasi-sdk/bin/llvm-objcopy`
- `/opt/wasi-sdk/bin/llvm-nm`
- `/opt/wasi-sdk/bin/llvm-dwarfdump`
You can use different install locations by setting environment variables before building and in the systemd service environment:
@@ -100,6 +102,8 @@ The expected directories are:
/opt/wasi-sdk/bin/clang++
/opt/wasi-sdk/bin/wasm-ld
/opt/wasi-sdk/bin/llvm-objcopy
/opt/wasi-sdk/bin/llvm-nm
/opt/wasi-sdk/bin/llvm-dwarfdump
```
Install the WASI SDK:
@@ -135,6 +139,8 @@ test -f /opt/wasmtime/lib/libwasmtime.so
/opt/wasi-sdk/bin/clang++ --version
/opt/wasi-sdk/bin/wasm-ld --version
/opt/wasi-sdk/bin/llvm-objcopy --version
/opt/wasi-sdk/bin/llvm-nm --version
/opt/wasi-sdk/bin/llvm-dwarfdump --version
```
If your paths differ, export the variables for manual builds:
@@ -687,7 +693,7 @@ Common compile footguns:
- `WASM_COMPILE_SCRIPT` is unset or points at a removed script such as `scripts/compile`; set it to `scripts/compile_wasm_unit`.
- `scripts/check_unit_wasm.py` is missing or not executable; `scripts/compile_wasm_unit` calls it after linking each unit.
- `WASI_SDK` does not point at the pinned tree with `clang++`, `wasm-ld`, `llvm-objcopy`, and `llvm-nm`; run `scripts/install_wasi_sdk.sh --check-only`.
- `WASI_SDK` does not point at the pinned tree with `clang++`, `wasm-ld`, `llvm-objcopy`, `llvm-nm`, and `llvm-dwarfdump`; run `scripts/install_wasi_sdk.sh --check-only`.
- `WASMTIME_HOME` does not point at a tree with Wasmtime headers and `libwasmtime.so`.
- A previous failed compile left stale `.compile.txt`, `.wasm-check.txt`, or partial `.wasm` files under `BIN_DIRECTORY`.
+4
View File
@@ -31,9 +31,13 @@ UCE expects these executables on each deployment host:
/opt/wasi-sdk/bin/wasm-ld
/opt/wasi-sdk/bin/llvm-objcopy
/opt/wasi-sdk/bin/llvm-nm
/opt/wasi-sdk/bin/llvm-dwarfdump
```
`llvm-nm` is used by `scripts/check_unit_wasm.py`, which is called by `scripts/compile_wasm_unit` after linking each unit.
`llvm-dwarfdump` is used at unit compile time to extract the compact, out-of-band
source map before debug sections are stripped from the runtime artifact. The
map is consulted only on a wasm trap.
## Upgrade policy
+15 -2
View File
@@ -222,8 +222,8 @@ Freshness still stats every distinct source on every entry check. Exact repeated
load paths are deduplicated before canonicalization, while distinct aliases are
resolved independently so symlink retargets remain immediately visible.
When a current serialized module exists, the worker scans wasm section headers
and reads only `dylink.0` and `uce.abi`; it does not fault the multi-megabyte code
and data bodies into every new worker. A missing/stale/invalid serialized module
and reads only `dylink.0`, `uce.abi`, and the tiny `uce.module` identity; it does
not fault the code and data bodies into every new worker. A missing/stale/invalid serialized module
still reads, validates, compiles, and republishes the complete wasm artifact.
The proactive compiler also creates that serialization immediately after source
compilation, keeping first-worker native compilation off the request path.
@@ -257,6 +257,19 @@ intentional unreadable-source regression request as an expected source-read
failure, keeping production log scans focused on real runtime failures while
preserving the failing compile result and diagnostic artifacts.
Unit linking retains DWARF only long enough for
`scripts/build_unit_source_map.py` to extract a compact address/file/line table.
The published `.wasm` is debug-stripped and the table is stored beside it as
`.wasm.source-map`, keyed to the exact temporary module identity recorded in
the wasm's `uce.module` custom section. Normal module loading never reads this
sidecar. On a Wasmtime trap, the worker uses structured frame module offsets to
load only the matching map and appends source locations to the error. A missing,
stale, or malformed map is deliberately non-fatal: the ordinary named wasm
backtrace remains available. Generated C++ uses a `#line` directive naming the
original `.uce` file, so application frames resolve to application source rather
than the generated cache file. Artifact invalidation removes the wasm, serialized
module, and source map together.
---
## 4. The workspace runtime
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Extract a compact address-to-source table before a unit's DWARF is stripped."""
import argparse
import ast
import os
import re
import subprocess
DIRECTORY = re.compile(r'^include_directories\[\s*(\d+)\] = (".*")$')
FILE = re.compile(r'^file_names\[\s*(\d+)\]:$')
ROW = re.compile(r'^0x([0-9a-fA-F]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+')
def quoted(value: str) -> str:
return ast.literal_eval(value)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dwarfdump", required=True)
parser.add_argument("--wasm", required=True)
parser.add_argument("--module", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
text = subprocess.run(
[args.dwarfdump, "--debug-line", args.wasm],
check=True,
stdout=subprocess.PIPE,
text=True,
).stdout
directories: dict[int, str] = {}
files: dict[int, tuple[str, int]] = {}
rows: list[tuple[int, int, int, int]] = []
current_file = 0
for line in text.splitlines():
if match := DIRECTORY.match(line):
directories[int(match.group(1))] = quoted(match.group(2))
continue
if match := FILE.match(line):
current_file = int(match.group(1))
continue
stripped = line.strip()
if current_file and stripped.startswith("name: "):
files[current_file] = (quoted(stripped[6:]), 0)
continue
if current_file and stripped.startswith("dir_index: "):
name, _ = files[current_file]
files[current_file] = (name, int(stripped[11:]))
continue
if match := ROW.match(line):
rows.append(tuple(int(value, 16 if index == 0 else 10) for index, value in enumerate(match.groups())))
paths: dict[int, str] = {}
for file_id, (name, directory_id) in files.items():
directory = directories.get(directory_id, "")
path = name if os.path.isabs(name) else os.path.normpath(os.path.join(directory, name))
if not os.path.isabs(path):
path = os.path.abspath(path)
if "\t" in path or "\n" in path or "\r" in path:
raise ValueError(f"source-map path contains a control character: {path!r}")
paths[file_id] = path
with open(args.output, "w", encoding="utf-8") as output:
output.write(f"UCE_SOURCE_MAP_V1\t{args.module}\n")
for file_id, path in sorted(paths.items()):
output.write(f"F\t{file_id}\t{path}\n")
for address, line, column, file_id in rows:
output.write(f"L\t{address:x}\t{file_id}\t{line}\t{column}\n")
if __name__ == "__main__":
main()
+3
View File
@@ -182,6 +182,9 @@ def main() -> int:
for needle in required:
if needle not in abi_text:
errors.append(f"uce.abi missing {needle!r}")
module_payloads = customs.get("uce.module", [])
if not module_payloads or not module_payloads[-1]:
errors.append("missing uce.module custom section")
export_names = {name for name, _ in exports}
forbidden_exports = {"uce_alloc", "uce_free"}
for name in sorted(export_names & forbidden_exports):
+17 -4
View File
@@ -15,7 +15,9 @@ ABI_VERSION=${UCE_UNIT_ABI_VERSION:-6}
ROOT=$(pwd)
OBJ_FN="$DEST_DIR/$PP_FN.wasm.o"
ABI_TMP="$DEST_DIR/$PP_FN.uce-abi.txt"
MODULE_TMP="$DEST_DIR/$PP_FN.uce-module.txt"
WASM_TMP="$DEST_DIR/$WASM_FN.tmp.$$"
MAP_TMP="$DEST_DIR/$WASM_FN.source-map.tmp.$$"
PCH_ENABLED=${UCE_WASM_UNIT_PCH:-1}
PCH_DIR=${UCE_WASM_PCH_DIR:-/tmp/uce/wasm-w2/pch}
COMMON_FLAGS=(
@@ -33,7 +35,7 @@ COMMON_FLAGS=(
-DPLATFORM_NAME=\"wasm32-wasip1\"
)
if [ ! -x "$SDK/bin/clang++" ] || [ ! -x "$SDK/bin/wasm-ld" ] || [ ! -x "$SDK/bin/llvm-objcopy" ]; then
if [ ! -x "$SDK/bin/clang++" ] || [ ! -x "$SDK/bin/wasm-ld" ] || [ ! -x "$SDK/bin/llvm-objcopy" ] || [ ! -x "$SDK/bin/llvm-dwarfdump" ]; then
echo "wasi-sdk tools not found; set WASI_SDK" >&2
exit 1
fi
@@ -45,7 +47,7 @@ PCH_KEY=$(printf '%s\n%s\n%s\n%s\n' "$ABI_VERSION" "$TOOLCHAIN_ID" "$HEADER_HASH
PCH_FN="$PCH_DIR/uce_lib-wasm-unit-$PCH_KEY.pch"
mkdir -p "$DEST_DIR" >/dev/null 2>&1
trap 'rm -f "$OBJ_FN" "$ABI_TMP" "$WASM_TMP"' EXIT
trap 'rm -f "$OBJ_FN" "$ABI_TMP" "$MODULE_TMP" "$WASM_TMP" "$MAP_TMP"' EXIT
build_pch_if_needed() {
if [ "$PCH_ENABLED" = "0" ]; then
@@ -67,6 +69,7 @@ unit_abi_version=$ABI_VERSION
toolchain=$TOOLCHAIN_ID
source=$SRC_FN
EOF
printf '%s' "$(basename "$WASM_TMP")" > "$MODULE_TMP"
build_pch_if_needed
PCH_FLAGS=()
@@ -93,9 +96,19 @@ fi
--export-if-defined=__uce_once \
--export-if-defined=__uce_init
"$SDK/bin/llvm-objcopy" --strip-debug --add-section=uce.abi="$ABI_TMP" "$WASM_TMP"
python3 scripts/build_unit_source_map.py \
--dwarfdump "$SDK/bin/llvm-dwarfdump" \
--wasm "$WASM_TMP" \
--module "$(basename "$WASM_TMP")" \
--output "$MAP_TMP"
"$SDK/bin/llvm-objcopy" --strip-debug \
--add-section=uce.abi="$ABI_TMP" \
--add-section=uce.module="$MODULE_TMP" \
"$WASM_TMP"
python3 scripts/check_unit_wasm.py "$WASM_TMP" --abi-version "$ABI_VERSION" --llvm-nm "$SDK/bin/llvm-nm"
mv "$MAP_TMP" "$DEST_DIR/$WASM_FN.source-map"
mv "$WASM_TMP" "$DEST_DIR/$WASM_FN"
rm -f "$OBJ_FN" "$ABI_TMP"
rm -f "$OBJ_FN" "$ABI_TMP" "$MODULE_TMP"
+1 -1
View File
@@ -53,7 +53,7 @@ require_command() {
verify_tree() {
local root="$1"
for tool in clang++ wasm-ld llvm-objcopy llvm-nm; do
for tool in clang++ wasm-ld llvm-objcopy llvm-nm llvm-dwarfdump; do
if [[ ! -x "$root/bin/$tool" ]]; then
echo "Missing WASI SDK tool: $root/bin/$tool" >&2
return 1
+1
View File
@@ -77,5 +77,6 @@ if [[ "$action" == "run" ]]; then
scripts/test_raw_http_request_log.sh
scripts/test_component_resolution_ttl.sh
scripts/test_unit_export_surface.sh
scripts/test_wasm_source_locations.sh
scripts/test_socket_activation.sh
fi
+15 -2
View File
@@ -50,7 +50,7 @@ fi
entry_wasm="$artifact_dir/entry.uce.wasm"
named_wasm="$artifact_dir/named.uce.wasm"
python3 - "$entry_wasm" "$named_wasm" <<'PY'
python3 - "$entry_wasm" "$named_wasm" "$absolute_source_dir" <<'PY'
import sys
from pathlib import Path
@@ -60,9 +60,10 @@ cases = [
(Path(sys.argv[1]), {"__wasm_call_ctors", "__uce_set_current_request", "__uce_cli", "visibility_shared"}, b"uce-private-unused-marker-8f61d2"),
(Path(sys.argv[2]), {"__wasm_call_ctors", "__uce_set_current_request", "__uce_component_NAMED", "named_shared"}, b"uce-named-unused-marker-4ae973"),
]
source_dir = Path(sys.argv[3])
for path, allowed, unused_marker in cases:
data = path.read_bytes()
_, imports, exports = collect(path)
customs, imports, exports = collect(path)
names = {name for name, _ in exports}
unexpected = names - allowed
missing = allowed - names
@@ -74,6 +75,18 @@ for path, allowed, unused_marker in cases:
raise SystemExit(f"{path}: retained {len(imports)} imports (expected fewer than 40)")
if path.stat().st_size >= 1024 * 1024:
raise SystemExit(f"{path}: artifact is {path.stat().st_size} bytes (expected under 1 MiB)")
source_map = Path(str(path) + ".source-map")
if not source_map.is_file() or source_map.stat().st_size >= 256 * 1024:
raise SystemExit(f"{path}: missing or oversized source map")
module = customs["uce.module"][-1].decode()
lines = source_map.read_text().splitlines()
if not lines or lines[0] != f"UCE_SOURCE_MAP_V1\t{module}":
raise SystemExit(f"{path}: source map does not match wasm module identity")
expected_source = str(source_dir / path.name.removesuffix(".wasm"))
if not any(line.startswith("F\t") and line.endswith("\t" + expected_source) for line in lines):
raise SystemExit(f"{path}: source map does not identify {expected_source}")
if not any(line.startswith("L\t") for line in lines):
raise SystemExit(f"{path}: source map has no address rows")
PY
echo "unit export surface passed"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
test_name="wasm-source-location-test-$$"
site_directory="${UCE_TEST_SITE_DIRECTORY:-site}"
bin_directory="${UCE_TEST_BIN_DIRECTORY:-/tmp/uce/work}"
if [[ -r /etc/uce/settings.cfg ]]; then
configured_site_directory=$(awk -F= '/^[[:space:]]*HTTP_DOCUMENT_ROOT[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg)
configured_bin_directory=$(awk -F= '/^[[:space:]]*BIN_DIRECTORY[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' /etc/uce/settings.cfg)
[[ -n "${UCE_TEST_SITE_DIRECTORY:-}" ]] || site_directory="${configured_site_directory:-$site_directory}"
[[ -n "${UCE_TEST_BIN_DIRECTORY:-}" ]] || bin_directory="${configured_bin_directory:-$bin_directory}"
fi
source_dir="$site_directory/$test_name"
absolute_source_dir=""
artifact_dir=""
cleanup() {
rm -rf "$source_dir"
[[ -z "$artifact_dir" ]] || rm -rf "$artifact_dir"
}
trap cleanup EXIT
mkdir -p "$source_dir"
absolute_source_dir=$(realpath "$source_dir")
artifact_dir="$bin_directory$absolute_source_dir"
printf '%s\n' 'CLI(Request& context) { __builtin_trap(); }' >"$source_dir/entry.uce"
set +e
output=$(scripts/uce-cli "/$test_name/entry.uce" 2>&1)
status=$?
set -e
if [[ $status -eq 0 ]]; then
echo "trapping wasm unit unexpectedly succeeded" >&2
exit 1
fi
expected="$absolute_source_dir/entry.uce:1:25"
if [[ "$output" != *'wasm `unreachable` instruction executed'* || "$output" != *"source locations:"* || "$output" != *"$expected"* ]]; then
echo "wasm trap omitted its source location:" >&2
echo "$output" >&2
exit 1
fi
rm "$artifact_dir/entry.uce.wasm.source-map"
set +e
without_map=$(scripts/uce-cli "/$test_name/entry.uce" 2>&1)
status=$?
set -e
if [[ $status -eq 0 || "$without_map" != *'wasm `unreachable` instruction executed'* || "$without_map" == *"source locations:"* ]]; then
echo "missing-map trap fallback failed:" >&2
echo "$without_map" >&2
exit 1
fi
echo "wasm source locations passed"
+2 -2
View File
@@ -497,8 +497,8 @@ String compiler_preprocess_shared_unit_char_wise(Request* context, SharedUnit* s
"#include \"uce_lib.h\" \n"+
file_get_contents(
context->server->config["COMPILER_SYS_PATH"] + "/" + context->server->config["SETUP_TEMPLATE"]
)+
"#line 1\n";
)+
"#line 1 " + json_escape(su->file_name) + "\n";
CompilerCodeState code_state;
String current_line = "";
String literal_buffer = "";
+8 -2
View File
@@ -16,7 +16,7 @@
namespace {
const u64 UCE_UNIT_ABI_VERSION = 9;
const u64 UCE_UNIT_ABI_VERSION = 10;
struct SharedUnitFilesystemState
{
@@ -294,10 +294,16 @@ String compiler_cached_wasm_path(String wasm_path)
return(wasm_path + ".cwasm");
}
String compiler_source_map_path(String wasm_path)
{
return(wasm_path + ".source-map");
}
void compiler_unlink_unit_wasm_artifacts(SharedUnit* su)
{
file_unlink(su->wasm_name);
file_unlink(compiler_cached_wasm_path(su->wasm_name));
file_unlink(compiler_source_map_path(su->wasm_name));
}
String compiler_wasm_compile_script(Request* context)
@@ -937,7 +943,7 @@ void compile_shared_unit(Request* context, SharedUnit* su)
file_put_contents(su->compile_output_file_name, su->compiler_messages + "\n");
file_put_contents(su->wasm_check_file_name, su->compiler_messages + "\n");
file_put_contents(su->meta_file_name, compiler_unit_metadata_text(context, su));
file_unlink(su->wasm_name);
compiler_unlink_unit_wasm_artifacts(su);
compiler_record_compile_result(su, time_precise() - comp_start, false, "compile_error", su->compiler_messages);
printf("%s \n", compiler_format_source_read_failure(context, su, su->compiler_messages).c_str());
compiler_mark_source_generation(context);
+136 -3
View File
@@ -69,9 +69,24 @@ struct WasmAbiInfo
{
u32 version = 0;
String toolchain;
String module_name;
bool found = false;
};
struct WasmSourceMap
{
struct Row
{
u64 address = 0;
u32 file = 0;
u32 line = 0;
u32 column = 0;
};
String module_name;
std::map<u32, String> files;
std::vector<Row> rows;
};
enum class WasmUnitImportKind
{
Memory,
@@ -725,6 +740,8 @@ static bool wasm_parse_sections(const std::vector<u8>& bytes, WasmDylinkInfo& dy
line_start = line_end + 1;
}
}
else if(name == "uce.module")
abi.module_name.assign((const char*)bytes.data() + cursor, end - cursor);
}
pos = end;
}
@@ -748,6 +765,53 @@ static bool wasm_read_file(const String& path, std::vector<u8>& out)
return((bool)in);
}
static bool wasm_source_map_load(const String& path, WasmSourceMap& map)
{
std::ifstream input(path);
if(!input)
return(false);
String line;
if(!std::getline(input, line) || line.rfind("UCE_SOURCE_MAP_V1\t", 0) != 0)
return(false);
map.module_name = line.substr(18);
while(std::getline(input, line))
{
auto fields = split(line, "\t");
if(fields.size() == 3 && fields[0] == "F")
map.files[(u32)strtoul(fields[1].c_str(), 0, 10)] = fields[2];
else if(fields.size() == 5 && fields[0] == "L")
{
WasmSourceMap::Row row;
row.address = strtoull(fields[1].c_str(), 0, 16);
row.file = (u32)strtoul(fields[2].c_str(), 0, 10);
row.line = (u32)strtoul(fields[3].c_str(), 0, 10);
row.column = (u32)strtoul(fields[4].c_str(), 0, 10);
map.rows.push_back(row);
}
}
return(!map.rows.empty());
}
static String wasm_source_map_lookup(const WasmSourceMap& map, u64 address)
{
const WasmSourceMap::Row* found = 0;
for(auto& row : map.rows)
{
if(row.address > address)
break;
found = &row;
}
if(!found || found->line == 0)
return("");
auto file = map.files.find(found->file);
if(file == map.files.end())
return("");
String result = file->second + ":" + std::to_string(found->line);
if(found->column)
result += ":" + std::to_string(found->column);
return(result);
}
static bool wasm_pread_all(int fd, u64 offset, u8* out, size_t size, u64& bytes_read)
{
size_t done = 0;
@@ -858,7 +922,7 @@ static bool wasm_read_metadata_file(const String& path, std::vector<u8>& metadat
break;
}
}
if(name == "dylink.0" || name == "uce.abi")
if(name == "dylink.0" || name == "uce.abi" || name == "uce.module")
{
if(section_size > 1024 * 1024)
{
@@ -1771,9 +1835,78 @@ private:
return(pos == String::npos ? String("") : path.substr(0, pos));
}
static String trap_text(const wasmtime::TrapError& error)
String trap_text(const wasmtime::TrapError& error)
{
return(wasm_trace_collapse(String(error.message())));
String result = wasm_trace_collapse(String(error.message()));
struct Frame
{
String module;
String function;
u64 offset = 0;
};
std::vector<Frame> frames;
auto collect = [&](const wasmtime::Trace& trace) {
if(trace.size() == 0)
return;
for(auto& frame : trace)
{
Frame item;
if(auto name = frame.module_name())
item.module = String(*name);
if(auto name = frame.func_name())
item.function = wasm_trace_demangle(String(*name));
item.offset = frame.module_offset();
frames.push_back(item);
}
};
if(auto* trap = std::get_if<wasmtime::Trap>(&error.data))
{
auto trace = trap->trace();
collect(trace);
}
else if(auto* runtime_error = std::get_if<wasmtime::Error>(&error.data))
{
auto trace = runtime_error->trace();
collect(trace);
}
std::map<String, WasmSourceMap> maps;
std::set<String> unavailable;
std::vector<String> locations;
for(size_t index = 0; index < frames.size() && locations.size() < 12; index++)
{
const WasmUnitModule* unit = 0;
for(auto& loaded : units)
if(loaded.mod->abi.module_name == frames[index].module)
{
unit = loaded.mod.get();
break;
}
if(!unit)
continue;
String map_path = unit->wasm_path + ".source-map";
if(unavailable.find(map_path) != unavailable.end())
continue;
auto loaded_map = maps.find(map_path);
if(loaded_map == maps.end())
{
WasmSourceMap source_map;
if(!wasm_source_map_load(map_path, source_map) || source_map.module_name != frames[index].module)
{
unavailable.insert(map_path);
continue;
}
loaded_map = maps.emplace(map_path, std::move(source_map)).first;
}
String location = wasm_source_map_lookup(loaded_map->second, frames[index].offset);
if(location == "")
continue;
String label = frames[index].function == "" ? "wasm function" : frames[index].function;
locations.push_back("#" + std::to_string(index) + " " + label + " at " + location);
}
if(!locations.empty())
result += "\nsource locations:\n " + join(locations, "\n ");
return(result);
}
// ---- guest memory access (pointer re-derived per call: it moves) ------