wasm runtime: central WS broker, unified handlers, W7d holdouts, membrane completeness

- WS: a dedicated broker process owns HTTP_PORT + every connection; it forwards
  renders to the worker pool over uce.sock (non-blocking) and applies ws_*
  command batches flushed back at workspace teardown. Removes the now-dead
  per-worker websocket executor (-509 lines).
- Dispatch: unify CLI / WebSocket / serve_http / page render through one
  serve_via_wasm(entry_unit, handler) path; handler string -> __uce_<handler>
  export symbol.
- W7d: rewrite zip.uce to the membrane return-value error contract (no C++
  try/catch), error-reporting.uce to genuine wasm traps instead of throw, and
  sharedunit.uce to unit_info(); empty the native-only token gate.
- Membrane: wire ls / mkdir / file_mtime through new uce_host_file_list /
  uce_host_file_mkdir / uce_host_file_mtime hostcalls (resolve_guest_file gains
  directory support). Fixes /doc/index.uce listing nothing; adds a regression
  assertion that the index enumerates items.
- Docs: add docs/wasm-runtime-architecture.md; record the W7e staged native-
  deletion plan in WASM-PROPOSAL.md.

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 17:51:51 +00:00
co-authored by Claude Opus 4.8
parent 15e8d092bc
commit 8587fbc5aa
14 changed files with 1219 additions and 769 deletions
+68 -33
View File
@@ -475,7 +475,8 @@ extern "C" void uce_wasm_link_anchors()
// slots (loading units lazily) and writes the resolved unit path back so
// nested relative component resolution keeps working.
extern "C" int32_t uce_host_component_resolve(
const char* target, size_t target_len, int32_t kind,
const char* target, size_t target_len,
const char* handler, size_t handler_len,
const char* current_unit, size_t current_unit_len,
char* resolved_buf, size_t resolved_cap);
@@ -534,38 +535,37 @@ struct RequestPropsScope
}
};
// kind values shared with the host loader (src/wasm/worker.cpp)
enum WasmResolveKind {
WASM_RESOLVE_COMPONENT = 0,
WASM_RESOLVE_RENDER = 1,
WASM_RESOLVE_EXISTS = 2,
WASM_RESOLVE_ONCE = 3,
WASM_RESOLVE_CLI = 4,
};
static s32 wasm_resolve_target(String target, s32 kind, String* resolved_out = 0)
// A unit is a bag of exported handlers; invoking any of them is one operation —
// the host resolves __uce_<handler> in the loaded module to a funcref slot. The
// handler is just a string: "render", "component:CARD", "render:VARIANT",
// "once", "cli", "websocket", "serve_http:named" — or "exists" (an existence
// probe that loads nothing). No per-mode kinds.
static s32 wasm_resolve_target(String unit_target, String handler, String* resolved_out = 0)
{
String cache_key = std::to_string(kind) + ":" + target;
String cache_key = handler + "\t" + unit_target;
bool is_exists = (handler == "exists");
auto cached = wasm_component_slots.find(cache_key);
if(cached != wasm_component_slots.end() && kind != WASM_RESOLVE_EXISTS)
if(cached != wasm_component_slots.end() && !is_exists)
return(cached->second);
char resolved[512];
String current = context ? context->resources.current_unit_file : "";
s32 slot = uce_host_component_resolve(
target.data(), target.size(), kind,
unit_target.data(), unit_target.size(), handler.data(), handler.size(),
current.data(), current.size(),
resolved, sizeof(resolved));
if(resolved_out && slot)
*resolved_out = String(resolved, strnlen(resolved, sizeof(resolved)));
if(kind != WASM_RESOLVE_EXISTS)
if(!is_exists)
wasm_component_slots[cache_key] = slot;
return(slot);
}
String component_resolve(String name)
{
String file_name, render_name;
component_parse_target(trim(name), file_name, render_name);
String resolved;
if(wasm_resolve_target(trim(name), WASM_RESOLVE_EXISTS, &resolved))
if(wasm_resolve_target(file_name, "exists", &resolved))
return(resolved);
return("");
}
@@ -585,7 +585,7 @@ static void wasm_run_once(const String& resolved, Request& request)
if(request.once_units.find(resolved) != request.once_units.end())
return;
request.once_units.insert(resolved);
s32 once_slot = wasm_resolve_target(resolved, WASM_RESOLVE_ONCE);
s32 once_slot = wasm_resolve_target(resolved, "once");
if(once_slot == 0)
return;
String previous_unit = request.resources.current_unit_file;
@@ -597,8 +597,11 @@ static void wasm_run_once(const String& resolved, Request& request)
void component_render(String name, DValue props, Request& request)
{
String file_name, render_name;
component_parse_target(trim(name), file_name, render_name);
String handler = render_name == "" ? String("component") : "component:" + render_name;
String resolved;
s32 slot = wasm_resolve_target(trim(name), WASM_RESOLVE_COMPONENT, &resolved);
s32 slot = wasm_resolve_target(file_name, handler, &resolved);
if(!slot)
{
print(component_error_banner("component not found: " + trim(name)));
@@ -611,8 +614,8 @@ void component_render(String name, DValue props, Request& request)
request.resources.current_unit_file = resolved;
// a wasm function pointer is its index in the shared funcref table; the
// host returned the handler's slot, so this is a plain call_indirect
request_ref_handler handler = (request_ref_handler)(uintptr_t)slot;
handler(request);
request_ref_handler handler_fn = (request_ref_handler)(uintptr_t)slot;
handler_fn(request);
request.resources.current_unit_file = previous_unit;
}
@@ -633,8 +636,11 @@ String component(String name, DValue props) { return(component(name, props, *con
void unit_render(String file_name, Request& request)
{
String unit_name, render_name;
component_parse_target(trim(file_name), unit_name, render_name);
String handler = render_name == "" ? String("render") : "render:" + render_name;
String resolved;
s32 slot = wasm_resolve_target(trim(file_name), WASM_RESOLVE_RENDER, &resolved);
s32 slot = wasm_resolve_target(unit_name, handler, &resolved);
if(!slot)
{
print(component_error_banner("unit not found: " + trim(file_name)));
@@ -644,8 +650,8 @@ void unit_render(String file_name, Request& request)
String previous_unit = request.resources.current_unit_file;
if(resolved != "")
request.resources.current_unit_file = resolved;
request_ref_handler handler = (request_ref_handler)(uintptr_t)slot;
handler(request);
request_ref_handler handler_fn = (request_ref_handler)(uintptr_t)slot;
handler_fn(request);
request.resources.current_unit_file = previous_unit;
}
@@ -653,25 +659,25 @@ void unit_render(String file_name) { unit_render(file_name, *context); }
extern "C" {
// Host calls this to invoke the request's entry unit through one of its
// directive handlers (render/cli/websocket/serve_http). Routing through the
// resolver gives the entry the same ONCE() + dispatch semantics as components
// (the host pre-loaded it, so resolution is a cache hit). The host pre-checks
// that the handler export exists, so a missing slot here is a silent no-op.
void uce_wasm_invoke_entry(const char* path, size_t len, int32_t kind)
// Host calls this to invoke the request's entry unit through a named handler
// ("render"/"cli"/"websocket"/"serve_http:named"). It is the same resolve +
// ONCE() + dispatch path as components — the entry handler is just another
// export. The host pre-checks the export exists, so a missing slot is a no-op.
void uce_wasm_invoke_entry(const char* path, size_t path_len, const char* handler, size_t handler_len)
{
// the host always runs uce_wasm_core_init + apply_context before this
String file_name(path, len);
String file_name(path, path_len);
String handler_name(handler, handler_len);
String resolved;
s32 slot = wasm_resolve_target(trim(file_name), kind, &resolved);
s32 slot = wasm_resolve_target(trim(file_name), handler_name, &resolved);
if(!slot)
return;
wasm_run_once(resolved, *context);
String previous_unit = context->resources.current_unit_file;
if(resolved != "")
context->resources.current_unit_file = resolved;
request_ref_handler handler = (request_ref_handler)(uintptr_t)slot;
handler(*context);
request_ref_handler handler_fn = (request_ref_handler)(uintptr_t)slot;
handler_fn(*context);
context->resources.current_unit_file = previous_unit;
}
@@ -761,6 +767,27 @@ int uce_wasm_apply_context(const char* buf, size_t len)
wasm_request.resources.current_unit_file = entry->to_string();
DValue* raw_in = decoded.key("in");
wasm_request.in = raw_in ? raw_in->to_string() : "";
// websocket event context: ws_send()/ws_close() capture into the dispatch
// list (the workspace owns no connections), which collect() carries back to
// the broker. Reset per invocation.
wasm_request.resources.websocket_dispatch_commands = DValue();
wasm_request.resources.websocket_dispatch_capture = false;
DValue* ws = decoded.key("ws");
if(ws)
{
wasm_request.resources.websocket_connection_id = (*ws)["connection_id"].to_string();
wasm_request.resources.websocket_scope = (*ws)["scope"].to_string();
wasm_request.resources.websocket_opcode = (u8)(*ws)["opcode"].to_u64();
wasm_request.resources.websocket_is_binary = (*ws)["binary"].to_bool();
wasm_request.resources.websocket_scope_connection_ids.clear();
if(DValue* conns = ws->key("connections"))
conns->each([&](const DValue& v, String) {
wasm_request.resources.websocket_scope_connection_ids.push_back(v.to_string());
});
wasm_request.resources.websocket_dispatch_capture = true;
if(DValue* cstate = ws->key("connection_state"))
wasm_request.connection = *cstate;
}
return(0);
}
@@ -787,6 +814,14 @@ void uce_wasm_finish_response_meta()
}
for(auto& entry : wasm_request.session)
meta["session"][entry.first] = entry.second;
// Any unit code (not just WS handlers) may call ws_send/ws_close; whenever the
// dispatch list is non-empty, carry it back so the worker can flush it to the
// broker. ws_connection_state rides along for stateful WS handlers.
if(!wasm_request.resources.websocket_dispatch_commands._map.empty())
{
meta["ws_commands"] = wasm_request.resources.websocket_dispatch_commands;
meta["ws_connection_state"] = wasm_request.connection;
}
wasm_response_meta = ucb_encode(meta);
}