fix: prevent stale mutation execution

This commit is contained in:
udo 2026-07-13 20:17:48 +00:00
parent 0187bb60cf
commit 92baaa6299
7 changed files with 140 additions and 15 deletions

View File

@ -136,15 +136,19 @@ refreshes the epoch deadline before its first guest call. Otherwise a component
whose compilation outlasted the guest CPU budget would immediately trap in the whose compilation outlasted the guest CPU budget would immediately trap in the
following allocator/relocation call even though no guest loop consumed it. following allocator/relocation call even though no guest loop consumed it.
The proactive compiler and request workers coordinate through a per-unit file The proactive compiler and request workers coordinate through per-unit file
lock. Unit compilation writes and validates a process-unique temporary wasm locks and a lock-protected demand-priority queue under `BIN_DIRECTORY`. Unit
file, then publishes it with an atomic rename. When proactive compilation is compilation writes and validates a process-unique temporary wasm file, then
enabled, HTTP and WebSocket requests keep using the last complete artifact publishes it with an atomic rename. When proactive compilation is enabled,
while stale units rebuild in the background; CLI and explicit compile paths read-only HTTP requests keep using the last complete artifact while requesting
remain synchronous. A failed rebuild removes availability and surfaces the that stale unit at the head of the compiler queue. Non-read requests never run
compiler error rather than serving the old unit indefinitely. The per-unit lock a stale entry artifact: they return `503 Service Unavailable` with
also keeps concurrent synchronous compilers from waiting across a transitive `Retry-After: 1`, allowing the client to retry after the priority rebuild.
graph when a last complete artifact is available. CLI and explicit compile paths remain synchronous. A failed rebuild removes
availability and surfaces the compiler error rather than serving the old unit
indefinitely. The per-unit lock also keeps concurrent synchronous compilers
from waiting across a transitive graph when a last complete artifact is
available.
Before preprocessing, the compiler verifies that the worker can actually read Before preprocessing, the compiler verifies that the worker can actually read
the source. An unreadable path is a compile failure with a persisted diagnostic; the source. An unreadable path is a compile failure with a persisted diagnostic;
@ -367,8 +371,10 @@ header free-functions are `inline`. The wasm backend exposes only declarations
parent request still renders it. The dependency-invalidation gate also holds parent request still renders it. The dependency-invalidation gate also holds
parent and child compile locks across a transitive source edit, proves a parent and child compile locks across a transitive source edit, proves a
warmed HTTP request returns the last atomically published result without warmed HTTP request returns the last atomically published result without
waiting, while a CLI request waits and returns only the current dependency waiting, proves a POST returns a prompt retryable 503 without executing its
result after rebuild completion. old mutation handler, and verifies demand-priority convergence. A CLI request
still waits and returns only the current dependency result after rebuild
completion.
- **WebSocket end-to-end**: a headless client performs a raw WS handshake to - **WebSocket end-to-end**: a headless client performs a raw WS handshake to
`:HTTP_PORT` with path `/site/tests/websockets.ws.uce` (self-resolving `:HTTP_PORT` with path `/site/tests/websockets.ws.uce` (self-resolving

View File

@ -11,6 +11,9 @@ if [[ -z "$bin_directory" && -r /etc/uce/settings.cfg ]]; then
fi fi
bin_directory="${bin_directory:-/tmp/uce/work}" bin_directory="${bin_directory:-/tmp/uce/work}"
cache_dir="" cache_dir=""
mutation_file="/tmp/uce-dependency-mutation-$$"
post_body="/tmp/uce-dependency-post-body-$$"
post_headers="/tmp/uce-dependency-post-headers-$$"
http_host="${UCE_TEST_HTTP_HOST:-uce.openfu.com}" http_host="${UCE_TEST_HTTP_HOST:-uce.openfu.com}"
cleanup() { cleanup() {
@ -18,6 +21,7 @@ cleanup() {
if [[ -n "$cache_dir" ]]; then if [[ -n "$cache_dir" ]]; then
rm -rf "$cache_dir" rm -rf "$cache_dir"
fi fi
rm -f "$mutation_file" "$post_body" "$post_headers"
} }
trap cleanup EXIT trap cleanup EXIT
mkdir -p "$source_dir" mkdir -p "$source_dir"
@ -31,7 +35,7 @@ printf '%s\n' \
printf '%s\n' \ printf '%s\n' \
'#load "child.uce"' \ '#load "child.uce"' \
'CLI(Request& context) { print(dependency_cache_marker(), ":", request_perf()["worker_pid"].to_string()); }' \ 'CLI(Request& context) { print(dependency_cache_marker(), ":", request_perf()["worker_pid"].to_string()); }' \
'RENDER(Request& context) { print(dependency_cache_marker(), ":", request_perf()["worker_pid"].to_string()); }' >"$source_dir/parent.uce" "RENDER(Request& context) { if(context.params[\"REQUEST_METHOD\"] == \"POST\") file_put_contents(\"$mutation_file\", dependency_cache_marker()); print(dependency_cache_marker(), \":\", request_perf()[\"worker_pid\"].to_string()); }" >"$source_dir/parent.uce"
assert_marker() { assert_marker() {
local path="$1" local path="$1"
@ -83,6 +87,15 @@ if (( elapsed_ms >= 2000 )); then
echo "HTTP request spent ${elapsed_ms}ms rebuilding a stale artifact" >&2 echo "HTTP request spent ${elapsed_ms}ms rebuilding a stale artifact" >&2
exit 1 exit 1
fi fi
deadline=$((SECONDS + 15))
while [[ "$http_during_rebuild" != *"dependency-marker-b"* && $SECONDS -lt $deadline ]]; do
sleep 0.2
http_during_rebuild=$(http_marker)
done
if [[ "$http_during_rebuild" != *"dependency-marker-b"* ]]; then
echo "requested stale HTTP unit did not receive a demand-priority rebuild" >&2
exit 1
fi
assert_marker parent dependency-marker-b assert_marker parent dependency-marker-b
# A proactive rebuild owns these same per-unit locks. While it publishes fresh # A proactive rebuild owns these same per-unit locks. While it publishes fresh
@ -111,6 +124,26 @@ if (( elapsed_ms >= 2000 )); then
echo "HTTP request waited ${elapsed_ms}ms for an active transitive rebuild" >&2 echo "HTTP request waited ${elapsed_ms}ms for an active transitive rebuild" >&2
exit 1 exit 1
fi fi
rm -f "$mutation_file"
started_at=$(date +%s%N)
post_status=$(curl -sS -o "$post_body" -D "$post_headers" -w '%{http_code}' -X POST -H "Host: $http_host" "http://127.0.0.1/$test_name/parent.uce")
elapsed_ms=$(( ($(date +%s%N) - started_at) / 1000000 ))
if [[ "$post_status" != "503" ]]; then
echo "stale POST executed an application artifact instead of returning 503: status=$post_status body=$(cat "$post_body")" >&2
exit 1
fi
if ! grep -qi '^Retry-After: 1' "$post_headers"; then
echo "stale POST did not return a Retry-After header" >&2
exit 1
fi
if [[ -e "$mutation_file" ]]; then
echo "stale POST executed the old mutation handler: $(cat "$mutation_file")" >&2
exit 1
fi
if (( elapsed_ms >= 2000 )); then
echo "stale POST waited ${elapsed_ms}ms instead of failing closed promptly" >&2
exit 1
fi
started_at=$(date +%s%N) started_at=$(date +%s%N)
assert_marker parent dependency-marker-d assert_marker parent dependency-marker-d
elapsed_ms=$(( ($(date +%s%N) - started_at) / 1000000 )) elapsed_ms=$(( ($(date +%s%N) - started_at) / 1000000 ))

View File

@ -312,6 +312,11 @@ String compiler_registry_lock_file_name(Request* context)
return(compiler_registry_file_name(context) + ".lock"); return(compiler_registry_file_name(context) + ".lock");
} }
String compiler_priority_file_name(Request* context)
{
return(context->server->config["BIN_DIRECTORY"] + "/proactive-priority.txt");
}
int compiler_open_lock_file(String file_name, String purpose, bool nonblocking = false) int compiler_open_lock_file(String file_name, String purpose, bool nonblocking = false)
{ {
(void)purpose; (void)purpose;
@ -1015,6 +1020,58 @@ bool compiler_request_can_serve_stale_artifact(Request* context)
float_val(context->server->config["PROACTIVE_COMPILE_CHECK_INTERVAL"]) > 0); float_val(context->server->config["PROACTIVE_COMPILE_CHECK_INTERVAL"]) > 0);
} }
void compiler_prioritize_unit(Request* context, String file_name)
{
if(!compiler_request_can_serve_stale_artifact(context))
return;
file_name = compiler_normalize_unit_path(context, file_name);
if(file_name == "" || file_name.find('\n') != String::npos || file_name.find('\r') != String::npos)
return;
String queue_file = compiler_priority_file_name(context);
int fd = compiler_open_lock_file(queue_file + ".lock", "proactive-priority");
if(fd < 0)
return;
int queue_fd = open(queue_file.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);
if(queue_fd >= 0)
{
String line = file_name + "\n";
ssize_t offset = 0;
while(offset < (ssize_t)line.size())
{
ssize_t written = write(queue_fd, line.data() + offset, line.size() - offset);
if(written <= 0)
break;
offset += written;
}
close(queue_fd);
}
compiler_close_lock_file(fd);
}
StringList compiler_take_priority_units(Request* context)
{
StringList result;
if(!context || !context->server)
return(result);
String queue_file = compiler_priority_file_name(context);
int fd = compiler_open_lock_file(queue_file + ".lock", "proactive-priority");
if(fd < 0)
return(result);
if(file_exists(queue_file))
{
for(auto file_name : split(file_get_contents(queue_file), "\n"))
{
file_name = trim(file_name);
if(file_name != "" && std::find(result.begin(), result.end(), file_name) == result.end())
result.push_back(file_name);
}
file_put_contents(queue_file, "");
}
compiler_close_lock_file(fd);
return(result);
}
void unit_render(String file_name) void unit_render(String file_name)
{ {
unit_render(file_name, *context); unit_render(file_name, *context);

View File

@ -19,6 +19,8 @@ String compiler_error_page_unit(Request* context, String config_key);
bool compiler_unit_compile_pending(Request* context, String file_name); bool compiler_unit_compile_pending(Request* context, String file_name);
bool compiler_unit_compile_in_progress(Request* context, String file_name); bool compiler_unit_compile_in_progress(Request* context, String file_name);
bool compiler_request_can_serve_stale_artifact(Request* context); bool compiler_request_can_serve_stale_artifact(Request* context);
void compiler_prioritize_unit(Request* context, String file_name);
StringList compiler_take_priority_units(Request* context);
String compiler_site_directory(Request* context); String compiler_site_directory(Request* context);
StringList compiler_scan_site_units(Request* context); StringList compiler_scan_site_units(Request* context);
StringList compiler_list_known_units(Request* context); StringList compiler_list_known_units(Request* context);

View File

@ -575,6 +575,17 @@ int handle_complete(FastCGIRequest& request) {
}; };
String entry_unit = compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"]); String entry_unit = compiler_normalize_unit_path(&request, request.params["SCRIPT_FILENAME"]);
String request_method = to_upper(trim(request.params["REQUEST_METHOD"]));
bool read_request = request_method == "GET" || request_method == "HEAD" || request_method == "OPTIONS";
bool stale_mutation = !request.resources.is_cli && !read_request && compiler_unit_needs_recompile(&request, entry_unit);
if(stale_mutation)
{
compiler_prioritize_unit(&request, entry_unit);
request.set_status(503, "Service Unavailable");
request.header["Content-Type"] = "text/plain; charset=utf-8";
request.header["Retry-After"] = "1";
print("The requested code is being updated. Retry this request shortly.\n");
}
// W7e: every unit runs on wasm. When the artifact is missing or stale // W7e: every unit runs on wasm. When the artifact is missing or stale
// (cold worker, or source edited since the last compile), compile the // (cold worker, or source edited since the last compile), compile the
// unit on demand — get_shared_unit() builds the .wasm side-module — and // unit on demand — get_shared_unit() builds the .wasm side-module — and
@ -593,7 +604,11 @@ int handle_complete(FastCGIRequest& request) {
failure_trace = "source: " + request.params["SCRIPT_FILENAME"]; failure_trace = "source: " + request.params["SCRIPT_FILENAME"];
}; };
if(request.params["UCE_WS"] == "1") if(stale_mutation)
{
// The response above deliberately bypasses all stale application code.
}
else if(request.params["UCE_WS"] == "1")
{ {
// A WS message the broker forwarded here: rebuild the connection // A WS message the broker forwarded here: rebuild the connection
// context the broker passed as params, then run __uce_websocket. // context the broker passed as params, then run __uce_websocket.
@ -1243,7 +1258,13 @@ void run_proactive_compiler()
{ {
try try
{ {
if(compile_queue.size() == 0 && time_precise() >= next_scan_at) auto priority_units = compiler_take_priority_units(&background_context);
for(auto it = priority_units.rbegin(); it != priority_units.rend(); ++it)
{
compile_queue.erase(std::remove(compile_queue.begin(), compile_queue.end(), *it), compile_queue.end());
compile_queue.insert(compile_queue.begin(), *it);
}
if(compile_queue.size() == 0 && time_precise() >= next_scan_at)
{ {
auto tracked_units = compiler_list_known_units(&background_context); auto tracked_units = compiler_list_known_units(&background_context);
StringList existing_units; StringList existing_units;

View File

@ -100,7 +100,10 @@ static bool wasm_artifact_exists(Request* context, const String& entry_unit)
// metadata mismatches, which can leave stale wasm with old imports. // metadata mismatches, which can leave stale wasm with old imports.
bool source_missing = false; bool source_missing = false;
if(compiler_unit_needs_recompile(context, entry_unit, &source_missing)) if(compiler_unit_needs_recompile(context, entry_unit, &source_missing))
{
compiler_prioritize_unit(context, entry_unit);
return(compiler_request_can_serve_stale_artifact(context)); return(compiler_request_can_serve_stale_artifact(context));
}
if(source_missing) if(source_missing)
return(false); return(false);
return(true); return(true);

View File

@ -1606,7 +1606,10 @@ private:
return(1); return(1);
} }
if(!file_exists_host(worker.unit_wasm_path(resolved)) || (compiler_unit_needs_recompile(context, resolved, 0) && !compiler_request_can_serve_stale_artifact(context) && !compiler_unit_compile_in_progress(context, resolved))) bool stale = compiler_unit_needs_recompile(context, resolved, 0);
if(stale && compiler_request_can_serve_stale_artifact(context))
compiler_prioritize_unit(context, resolved);
if(!file_exists_host(worker.unit_wasm_path(resolved)) || (stale && !compiler_request_can_serve_stale_artifact(context) && !compiler_unit_compile_in_progress(context, resolved)))
get_shared_unit(context, resolved); get_shared_unit(context, resolved);
size_t unit_index = 0; size_t unit_index = 0;