Units now run exclusively on wasm; the native generated-C++ -> clang -> .so -> dlopen path and its request-time fallback are removed. - Dispatch (linux_fastcgi.cpp): the 4 handle_complete branches + the CLI-socket path route every request through wasm (wasm_ready compiles cold/stale on demand); a wasm-unavailable unit now yields a clean error page (fail_wasm_unavailable / render_request_failure) instead of native execution. compiler_invoke / _cli / _websocket / _serve_http deleted. - compiler.cpp (-1274): removed the native .so compile (COMPILE_SCRIPT), load_shared_unit, dlopen/dlsym/dlclose, compiler_load_shared_unit, and the SharedUnit .so function-pointer fields (on_setup/on_render/on_component/ on_websocket/on_cli/on_once/on_init) in types.h/types.cpp. compile_shared_unit now builds only the .wasm side-module; the .uce preprocessor/parser front-end is kept (it emits the C++ the wasm compile consumes). - unit_call()/component()/once/init now resolve across units through the wasm host component resolver (uce_host_component_resolve) instead of native dlsym; configured runtime error pages render through the wasm backend. - Dropped the WASM_BACKEND_ENABLED feature flag and dead COMPILE_SCRIPT / COMPILE_WASM_UNITS config; unit ABI freshness tied to UCE_UNIT_ABI_VERSION; guard against serving a stale .wasm for a deleted source; retired the obsolete W5 native-vs-wasm toggle script. Docs updated. Implemented via the pi agent (with 3 delegated sub-reviews); independently re-verified on the build host: run_cli_tests --include-wasm-kill => 87 passed, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
9.2 KiB
C++
237 lines
9.2 KiB
C++
// W4 — FastCGI backend glue for the W3 wasm workspace runtime.
|
|
//
|
|
// Included into the native server TU (src/linux_fastcgi.cpp) after uce_lib.cpp,
|
|
// so it shares String/DValue/config and the UCEB1 codec. Provides a
|
|
// Always-on wasm backend: every unit request is served through a per-request
|
|
// wasm workspace. The legacy native dlopen execution path has been removed.
|
|
|
|
#include "../lib/wasm_trace.h"
|
|
// The native server TU has the real connectors (sqlite/mysql) compiled in, so
|
|
// the worker's host-side connector hostcalls are available here. The W3 CLI
|
|
// driver does not define this and gets a named-trap stub for those imports.
|
|
#define UCE_WASM_HOST_CONNECTORS 1
|
|
#include "worker.cpp"
|
|
|
|
#include <atomic>
|
|
#include <sys/stat.h>
|
|
#include <thread>
|
|
// Worker flushes ws_* dispatch batches to the broker via the FastCGI client.
|
|
#include "../lib/fcgi_forward.h"
|
|
|
|
// per forked worker process: one engine + compiled-core cache, one epoch ticker
|
|
static WasmWorker* g_wasm_worker = 0;
|
|
static std::thread* g_wasm_epoch_ticker = 0;
|
|
static std::atomic<bool> g_wasm_epoch_running(false);
|
|
static String g_wasm_init_error;
|
|
static bool g_wasm_init_attempted = false;
|
|
|
|
bool wasm_backend_configured(Request* context)
|
|
{
|
|
return(context && context->server);
|
|
}
|
|
|
|
// Lazily bring up the per-process worker on first use inside a forked child
|
|
// (the engine must not be inherited across fork). Returns "" on success.
|
|
static String wasm_backend_ensure_started(Request* context)
|
|
{
|
|
if(g_wasm_init_attempted)
|
|
return(g_wasm_init_error);
|
|
g_wasm_init_attempted = true;
|
|
|
|
StringMap& cfg = context->server->config;
|
|
WasmWorkerConfig wc;
|
|
wc.core_wasm_path = first(cfg["WASM_CORE_PATH"],
|
|
path_join(cfg["COMPILER_SYS_PATH"], "bin/wasm/core.wasm"));
|
|
wc.site_root = path_join(cfg["COMPILER_SYS_PATH"], cfg["SITE_DIRECTORY"]);
|
|
wc.cache_root = cfg["BIN_DIRECTORY"];
|
|
// write membrane allowlist: the site tree plus the runtime scratch dirs
|
|
// pages legitimately write to (matches native reachable write targets).
|
|
wc.write_roots = { wc.site_root, "/tmp" };
|
|
for(const char* key : { "BIN_DIRECTORY", "SESSION_PATH", "TMP_UPLOAD_PATH" })
|
|
if(cfg[key] != "")
|
|
wc.write_roots.push_back(cfg[key]);
|
|
wc.memory_limit = (int64_t)config_u64("WASM_MEMORY_LIMIT_BYTES", 512ull * 1024 * 1024);
|
|
wc.epoch_deadline_ticks = config_u64("WASM_EPOCH_DEADLINE_TICKS", 200);
|
|
wc.verbose = config_bool("WASM_BACKEND_VERBOSE", false);
|
|
|
|
g_wasm_worker = new WasmWorker(wc);
|
|
g_wasm_init_error = g_wasm_worker->init();
|
|
if(g_wasm_init_error != "")
|
|
{
|
|
delete g_wasm_worker;
|
|
g_wasm_worker = 0;
|
|
return(g_wasm_init_error);
|
|
}
|
|
|
|
g_wasm_epoch_running.store(true);
|
|
WasmWorker* worker = g_wasm_worker;
|
|
u64 period_ms = config_u64("WASM_EPOCH_PERIOD_MS", 50);
|
|
g_wasm_epoch_ticker = new std::thread([worker, period_ms] {
|
|
while(g_wasm_epoch_running.load())
|
|
{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
|
|
worker->engine.increment_epoch();
|
|
}
|
|
});
|
|
return("");
|
|
}
|
|
|
|
static bool wasm_artifact_exists(Request* context, const String& entry_unit)
|
|
{
|
|
if(entry_unit == "")
|
|
return(false);
|
|
String wasm_path = context->server->config["BIN_DIRECTORY"] + entry_unit + ".wasm";
|
|
struct stat wasm_st;
|
|
if(stat(wasm_path.c_str(), &wasm_st) != 0 || !S_ISREG(wasm_st.st_mode))
|
|
return(false);
|
|
// Require the artifact to be newer than the source. If it is stale,
|
|
// dispatch compiles the unit on demand and then rechecks this predicate.
|
|
struct stat src_st;
|
|
if(stat(entry_unit.c_str(), &src_st) != 0 || !S_ISREG(src_st.st_mode))
|
|
return(false);
|
|
if(wasm_st.st_mtime < src_st.st_mtime)
|
|
return(false);
|
|
return(true);
|
|
}
|
|
|
|
// True if this request can be served by the wasm backend now. Every unit runs
|
|
// on wasm; cold/stale artifacts return false so dispatch can compile on demand
|
|
// and then recheck.
|
|
bool wasm_backend_should_handle(Request& request, const String& entry_unit)
|
|
{
|
|
if(!wasm_backend_configured(&request))
|
|
return(false);
|
|
if(!wasm_artifact_exists(&request, entry_unit))
|
|
return(false);
|
|
if(wasm_backend_ensure_started(&request) != "")
|
|
return(false);
|
|
return(true);
|
|
}
|
|
|
|
// Serve a request through a wasm workspace using the unit handler selected by
|
|
// `kind` (page render / cli / serve_http, with an optional named serve_http
|
|
// handler). Populates the native Request (status/headers/cookies/session/body)
|
|
// so the existing transport writes the response unchanged. Returns "" on
|
|
// success, or a collapsed error/trace string for the caller to route into the
|
|
// configured error page.
|
|
String wasm_backend_serve(Request& request, const String& entry_unit, const String& handler = "render")
|
|
{
|
|
DValue ctx;
|
|
auto copy_map = [&](const StringMap& source, const char* key) {
|
|
for(auto& entry : source)
|
|
ctx[key][entry.first] = entry.second;
|
|
};
|
|
copy_map(request.params, "params");
|
|
copy_map(request.get, "get");
|
|
copy_map(request.post, "post");
|
|
copy_map(request.cookies, "cookies");
|
|
copy_map(request.session, "session");
|
|
ctx["entry_unit"] = entry_unit;
|
|
// Raw request body: cli_input() parses a JSON CLI payload from context.in,
|
|
// and serve_http handlers read it as req->in; carried into the workspace.
|
|
ctx["in"] = request.in;
|
|
// WebSocket event context: the workspace owns no connections, so the frame's
|
|
// connection identity goes in and the handler's ws_send/ws_close dispatch
|
|
// commands come back out (below) for the native broker to apply.
|
|
if(handler == "websocket")
|
|
{
|
|
ctx["ws"]["connection_id"] = request.resources.websocket_connection_id;
|
|
ctx["ws"]["scope"] = request.resources.websocket_scope;
|
|
ctx["ws"]["opcode"] = (f64)request.resources.websocket_opcode;
|
|
ctx["ws"]["binary"].set_bool(request.resources.websocket_is_binary);
|
|
for(auto& id : request.resources.websocket_scope_connection_ids)
|
|
{
|
|
DValue v; v = id;
|
|
ctx["ws"]["connections"].push(v);
|
|
}
|
|
ctx["ws"]["connection_state"] = request.connection;
|
|
}
|
|
|
|
WasmResponse response = wasm_worker_serve(*g_wasm_worker, ctx, entry_unit, handler);
|
|
if(!response.ok)
|
|
return(response.error == "" ? String("wasm workspace failed") : response.error);
|
|
|
|
// Any handler may have called ws_send/ws_close (not just WS handlers). If it
|
|
// left dispatch commands, flush the batch to the central WS broker, which owns
|
|
// all connections and resolves each command's target (id/scope/broadcast)
|
|
// against the full registry. This is the only path WS data takes out — the
|
|
// workspace owns no connections.
|
|
if(DValue* cmds = response.meta.key("ws_commands"))
|
|
{
|
|
DValue batch;
|
|
batch["commands"] = *cmds;
|
|
if(DValue* cstate = response.meta.key("ws_connection_state"))
|
|
{
|
|
batch["connection_id"] = request.resources.websocket_connection_id;
|
|
batch["connection_state"] = *cstate;
|
|
}
|
|
StringMap dispatch_params;
|
|
dispatch_params["UCE_WS_DISPATCH"] = "1";
|
|
String broker_socket = first(request.server ? request.server->config["WS_BROKER_SOCKET_PATH"] : String(),
|
|
"/run/uce/ws-broker.sock");
|
|
fcgi_forward_request(broker_socket, dispatch_params, ucb_encode(batch), 5);
|
|
}
|
|
|
|
// A cli/serve_http unit that does not export the requested handler is a 404.
|
|
// For page render a missing handler is simply an empty body.
|
|
if(!response.handler_present && handler != "render")
|
|
{
|
|
request.set_status(404, handler == "cli" ? "CLI Entry Point Not Found" : "Handler Not Found");
|
|
return("");
|
|
}
|
|
|
|
// Diagnostic timing headers are opt-in: they leak workspace internals and
|
|
// belong to the W5 benchmark harness, not public responses.
|
|
if(config_bool("WASM_BACKEND_VERBOSE", false))
|
|
{
|
|
request.header["X-UCE-Backend"] = "wasm";
|
|
request.header["X-UCE-Wasm-Workspace-Birth-Us"] = std::to_string(response.workspace_birth_us);
|
|
request.header["X-UCE-Wasm-Component-Resolve-Count"] = std::to_string(response.component_resolve_count);
|
|
request.header["X-UCE-Wasm-Component-Resolve-Total-Us"] = std::to_string(response.component_resolve_total_us);
|
|
request.header["X-UCE-Wasm-Component-Resolve-Avg-Us"] = std::to_string(
|
|
response.component_resolve_count ? response.component_resolve_total_us / response.component_resolve_count : 0);
|
|
}
|
|
|
|
// status line: keep the native default unless the unit set one
|
|
String status = response.meta["status"].to_string();
|
|
if(status != "")
|
|
request.response_code = status;
|
|
// merge headers over the native defaults (so Content-Type survives unless
|
|
// the unit overrode it); replace cookies/session with the unit's view
|
|
if(response.meta.key("headers"))
|
|
response.meta["headers"].each([&](const DValue& value, String name) {
|
|
request.header[name] = value.to_string();
|
|
});
|
|
if(response.meta.key("cookies"))
|
|
response.meta["cookies"].each([&](const DValue& value, String) {
|
|
request.set_cookies.push_back(value.to_string());
|
|
});
|
|
if(response.meta.key("session"))
|
|
{
|
|
request.session.clear();
|
|
response.meta["session"].each([&](const DValue& value, String name) {
|
|
request.session[name] = value.to_string();
|
|
});
|
|
}
|
|
|
|
// body into the request's primary output stream (ob_stack[0]); the
|
|
// transport's assemble_output_buffer concatenates the stack
|
|
if(request.ob)
|
|
request.ob->write(response.body.data(), response.body.size());
|
|
return("");
|
|
}
|
|
|
|
// Stop the ticker before the worker process exits (best-effort; forked workers
|
|
// are usually killed, but a clean ager-out path should join the thread).
|
|
void wasm_backend_shutdown()
|
|
{
|
|
if(g_wasm_epoch_ticker)
|
|
{
|
|
g_wasm_epoch_running.store(false);
|
|
if(g_wasm_epoch_ticker->joinable())
|
|
g_wasm_epoch_ticker->join();
|
|
delete g_wasm_epoch_ticker;
|
|
g_wasm_epoch_ticker = 0;
|
|
}
|
|
}
|