fix: harden wasm w7 entrypoint paths
This commit is contained in:
parent
d6421cb8f3
commit
af6500d134
@ -957,6 +957,30 @@ tracking `operator new`, and `cleanup_*_connections()`.
|
|||||||
> is formally decommissioned. This is the honest end-state of W6 given the
|
> is formally decommissioned. This is the honest end-state of W6 given the
|
||||||
> current fallback set, not an oversight.
|
> current fallback set, not an oversight.
|
||||||
|
|
||||||
|
**W7 — non-render entrypoints and fallback hardening.**
|
||||||
|
With render traffic defaulting to wasm, the next retirement work is the entry
|
||||||
|
surface around render: CLI, WebSocket, and `SERVE_HTTP`/custom-server dispatch,
|
||||||
|
plus the cold fallback behaviors that now show up in the default gate. The first
|
||||||
|
W7 slice split the wasm backend into a proper object/header boundary and taught
|
||||||
|
`wasm_backend_serve()` to dispatch CLI/WebSocket/SERVE_HTTP entry kinds; the
|
||||||
|
socket CLI path now propagates wasm runtime errors instead of silently returning
|
||||||
|
success. Post-fork wasm reset was also tightened: the epoch ticker is now held
|
||||||
|
behind a pointer so a forked broker child can discard the inherited, unusable
|
||||||
|
thread handle without placement-new over a live `std::thread` object. The
|
||||||
|
network gate now gives only the known cold native-fallback pages (`sharedunit`
|
||||||
|
and the operational services test) longer per-request timeouts while preserving
|
||||||
|
strict status/body checks, and the services test drains split custom-server
|
||||||
|
response packets before deciding that the named handler failed.
|
||||||
|
|
||||||
|
> **Status: W7a/W7b PARTIAL (2026-06-13).** CLI/WebSocket/SERVE_HTTP entry kind
|
||||||
|
> plumbing exists, CLI socket error propagation is fixed, and post-fork ticker
|
||||||
|
> reset is safer. Directly enabling wasm `SERVE_HTTP` in the in-process custom
|
||||||
|
> HTTP dispatcher was tested and reverted: the dispatcher can hang/spin when it
|
||||||
|
> renders wasm in the forked broker. W7c therefore remains a design item: route
|
||||||
|
> custom-server requests back through a normal worker/workspace boundary rather
|
||||||
|
> than rendering inside the broker child. Remaining native fallback tokens after
|
||||||
|
> W6/W7 hardening are still `zip_` and direct `compiler_load_shared_unit(`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Risks & mitigations
|
## 10. Risks & mitigations
|
||||||
|
|||||||
@ -44,12 +44,19 @@ RENDER(Request& context)
|
|||||||
}
|
}
|
||||||
|
|
||||||
pid_t custom_http_pid = server_start_http("site-tests-http", "19091", "servers/http.uce", "named");
|
pid_t custom_http_pid = server_start_http("site-tests-http", "19091", "servers/http.uce", "named");
|
||||||
usleep(200000);
|
usleep(500000);
|
||||||
u64 custom_http_sockfd = socket_connect("127.0.0.1", 19091);
|
u64 custom_http_sockfd = socket_connect("127.0.0.1", 19091);
|
||||||
if(custom_http_sockfd != 0)
|
if(custom_http_sockfd != 0)
|
||||||
{
|
{
|
||||||
bool write_ok = socket_write(custom_http_sockfd, "GET /custom-test?alpha=1 HTTP/1.0\r\nHost: localhost\r\n\r\n");
|
bool write_ok = socket_write(custom_http_sockfd, "GET /custom-test?alpha=1 HTTP/1.0\r\nHost: localhost\r\n\r\n");
|
||||||
String response = socket_read(custom_http_sockfd, 4096, 2);
|
String response = socket_read(custom_http_sockfd, 4096, 2);
|
||||||
|
for(u64 i = 0; i < 3 && response.find("custom-http-named") == String::npos; i++)
|
||||||
|
{
|
||||||
|
String chunk = socket_read(custom_http_sockfd, 4096, 1);
|
||||||
|
if(chunk == "")
|
||||||
|
break;
|
||||||
|
response += chunk;
|
||||||
|
}
|
||||||
socket_close(custom_http_sockfd);
|
socket_close(custom_http_sockfd);
|
||||||
bool stopped = server_stop("site-tests-http");
|
bool stopped = server_stop("site-tests-http");
|
||||||
usleep(200000);
|
usleep(200000);
|
||||||
|
|||||||
@ -860,7 +860,14 @@ int handle_cli_complete(FastCGIRequest& request)
|
|||||||
// wasm worker (whose traps are signal-based) can run directly.
|
// wasm worker (whose traps are signal-based) can run directly.
|
||||||
String cli_unit = compiler_normalize_unit_path(&request, script_filename);
|
String cli_unit = compiler_normalize_unit_path(&request, script_filename);
|
||||||
if(wasm_backend_should_handle(request, cli_unit))
|
if(wasm_backend_should_handle(request, cli_unit))
|
||||||
wasm_backend_serve(request, cli_unit, wasm_kind::CLI);
|
{
|
||||||
|
String wasm_error = wasm_backend_serve(request, cli_unit, wasm_kind::CLI);
|
||||||
|
if(wasm_error != "")
|
||||||
|
{
|
||||||
|
request.set_status(500, "Internal Server Error");
|
||||||
|
print("UCE CLI wasm error: ", wasm_error, "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
compiler_invoke_cli(&request, script_filename);
|
compiler_invoke_cli(&request, script_filename);
|
||||||
}
|
}
|
||||||
@ -976,9 +983,9 @@ int handle_complete(FastCGIRequest& request) {
|
|||||||
else if(request.params["UCE_SERVE_HTTP"] == "1")
|
else if(request.params["UCE_SERVE_HTTP"] == "1")
|
||||||
// W7c pending: the custom-server dispatcher is forked from a worker
|
// W7c pending: the custom-server dispatcher is forked from a worker
|
||||||
// that already holds a live Wasmtime engine, so re-creating an engine
|
// that already holds a live Wasmtime engine, so re-creating an engine
|
||||||
// in that child is unsafe (engine must not cross fork). The fix is to
|
// in that child currently hangs under the in-process dispatcher path.
|
||||||
// have the broker forward to the worker pool rather than render in the
|
// The fix is to have the broker forward to the worker pool rather than
|
||||||
// fork; until then serve_http renders natively.
|
// render in the fork; until then serve_http renders natively.
|
||||||
compiler_invoke_serve_http(&request, request.params["SCRIPT_FILENAME"], request.params["UCE_SERVE_HTTP_FUNCTION"]);
|
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_backend_should_handle(request, entry_unit))
|
||||||
serve_via_wasm(entry_unit, wasm_kind::RENDER);
|
serve_via_wasm(entry_unit, wasm_kind::RENDER);
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
// per forked worker process: one engine + compiled-core cache, one epoch ticker
|
// per forked worker process: one engine + compiled-core cache, one epoch ticker
|
||||||
static WasmWorker* g_wasm_worker = 0;
|
static WasmWorker* g_wasm_worker = 0;
|
||||||
static std::thread g_wasm_epoch_ticker;
|
static std::thread* g_wasm_epoch_ticker = 0;
|
||||||
static std::atomic<bool> g_wasm_epoch_running(false);
|
static std::atomic<bool> g_wasm_epoch_running(false);
|
||||||
static String g_wasm_init_error;
|
static String g_wasm_init_error;
|
||||||
static bool g_wasm_init_attempted = false;
|
static bool g_wasm_init_attempted = false;
|
||||||
@ -70,7 +70,7 @@ static String wasm_backend_ensure_started(Request* context)
|
|||||||
g_wasm_epoch_running.store(true);
|
g_wasm_epoch_running.store(true);
|
||||||
WasmWorker* worker = g_wasm_worker;
|
WasmWorker* worker = g_wasm_worker;
|
||||||
u64 period_ms = config_u64("WASM_EPOCH_PERIOD_MS", 50);
|
u64 period_ms = config_u64("WASM_EPOCH_PERIOD_MS", 50);
|
||||||
g_wasm_epoch_ticker = std::thread([worker, period_ms] {
|
g_wasm_epoch_ticker = new std::thread([worker, period_ms] {
|
||||||
while(g_wasm_epoch_running.load())
|
while(g_wasm_epoch_running.load())
|
||||||
{
|
{
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
|
std::this_thread::sleep_for(std::chrono::milliseconds(period_ms));
|
||||||
@ -249,8 +249,14 @@ String wasm_backend_serve(Request& request, const String& entry_unit, int32_t ki
|
|||||||
// are usually killed, but a clean ager-out path should join the thread).
|
// are usually killed, but a clean ager-out path should join the thread).
|
||||||
void wasm_backend_shutdown()
|
void wasm_backend_shutdown()
|
||||||
{
|
{
|
||||||
if(g_wasm_epoch_running.exchange(false) && g_wasm_epoch_ticker.joinable())
|
if(g_wasm_epoch_ticker)
|
||||||
g_wasm_epoch_ticker.join();
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A connection-broker child (the custom-server HTTP dispatcher, the websocket
|
// A connection-broker child (the custom-server HTTP dispatcher, the websocket
|
||||||
@ -260,14 +266,14 @@ void wasm_backend_shutdown()
|
|||||||
// the ticker is a phantom and the engine state is unsafe. Reset the per-process
|
// the ticker is a phantom and the engine state is unsafe. Reset the per-process
|
||||||
// statics so the child lazily initializes its own engine + ticker on first use.
|
// statics so the child lazily initializes its own engine + ticker on first use.
|
||||||
// The inherited std::thread refers to a thread that does not exist in the child;
|
// The inherited std::thread refers to a thread that does not exist in the child;
|
||||||
// placement-new it back to a default (non-joinable) state so the eventual
|
// discard the inherited pointer without touching the pointed-to object. Joining,
|
||||||
// re-assignment in ensure_started does not std::terminate on a "joinable" object,
|
// deleting, or destroying a joinable std::thread after fork can terminate the
|
||||||
// and so no join/detach touches the dead handle.
|
// child; the child will allocate its own ticker on first wasm use.
|
||||||
void wasm_backend_reset_after_fork()
|
void wasm_backend_reset_after_fork()
|
||||||
{
|
{
|
||||||
g_wasm_worker = 0;
|
g_wasm_worker = 0;
|
||||||
g_wasm_init_attempted = false;
|
g_wasm_init_attempted = false;
|
||||||
g_wasm_init_error = "";
|
g_wasm_init_error = "";
|
||||||
g_wasm_epoch_running.store(false);
|
g_wasm_epoch_running.store(false);
|
||||||
new (&g_wasm_epoch_ticker) std::thread();
|
g_wasm_epoch_ticker = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from run_network_tests import TestFailure
|
||||||
|
|
||||||
# The demo pages are not regression suites, but every one of them must at
|
# The demo pages are not regression suites, but every one of them must at
|
||||||
# least compile and render: a preprocessor or runtime change that breaks a
|
# least compile and render: a preprocessor or runtime change that breaks a
|
||||||
# demo page (e.g. literal text that looks like an entry point) should fail CI.
|
# demo page (e.g. literal text that looks like an entry point) should fail CI.
|
||||||
@ -17,7 +19,12 @@ def _repo_root():
|
|||||||
|
|
||||||
def _make_demo_case(page_path):
|
def _make_demo_case(page_path):
|
||||||
def run(context):
|
def run(context):
|
||||||
response = context.expect_status(page_path, 200)
|
# sharedunit.uce exercises compiler_load_shared_unit() and can cold-compile
|
||||||
|
# through the native fallback in a fresh worker process; keep the smoke gate
|
||||||
|
# strict on status/body while allowing that one cold path enough time.
|
||||||
|
response = context.request(page_path, timeout=15.0 if page_path.endswith("/sharedunit.uce") else None)
|
||||||
|
if response.status != 200:
|
||||||
|
raise TestFailure("expected HTTP 200 for %s, got %s %s" % (page_path, response.status, response.reason))
|
||||||
body_lower = response.text.lower()
|
body_lower = response.text.lower()
|
||||||
for marker in ERROR_MARKERS:
|
for marker in ERROR_MARKERS:
|
||||||
if marker in body_lower:
|
if marker in body_lower:
|
||||||
|
|||||||
@ -61,7 +61,12 @@ def _make_missing_metadata_case(file_name):
|
|||||||
|
|
||||||
def _make_suite_case(page_path, expected_title):
|
def _make_suite_case(page_path, expected_title):
|
||||||
def run(context):
|
def run(context):
|
||||||
response = context.expect_status(page_path, 200)
|
# services.uce starts/stops a local HTTP listener and may cold-compile the
|
||||||
|
# handler in a fresh worker; keep content checks strict but avoid a false
|
||||||
|
# timeout on the operational smoke path.
|
||||||
|
response = context.request(page_path, timeout=15.0 if page_path.endswith("/services.uce") else None)
|
||||||
|
if response.status != 200:
|
||||||
|
raise TestFailure("expected HTTP 200 for %s, got %s %s" % (page_path, response.status, response.reason))
|
||||||
context.expect_body_contains(response, expected_title)
|
context.expect_body_contains(response, expected_title)
|
||||||
|
|
||||||
body_lower = response.text.lower()
|
body_lower = response.text.lower()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user