From af6500d1343581abdac475dcb6855821afd8c8d0 Mon Sep 17 00:00:00 2001 From: udo Date: Sat, 13 Jun 2026 22:08:52 +0000 Subject: [PATCH] fix: harden wasm w7 entrypoint paths --- WASM-PROPOSAL.md | 24 ++++++++++++++++++++++++ site/tests/services.uce | 9 ++++++++- src/linux_fastcgi.cpp | 15 +++++++++++---- src/wasm/backend.cpp | 22 ++++++++++++++-------- tests/plugins/uce_demo_smoke.py | 9 ++++++++- tests/plugins/uce_site_suite.py | 7 ++++++- 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/WASM-PROPOSAL.md b/WASM-PROPOSAL.md index 0055008..dd63f51 100644 --- a/WASM-PROPOSAL.md +++ b/WASM-PROPOSAL.md @@ -957,6 +957,30 @@ tracking `operator new`, and `cleanup_*_connections()`. > is formally decommissioned. This is the honest end-state of W6 given the > 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 diff --git a/site/tests/services.uce b/site/tests/services.uce index e568db1..250b369 100644 --- a/site/tests/services.uce +++ b/site/tests/services.uce @@ -44,12 +44,19 @@ RENDER(Request& context) } 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); 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"); 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); bool stopped = server_stop("site-tests-http"); usleep(200000); diff --git a/src/linux_fastcgi.cpp b/src/linux_fastcgi.cpp index 4fd9bb6..70bf78e 100644 --- a/src/linux_fastcgi.cpp +++ b/src/linux_fastcgi.cpp @@ -860,7 +860,14 @@ int handle_cli_complete(FastCGIRequest& request) // wasm worker (whose traps are signal-based) can run directly. String cli_unit = compiler_normalize_unit_path(&request, script_filename); 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 compiler_invoke_cli(&request, script_filename); } @@ -976,9 +983,9 @@ int handle_complete(FastCGIRequest& request) { else if(request.params["UCE_SERVE_HTTP"] == "1") // W7c pending: the custom-server dispatcher is forked from a worker // 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 - // have the broker forward to the worker pool rather than render in the - // fork; until then serve_http renders natively. + // in that child currently hangs under the in-process dispatcher path. + // The fix is to have the broker forward to the worker pool rather than + // 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"]); else if(wasm_backend_should_handle(request, entry_unit)) serve_via_wasm(entry_unit, wasm_kind::RENDER); diff --git a/src/wasm/backend.cpp b/src/wasm/backend.cpp index 6668496..03c9bee 100644 --- a/src/wasm/backend.cpp +++ b/src/wasm/backend.cpp @@ -22,7 +22,7 @@ // 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; +static std::thread* g_wasm_epoch_ticker = 0; static std::atomic g_wasm_epoch_running(false); static String g_wasm_init_error; 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); WasmWorker* worker = g_wasm_worker; 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()) { 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). void wasm_backend_shutdown() { - if(g_wasm_epoch_running.exchange(false) && g_wasm_epoch_ticker.joinable()) - g_wasm_epoch_ticker.join(); + 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; + } } // 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 // 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; -// placement-new it back to a default (non-joinable) state so the eventual -// re-assignment in ensure_started does not std::terminate on a "joinable" object, -// and so no join/detach touches the dead handle. +// discard the inherited pointer without touching the pointed-to object. Joining, +// deleting, or destroying a joinable std::thread after fork can terminate the +// child; the child will allocate its own ticker on first wasm use. void wasm_backend_reset_after_fork() { g_wasm_worker = 0; g_wasm_init_attempted = false; g_wasm_init_error = ""; g_wasm_epoch_running.store(false); - new (&g_wasm_epoch_ticker) std::thread(); + g_wasm_epoch_ticker = 0; } diff --git a/tests/plugins/uce_demo_smoke.py b/tests/plugins/uce_demo_smoke.py index 397770b..7efc94f 100644 --- a/tests/plugins/uce_demo_smoke.py +++ b/tests/plugins/uce_demo_smoke.py @@ -1,5 +1,7 @@ from pathlib import Path +from run_network_tests import TestFailure + # 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 # 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 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() for marker in ERROR_MARKERS: if marker in body_lower: diff --git a/tests/plugins/uce_site_suite.py b/tests/plugins/uce_site_suite.py index be94089..72ed0c0 100644 --- a/tests/plugins/uce_site_suite.py +++ b/tests/plugins/uce_site_suite.py @@ -61,7 +61,12 @@ def _make_missing_metadata_case(file_name): def _make_suite_case(page_path, expected_title): 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) body_lower = response.text.lower()