diff --git a/scripts/test_dependency_invalidation.sh b/scripts/test_dependency_invalidation.sh index 1e3b2b7..81961b6 100755 --- a/scripts/test_dependency_invalidation.sh +++ b/scripts/test_dependency_invalidation.sh @@ -18,6 +18,10 @@ fi bin_directory="${bin_directory:-/tmp/uce/work}" cache_dir="" mutation_file="/tmp/uce-dependency-mutation-$$" +component_mutation_file="/tmp/uce-component-mutation-$$" +component_post_body="/tmp/uce-component-post-body-$$" +component_post_headers="/tmp/uce-component-post-headers-$$" +component_lock_ready_file="/tmp/uce-component-lock-ready-$$" post_body="/tmp/uce-dependency-post-body-$$" post_headers="/tmp/uce-dependency-post-headers-$$" lock_ready_file="/tmp/uce-dependency-lock-ready-$$" @@ -32,7 +36,7 @@ cleanup() { if [[ -n "$cache_dir" ]]; then rm -rf "$cache_dir" fi - rm -f "$mutation_file" "$post_body" "$post_headers" "$lock_ready_file" "$nested_lock_ready_file" "$race_output" "$race_error" "$race_status" + rm -f "$mutation_file" "$component_mutation_file" "$component_post_body" "$component_post_headers" "$component_lock_ready_file" "$post_body" "$post_headers" "$lock_ready_file" "$nested_lock_ready_file" "$race_output" "$race_error" "$race_status" } trap cleanup EXIT mkdir -p "$source_dir" @@ -69,6 +73,80 @@ if [[ "$(http_marker)" != *"dependency-marker-a"* ]]; then exit 1 fi +# HTTP entry units can resolve route/components dynamically. A changed dynamic +# component must enter the demand-priority queue just like a changed entry unit; +# otherwise a common dependency rebuild can leave the requested page stale for +# the duration of the full proactive queue. +printf '%s\n' "COMPONENT(Request& context) { if(context.params[\"REQUEST_METHOD\"] == \"POST\") file_put_contents(\"$component_mutation_file\", \"executed\"); print(\"http-component-marker-a\"); }" >"$source_dir/http-component-child.uce" +printf '%s\n' 'RENDER(Request& context) { DValue props; print(component("http-component-child", props, context)); }' >"$source_dir/http-component-parent.uce" +http_component_marker() { + curl -fsS -H "Host: $http_host" "http://127.0.0.1/$test_name/http-component-parent.uce" +} +if [[ "$(http_component_marker)" != *"http-component-marker-a"* ]]; then + echo "HTTP component warm-up did not return marker-a" >&2 + exit 1 +fi +sed -i 's/http-component-marker-a/http-component-marker-b/' "$source_dir/http-component-child.uce" +started_at=$(date +%s%N) +http_component_output=$(http_component_marker) +elapsed_ms=$(( ($(date +%s%N) - started_at) / 1000000 )) +if [[ "$http_component_output" != *"http-component-marker-a"* && "$http_component_output" != *"http-component-marker-b"* ]]; then + echo "stale HTTP component request returned neither complete artifact: $http_component_output" >&2 + exit 1 +fi +if (( elapsed_ms >= 2000 )); then + echo "HTTP component request spent ${elapsed_ms}ms rebuilding a stale artifact" >&2 + exit 1 +fi +deadline=$((SECONDS + 15)) +while [[ "$http_component_output" != *"http-component-marker-b"* && $SECONDS -lt $deadline ]]; do + sleep 0.2 + http_component_output=$(http_component_marker) +done +if [[ "$http_component_output" != *"http-component-marker-b"* ]]; then + echo "requested stale HTTP component did not receive a demand-priority rebuild" >&2 + exit 1 +fi + +# Mutations must never execute a stale dynamically resolved component, even +# inside the short read-only freshness memo window. +http_component_wasm="$cache_dir/http-component-child.uce.wasm" +( + exec 8>"$http_component_wasm.lock" + flock 8 + : >"$component_lock_ready_file" + sleep 2 +) & +component_lock_pid=$! +deadline=$((SECONDS + 5)) +while [[ ! -e "$component_lock_ready_file" && $SECONDS -lt $deadline ]]; do sleep 0.05; done +if [[ ! -e "$component_lock_ready_file" ]]; then + echo "HTTP component mutation lock was not acquired before deadline" >&2 + exit 1 +fi +sed -i 's/http-component-marker-b/http-component-marker-c/' "$source_dir/http-component-child.uce" +rm -f "$component_mutation_file" +component_post_status=$(curl -sS -o "$component_post_body" -D "$component_post_headers" -w '%{http_code}' -X POST -H "Host: $http_host" "http://127.0.0.1/$test_name/http-component-parent.uce") +if [[ "$component_post_status" != "503" ]]; then + echo "stale HTTP component mutation did not fail closed: status=$component_post_status body=$(cat "$component_post_body")" >&2 + exit 1 +fi +if ! grep -qi '^Retry-After: 1' "$component_post_headers"; then + echo "stale HTTP component mutation omitted Retry-After" >&2 + exit 1 +fi +if [[ -e "$component_mutation_file" ]]; then + echo "stale HTTP component mutation executed application code" >&2 + exit 1 +fi +wait "$component_lock_pid" +deadline=$((SECONDS + 15)) +while [[ "$(http_component_marker)" != *"http-component-marker-c"* && $SECONDS -lt $deadline ]]; do sleep 0.2; done +if [[ "$(http_component_marker)" != *"http-component-marker-c"* ]]; then + echo "HTTP component did not recover after the guarded mutation rebuild" >&2 + exit 1 +fi + # A failed parent build must remember the exact dependency graph that failed. # If a dependency is restored byte-for-byte to the last working source, the # old successful metadata must not make that transient failure permanent. diff --git a/src/wasm/worker.cpp b/src/wasm/worker.cpp index b6d2e7c..9ab964c 100644 --- a/src/wasm/worker.cpp +++ b/src/wasm/worker.cpp @@ -1117,6 +1117,12 @@ public: private: friend class WasmWorkspace; + struct ComponentFreshnessState + { + std::chrono::steady_clock::time_point checked_at; + bool stale = false; + }; + std::map component_freshness; static String cached_wasm_path(const String& wasm_path) { @@ -1491,6 +1497,13 @@ public: String decode_error; if(!ucb_decode(meta_encoded, response.meta, &decode_error)) return("response meta decode failed: " + decode_error); + if(stale_component_mutation_blocked) + { + response.body = "The requested code is being updated. Retry this request shortly.\n"; + response.meta["status"] = stale_component_mutation_status; + response.meta["headers"]["Content-Type"] = "text/plain; charset=utf-8"; + response.meta["headers"]["Retry-After"] = "1"; + } return(""); } @@ -1513,6 +1526,8 @@ private: std::map units_by_source; std::map handler_slots; // source + ":" + symbol → table slot std::vector host_funcs; // keep host imports alive + bool stale_component_mutation_blocked = false; + String stale_component_mutation_status; wasmtime::Store::Context ctx() { @@ -2180,11 +2195,42 @@ private: auto artifact_start = std::chrono::steady_clock::now(); bool artifact_exists = file_exists_host(worker.unit_wasm_path(resolved)); bool can_serve_stale = compiler_request_can_serve_stale_artifact(context); - // The proactive compiler owns freshness while stale HTTP artifacts may be - // served. Keep source-graph scans off that request path; CLI/non-stale - // execution still checks synchronously and cold artifacts still compile. - bool stale = !can_serve_stale && artifact_exists && compiler_unit_needs_recompile(context, resolved, 0); - if(!artifact_exists || stale) + String method = context ? to_upper(trim(context->params["REQUEST_METHOD"])) : String(""); + bool read_request = method == "GET" || method == "HEAD" || method == "OPTIONS"; + bool stale = false; + if(artifact_exists) + { + auto now = std::chrono::steady_clock::now(); + auto cached_freshness = worker.component_freshness.find(resolved); + // HTTP reads may serve a complete stale artifact, so bound their graph + // stat work; CLI and mutations always check the current graph. + bool check_freshness = !can_serve_stale || !read_request || + cached_freshness == worker.component_freshness.end() || + std::chrono::duration_cast( + now - cached_freshness->second.checked_at).count() >= 1000; + if(check_freshness) + { + stale = compiler_unit_needs_recompile(context, resolved, 0); + worker.component_freshness[resolved] = { now, stale }; + } + else + stale = cached_freshness->second.stale; + } + if(stale && can_serve_stale) + { + compiler_prioritize_unit(context, resolved); + if(!read_request) + { + stale_component_mutation_blocked = true; + stale_component_mutation_status = context->params["GATEWAY_INTERFACE"] != "" ? + "Status: 503 Service Unavailable" : "HTTP/1.1 503 Service Unavailable"; + component_artifact_total_us += (u64)std::chrono::duration_cast( + std::chrono::steady_clock::now() - artifact_start).count(); + record_probe(); + return(0); + } + } + if(!artifact_exists || (stale && !can_serve_stale)) get_shared_unit(context, resolved); component_artifact_total_us += (u64)std::chrono::duration_cast( std::chrono::steady_clock::now() - artifact_start).count();