W7e: wasm-preferred dispatch + compile-on-demand; harden unit ABI validator

- Dispatch (linux_fastcgi.cpp): route every request through wasm. On a
  cold/stale artifact, compile the unit on demand (get_shared_unit, forced) and
  serve wasm; native compiler_invoke* remains only as a fallback when wasm
  cannot be made ready (compile failure / backend disabled). Applies to the 4
  handle_complete branches and the CLI socket path.
- backend.cpp: delete the now-vestigial native-only fallback token gate
  (wasm_backend_native_fallback_*), empty since W7d; should_handle now gates on
  config + current artifact + healthy worker only.
- check_unit_wasm.py: skip the defense-in-depth llvm-nm allocator scan when
  llvm-nm SIGSEGVs on a degenerate-but-valid module (e.g. a unit with no
  exported handlers). Fixes site/demo/empty.uce, the last unit that could not
  produce a .wasm; forbidden allocator *exports* are still rejected.

A pi-assisted review caught that the compile-freshness check keys off the .so
mtime only, so force_recompile is required to rebuild a missing/stale .wasm; a
unit already cached in-process whose .wasm later vanished still uses native
fallback (closed in W7e stage B). Native execution is otherwise bypassed for
all real traffic.

Verified: scripts/run_cli_tests.sh --include-wasm-kill -> 87 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root 2026-06-14 18:20:12 +00:00
parent 8587fbc5aa
commit 2debd33804
3 changed files with 46 additions and 57 deletions

View File

@ -140,6 +140,14 @@ def dylink_has_valid_mem_info(payload: bytes) -> bool:
def defined_symbols(path: Path, llvm_nm: str) -> list[str]:
proc = subprocess.run([llvm_nm, "--defined-only", str(path)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
# llvm-nm (wasi-sdk) can SIGSEGV on degenerate-but-valid modules — e.g. a
# unit with no exported handlers (`empty.uce`). A toolchain crash is not
# evidence of a forbidden allocator, so skip this defense-in-depth scan
# rather than fail the unit; forbidden allocator *exports* are still
# rejected by the export-section check above.
crashed = proc.returncode < 0 or "Stack dump:" in proc.stderr or "PLEASE submit a bug report" in proc.stderr
if crashed:
return []
raise RuntimeError(proc.stderr.strip() or "llvm-nm failed")
symbols = []
for line in proc.stdout.splitlines():

View File

@ -368,6 +368,12 @@ int handle_cli_complete(FastCGIRequest& request)
// The CLI socket path installs no native fault handler, so the
// wasm worker (whose traps are signal-based) can run directly.
String cli_unit = compiler_normalize_unit_path(&request, script_filename);
// W7e: compile a cold/stale unit on demand instead of executing
// native. force_recompile: the compile-needed check ignores the
// .wasm (keys off .so mtime), so a missing/stale .wasm with a
// current .so must be forced to rebuild — see wasm_ready note.
if(!wasm_backend_should_handle(request, cli_unit))
get_shared_unit(&request, cli_unit, true);
if(wasm_backend_should_handle(request, cli_unit))
{
String wasm_error = wasm_backend_serve(request, cli_unit, "cli");
@ -477,6 +483,28 @@ int handle_complete(FastCGIRequest& request) {
};
String entry_unit = compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"]);
// W7e: every unit runs on wasm. When the artifact is missing or stale
// (cold worker, or source edited since the last compile),
// wasm_backend_should_handle() returns false; rather than fall back to
// native execution we compile the unit on demand — get_shared_unit()
// builds the .wasm side-module — and recheck. Native execution remains
// only as a safety net for a genuine wasm-compile failure (or the wasm
// backend being disabled), both of which leave should_handle() false.
// Only reached on the cold/stale path (should_handle false); warm
// requests short-circuit before this, so there is no steady-state
// recompile cost. force_recompile is needed because the compile-needed
// check keys off the .so mtime only (shared_unit_compile_check /
// compiled_time ignores the .wasm), so an uncached unit with a current
// .so but missing .wasm would otherwise not rebuild. NOTE: this does not
// cover a unit already cached in-process whose .wasm later vanished
// (compiler_reusable_cached_unit short-circuits before the recompile) —
// that synthetic case still uses native fallback and is closed properly
// in W7e stage B (make the .wasm part of the compile-freshness check).
auto wasm_ready = [&](const String& unit) -> bool {
if(!wasm_backend_should_handle(request, unit))
get_shared_unit(&request, unit, true);
return(wasm_backend_should_handle(request, unit));
};
if(request.params["UCE_WS"] == "1")
{
// A WS message the broker forwarded here: rebuild the connection
@ -498,14 +526,14 @@ int handle_complete(FastCGIRequest& request) {
if(ucb_decode(state_raw, state, &e))
request.connection = state;
}
if(wasm_backend_should_handle(request, entry_unit))
if(wasm_ready(entry_unit))
serve_via_wasm(entry_unit, "websocket");
else
compiler_invoke_websocket(&request, request.params["SCRIPT_FILENAME"]);
}
else if(request.resources.is_cli)
{
if(wasm_backend_should_handle(request, entry_unit))
if(wasm_ready(entry_unit))
serve_via_wasm(entry_unit, "cli");
else
compiler_invoke_cli(&request, request.params["SCRIPT_FILENAME"]);
@ -515,7 +543,7 @@ int handle_complete(FastCGIRequest& request) {
// W7c: this runs in a normal worker (clean engine) — the custom-server
// dispatcher forwarded the request here via FastCGI rather than render
// wasm in its own fork (Wasmtime cannot be re-created across fork).
if(wasm_backend_should_handle(request, entry_unit))
if(wasm_ready(entry_unit))
{
String fn = request.params["UCE_SERVE_HTTP_FUNCTION"];
serve_via_wasm(entry_unit, fn == "" ? String("serve_http") : "serve_http:" + fn);
@ -523,7 +551,7 @@ int handle_complete(FastCGIRequest& request) {
else
compiler_invoke_serve_http(&request, request.params["SCRIPT_FILENAME"], request.params["UCE_SERVE_HTTP_FUNCTION"]);
}
else if(wasm_backend_should_handle(request, entry_unit))
else if(wasm_ready(entry_unit))
serve_via_wasm(entry_unit, "render");
else
compiler_invoke(&request, request.params["SCRIPT_FILENAME"]);

View File

@ -100,63 +100,16 @@ static bool wasm_artifact_exists(Request* context, const String& entry_unit)
return(true);
}
// Decide, by source inspection, whether a page must stay on the native
// backend for now (W5 surfaces not yet behind the membrane).
//
// Caveats this deliberately accepts:
// - ENTRY-UNIT ONLY: a clean entry page that component()s into a native-only
// unit is still served by wasm. With signals_based_traps off (see
// make_engine) the worst case is a clean wasm error or a stubbed-empty
// result, never a worker crash — so this is a best-effort routing hint,
// not a guarantee. Full coverage is a W5 concern (un-stub the APIs).
// - SUBSTRING match, intentionally conservative: a token like "xml_" also
// matches an identifier or doc string containing it. That only ever
// over-routes to native (always correct), never the reverse.
static bool wasm_backend_native_fallback_uncached(Request* context, const String& entry_unit)
{
String source = file_get_contents(entry_unit);
// Supported by the wasm core, intentionally NOT in this list:
// - regex_* (host PCRE2 hostcall), xml_*/yaml_*/markdown_* (compiled in),
// - unit_render()/component() (host resolver).
// W7d: every former native-only surface now goes through the membrane —
// zip_* (uce_host_zip), unit introspection (uce_host_units: unit_info/
// unit_call/units_list), regex/xml/yaml/markdown, filesystem, sqlite,
// background tasks, sleep, sockets/custom servers, memcache, mysql. No unit
// source token forces native anymore, so there is nothing left to scan for.
// (The native fallback now only covers cold/stale artifacts; W7e removes it.)
(void)source;
return(false);
}
static bool wasm_backend_native_fallback_needed(Request* context, const String& entry_unit)
{
if(!context || !context->server || entry_unit == "")
return(true);
// Cache the verdict per process, keyed on path + mtime: the source scan is
// otherwise a file read + 20 substring searches on every page request.
struct CachedVerdict { time_t mtime; bool fallback; };
static std::map<String, CachedVerdict> cache;
struct stat st;
time_t mtime = (stat(entry_unit.c_str(), &st) == 0) ? st.st_mtime : 0;
auto it = cache.find(entry_unit);
if(it != cache.end() && it->second.mtime == mtime)
return(it->second.fallback);
bool fallback = wasm_backend_native_fallback_uncached(context, entry_unit);
cache[entry_unit] = { mtime, fallback };
return(fallback);
}
// True if this request should be served by the wasm backend. Falls through to
// native when disabled, when init failed, or when the unit has no wasm artifact
// (e.g. units skip-listed for try/catch — automatic, graceful fallback). Mode-
// agnostic: the caller (page render / cli / serve_http) decides which entry to
// route here; this only gates on config, fallback tokens, artifact, and worker.
// True if this request should be served by the wasm backend. Every unit now
// runs on wasm (W7d retired the last native-only surfaces), so this only gates
// on config, the presence of a current wasm artifact, and a healthy worker —
// the remaining native fallback exists solely to cover a cold/stale artifact
// (a unit not yet compiled, or whose source is newer than its .wasm). W7e
// removes that last fallback once cold/stale triggers an on-demand wasm compile.
bool wasm_backend_should_handle(Request& request, const String& entry_unit)
{
if(!wasm_backend_configured(&request))
return(false);
if(wasm_backend_native_fallback_needed(&request, entry_unit))
return(false);
if(!wasm_artifact_exists(&request, entry_unit))
return(false);
if(wasm_backend_ensure_started(&request) != "")