diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 44617a06e..3bf7ef565 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -4657,6 +4657,7 @@ struct v8_dom_runtime::implementation final { this.credentials = String(options.credentials ?? input?.credentials ?? 'same-origin'); this.mode = String(options.mode ?? input?.mode ?? 'cors'); this.redirect = String(options.redirect ?? input?.redirect ?? 'follow'); + this.destination = String(options.destination ?? input?.destination ?? ''); } } @@ -6343,7 +6344,7 @@ bool v8_dom_runtime::has_pending_tasks() const noexcept || !impl_->pending_dialog_close_events.empty() || !impl_->pending_programmatic_scroll_events.empty() || !impl_->pending_frame_hydrations.empty() - || !impl_->connected_resources.empty() + || impl_->has_ready_connected_resource_task() || impl_->resize_observers_pending || impl_->has_due_timer(); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index b98048eb3..76757a4ca 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -1930,7 +1930,8 @@ connected_resources.push_back(connected_resource_task{ &node, v8::Global(isolate, resource_context), - v8::Global(isolate, wrapper)}); + v8::Global(isolate, wrapper), + {}, {}, {}, false}); } void enqueue_connected_resources_subtree( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc index 60d366484..86ddb482f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc @@ -914,17 +914,15 @@ void prefetch_connected_resources() { - if (!load_resource_callback || connected_resources.empty()) return; + if (connected_resources.empty()) return; for (auto& task : connected_resources) { - { - std::lock_guard lock(resource_prefetch_mutex); - if (prefetched_resources.size() >= maximum_resource_prefetches) return; - } if (task.node == nullptr) continue; auto task_context = task.context.Get(isolate); if (task_context.IsEmpty()) continue; activate_css_cascade(task_context); v8::Context::Scope task_context_scope(task_context); + if (begin_connected_resource_interception(task)) continue; + if (!load_resource_callback) continue; const auto& base = current_base_address(); if (task.node->tag == "link") { const auto href = task.node->attributes.find("href"); @@ -984,7 +982,99 @@ return resource_root / relative; } - bool load_connected_resource(dom_node& node, v8::Local wrapper) + static uint32_t connected_resource_kind(const dom_node& node) + { + return node.tag == "script" ? WEBSCENE_RESOURCE_SCRIPT + : node.tag == "link" ? WEBSCENE_RESOURCE_STYLESHEET + : node.tag == "img" ? WEBSCENE_RESOURCE_IMAGE + : WEBSCENE_RESOURCE_DATA; + } + + static uint32_t connected_resource_destination(const dom_node& node) + { + return node.tag == "script" ? WEBSCENE_REQUEST_DESTINATION_SCRIPT + : node.tag == "link" ? WEBSCENE_REQUEST_DESTINATION_STYLE + : node.tag == "img" ? WEBSCENE_REQUEST_DESTINATION_IMAGE + : WEBSCENE_REQUEST_DESTINATION_NONE; + } + + bool begin_connected_resource_interception(connected_resource_task& task) + { + if (task.interception_started) return task.controlled_fetch.valid(); + task.interception_started = true; + if (task.node == nullptr) return false; + const auto attribute_name = task.node->tag == "link" ? "href" : "src"; + const auto authored = task.node->attributes.find(attribute_name); + if (authored == task.node->attributes.end() || authored->second.empty() + || authored->second.starts_with("data:") + || authored->second.starts_with("blob:") + || object_urls.contains(authored->second)) { + return false; + } + const auto& base = current_base_address(); + task.controlled_url = resolve_resource_url(authored->second, base); + resource_request_context request_context{ + WEBSCENE_RESOURCE_INITIATOR_SUBRESOURCE, + current_security_origin(), + base, + WEBSCENE_FETCH_MODE_NONE, + connected_resource_destination(*task.node), + "GET", {}, {}, WEBSCENE_FETCH_CREDENTIALS_INCLUDE, {}}; + request_context.cookie = cookie_header_for( + task.controlled_url, request_context); + task.controlled_fetch = enqueue_controlled_service_worker_fetch( + task.controlled_url, request_context); + if (task.controlled_fetch.valid()) { + task.controlled_deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(30); + } + return task.controlled_fetch.valid(); + } + + bool has_ready_connected_resource_task() const + { + if (connected_resources.empty()) return false; + const auto now = std::chrono::steady_clock::now(); + const auto& task = connected_resources.front(); + return !task.interception_started || !task.controlled_fetch.valid() + || task.controlled_fetch.wait_for(std::chrono::seconds(0)) + == std::future_status::ready + || (task.controlled_deadline != std::chrono::steady_clock::time_point{} + && now >= task.controlled_deadline); + } + + bool load_connected_text_resource( + const std::string& value, + const std::string& base, + uint32_t kind, + std::string& content, + std::string& resolved_name, + std::shared_ptr* immutable_content, + const service_worker_fetch_result* controlled) + { + if (controlled == nullptr || !controlled->handled) { + return load_text_resource( + value, base, kind, content, resolved_name, immutable_content); + } + if (!controlled->error.empty() || controlled->status < 200U + || controlled->status >= 300U) { + return false; + } + if (immutable_content != nullptr) immutable_content->reset(); + content = controlled->body; + resolved_name = controlled->url.empty() + ? resolve_resource_url(value, base) + : controlled->url; + record_feature( + "resource", "service-worker:intercepted-" + resource_kind_name(kind), + "supported", {}, "service-worker-fetch"); + return true; + } + + bool load_connected_resource( + dom_node& node, + v8::Local wrapper, + const service_worker_fetch_result* controlled = nullptr) { auto local_context = isolate->GetCurrentContext(); const auto dispatch_resource_event = [&](const char* type) { @@ -1033,12 +1123,14 @@ ? (stylesheet = object_url->second, stylesheet_name = href_iterator->second, true) - : load_text_resource( + : load_connected_text_resource( href_iterator->second, base, WEBSCENE_RESOURCE_STYLESHEET, stylesheet, - stylesheet_name); + stylesheet_name, + nullptr, + controlled); if (loaded) { const auto first_new_rule = add_stylesheet( std::move(stylesheet), @@ -1084,7 +1176,9 @@ } else { frame_last_error_value = "Unable to load connected stylesheet: " + href_iterator->second; ++frame_script_error_count; - return false; + const auto dispatched = dispatch_resource_event("error"); + if (dispatched) frame_last_error_value.clear(); + return dispatched; } } } else if (node.tag == "script") { @@ -1094,16 +1188,19 @@ std::shared_ptr immutable_source; std::string script_name; const auto& base = current_base_address(); - if (!load_text_resource( + if (!load_connected_text_resource( source_iterator->second, base, WEBSCENE_RESOURCE_SCRIPT, source, script_name, - &immutable_source)) { + &immutable_source, + controlled)) { frame_last_error_value = "Unable to load connected script: " + source_iterator->second; ++frame_script_error_count; - return false; + const auto dispatched = dispatch_resource_event("error"); + if (dispatched) frame_last_error_value.clear(); + return dispatched; } std::string error; // A dynamically inserted external script and its load event are one @@ -1119,7 +1216,7 @@ : execute_in_context(local_context,script_source,script_name,error,false,std::move(immutable_source)))) { frame_last_error_value = "Connected script failed: " + error; ++frame_script_error_count; - return false; + return dispatch_resource_event("error"); } loaded_resource_names.push_back(resource_leaf_name(script_name)); ++frame_script_execution_count; @@ -1191,12 +1288,14 @@ true) : decode_base64_data_url(source->second, markup) ? (resolved_source = source->second, true) - : load_text_resource( + : load_connected_text_resource( source->second, base, WEBSCENE_RESOURCE_IMAGE, markup, - resolved_source); + resolved_source, + nullptr, + controlled); if (!loaded) { return mark_broken_image(); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_service_workers.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_service_workers.inc index e6bcf4038..c677fea27 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_service_workers.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_service_workers.inc @@ -11,25 +11,21 @@ static constexpr size_t maximum_service_worker_fetch_body_bytes = 64U * 1024U * 1024U; - struct service_worker_fetch_result final { - bool handled{}; - uint32_t status{200U}; - std::string status_text{"OK"}; - std::string url; - std::string response_type{"basic"}; - bool redirected{}; - std::vector> headers; - std::string body; - std::string error; - }; - struct service_worker_fetch_request final { std::string url; std::string method{"GET"}; + uint32_t destination{WEBSCENE_REQUEST_DESTINATION_NONE}; std::vector> headers; std::string client_id; uint64_t client_generation{}; std::promise promise; + std::function notify_client; + + void settle(service_worker_fetch_result result) + { + promise.set_value(std::move(result)); + if (notify_client) notify_client(); + } }; static void set_service_worker_client_properties( @@ -887,7 +883,7 @@ .ToLocal(&request_constructor_value) || !request_constructor_value->IsFunction()) { failure.error = "Service worker fetch dispatcher is unavailable"; - fetch_request->promise.set_value(std::move(failure)); + fetch_request->settle(std::move(failure)); std::lock_guard lock(state->mutex); if (state->active_fetches > 0U) --state->active_fetches; } else { @@ -896,6 +892,21 @@ realm, js_string(child->isolate, "method"), js_dom_string(child->isolate, fetch_request->method)).Check(); + const auto destination = fetch_request->destination + == WEBSCENE_REQUEST_DESTINATION_SCRIPT ? "script" + : fetch_request->destination + == WEBSCENE_REQUEST_DESTINATION_STYLE ? "style" + : fetch_request->destination + == WEBSCENE_REQUEST_DESTINATION_IMAGE ? "image" + : fetch_request->destination + == WEBSCENE_REQUEST_DESTINATION_DOCUMENT ? "document" + : fetch_request->destination + == WEBSCENE_REQUEST_DESTINATION_FONT ? "font" + : ""; + options->Set( + realm, + js_string(child->isolate, "destination"), + js_string(child->isolate, destination)).Check(); auto headers = v8::Array::New( child->isolate, static_cast(fetch_request->headers.size())); @@ -920,7 +931,7 @@ if (!request_constructor_value.As()->NewInstance( realm, 2, request_arguments).ToLocal(&request_value)) { failure.error = child->describe_reported_exception(caught, realm); - fetch_request->promise.set_value(std::move(failure)); + fetch_request->settle(std::move(failure)); std::lock_guard lock(state->mutex); if (state->active_fetches > 0U) --state->active_fetches; } else { @@ -946,7 +957,7 @@ || !dispatched->IsPromise()) { failure.error = child->describe_reported_exception( caught, realm); - fetch_request->promise.set_value(std::move(failure)); + fetch_request->settle(std::move(failure)); std::lock_guard lock(state->mutex); if (state->active_fetches > 0U) --state->active_fetches; } else { @@ -1053,6 +1064,27 @@ for (auto iterator = pending_fetches.begin(); iterator != pending_fetches.end();) { auto promise = iterator->promise.Get(child->isolate); + auto client_retired = false; + { + std::lock_guard lock(state->mutex); + const auto client = state->clients.find( + iterator->request->client_id); + client_retired = client == state->clients.end() + || client->second.generation + != iterator->request->client_generation; + if (client_retired && state->active_fetches > 0U) { + --state->active_fetches; + } + } + if (client_retired) { + service_worker_fetch_result retired; + retired.handled = true; + retired.error = "Service worker fetch client was retired"; + iterator->request->settle(std::move(retired)); + iterator->promise.Reset(); + iterator = pending_fetches.erase(iterator); + continue; + } if (promise->State() == v8::Promise::kPending) { ++iterator; continue; @@ -1123,17 +1155,9 @@ } { std::lock_guard lock(state->mutex); - const auto client = state->clients.find( - iterator->request->client_id); - if (client == state->clients.end() - || client->second.generation - != iterator->request->client_generation) { - result.handled = true; - result.error = "Service worker fetch client was retired"; - } if (state->active_fetches > 0U) --state->active_fetches; } - iterator->request->promise.set_value(std::move(result)); + iterator->request->settle(std::move(result)); iterator->promise.Reset(); iterator = pending_fetches.erase(iterator); } @@ -1653,6 +1677,7 @@ request->url = url; request->method = request_context.method.empty() ? "GET" : request_context.method; + request->destination = request_context.destination; if (!request_context.content_type.empty()) { request->headers.emplace_back( "content-type", request_context.content_type); @@ -1662,6 +1687,7 @@ } request->client_id = client->descriptor.id; request->client_generation = client->descriptor.generation; + request->notify_client = runtime_work_available; auto future = request->promise.get_future().share(); { std::lock_guard lock(runtime->mutex); @@ -1672,14 +1698,14 @@ service_worker_fetch_result result; result.handled = true; result.error = "Service worker fetch client was retired"; - request->promise.set_value(std::move(result)); + request->settle(std::move(result)); return future; } if (runtime->active_fetches >= maximum_service_worker_fetches) { service_worker_fetch_result result; result.handled = true; result.error = "Service worker fetch queue capacity exhausted"; - request->promise.set_value(std::move(result)); + request->settle(std::move(result)); return future; } ++runtime->active_fetches; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc index 7cf886a5a..1aa2ce053 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc @@ -235,10 +235,26 @@ failed }; + struct service_worker_fetch_result final { + bool handled{}; + uint32_t status{200U}; + std::string status_text{"OK"}; + std::string url; + std::string response_type{"basic"}; + bool redirected{}; + std::vector> headers; + std::string body; + std::string error; + }; + struct connected_resource_task final { dom_node* node; v8::Global context; v8::Global wrapper; + std::shared_future controlled_fetch; + std::string controlled_url; + std::chrono::steady_clock::time_point controlled_deadline{}; + bool interception_started{}; }; enum class resize_observer_box { content, border, device_pixel_content }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 65d51f4ed..f060f2a6f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -279,6 +279,33 @@ if (connected_resources.empty()) return true; prefetch_connected_resources(); prefer_timer_task = true; + auto& pending = connected_resources.front(); + auto pending_context = pending.context.Get(isolate); + if (pending.node == nullptr || pending_context.IsEmpty()) { + connected_resources.erase(connected_resources.begin()); + return true; + } + activate_css_cascade(pending_context); + v8::Context::Scope pending_scope(pending_context); + if (!pending.interception_started) { + const auto intercepted = begin_connected_resource_interception(pending); + if (!intercepted) { + const auto attribute_name = pending.node->tag == "link" ? "href" : "src"; + if (const auto authored = pending.node->attributes.find(attribute_name); + authored != pending.node->attributes.end() && !authored->second.empty()) { + start_resource_prefetch( + authored->second, + current_base_address(), + connected_resource_kind(*pending.node)); + } + } + } + if (pending.controlled_fetch.valid() + && pending.controlled_fetch.wait_for(std::chrono::seconds(0)) + != std::future_status::ready + && std::chrono::steady_clock::now() < pending.controlled_deadline) { + return true; + } #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) if (profile_startup) ++startup_connected_resources; #endif @@ -321,7 +348,28 @@ resource_name = resource_leaf_name(attribute->second); } } - if (!load_connected_resource(*resource.node, wrapper)) { + std::optional controlled; + if (resource.controlled_fetch.valid()) { + if (resource.controlled_fetch.wait_for(std::chrono::seconds(0)) + == std::future_status::ready) { + try { + controlled = resource.controlled_fetch.get(); + } catch (const std::exception& error) { + controlled.emplace(); + controlled->handled = true; + controlled->error = std::string( + "Service worker connected resource failed: ") + error.what(); + } + } else { + controlled.emplace(); + controlled->handled = true; + controlled->error = "Service worker connected resource timed out"; + } + } + if (!load_connected_resource( + *resource.node, + wrapper, + controlled.has_value() ? &*controlled : nullptr)) { last_error = frame_last_error_value; return false; } @@ -630,8 +678,9 @@ #else constexpr auto cooperative_frames = true; #endif + const auto connected_resource_ready = has_ready_connected_resource_task(); const auto competing_task_ready = timer_due - || !connected_resources.empty() + || connected_resource_ready || resize_observers_pending; if (!pending_frame_hydrations.empty() && (!cooperative_frames @@ -643,7 +692,7 @@ // Dynamically connected resources and zero-delay timers are separate // browser task sources. Do not starve React/datafeed timers behind an // entire Webpack resource wave; alternate when both are ready. - if (!connected_resources.empty() + if (connected_resource_ready && (!timer_due || !prefer_timer_task)) { // Preparing a large warm-cache resource wave can itself take a few // milliseconds. Only do that work once this task source has won diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_stream_fetch_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_stream_fetch_tests.inc index 2f4dd315a..e20378bab 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_stream_fetch_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_stream_fetch_tests.inc @@ -409,3 +409,215 @@ void test_cache_storage_and_controlled_fetch_broker() << " rssBefore=" << rss_before << " rssAfter=" << rss_after << '\n'; webscene_engine_destroy(engine); } + +void test_controlled_connected_resource_interception() +{ + resource_server server{.content = { + {"https://connected-resource.test/index.html", + "connected resource interception" + "
controlled
"}, + {"https://connected-resource.test/next.html", + "retired connected resource"}, + {"https://connected-resource.test/service-worker.js", R"JS( + const destinations = {}; + self.addEventListener('install', event => event.waitUntil(skipWaiting())); + self.addEventListener('activate', event => event.waitUntil(clients.claim())); + self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + destinations[url.pathname] = event.request.destination; + if (url.pathname === '/fallback.js') return; + if (url.pathname === '/denied.js') { + event.respondWith(Promise.reject(new Error('denied by controller'))); + return; + } + if (url.pathname === '/pending.svg') { + event.respondWith(new Promise(() => {})); + return; + } + if (url.pathname === '/controlled.js' || url.pathname === '/cycle.js') { + event.respondWith(new Response( + 'globalThis.__controlledScript=(globalThis.__controlledScript||0)+1;', + {headers:{'content-type':'text/javascript'}})); + return; + } + if (url.pathname === '/controlled.css' || url.pathname === '/cycle.css') { + event.respondWith(new Response('#styled{color:rgb(12, 34, 56)}', + {headers:{'content-type':'text/css'}})); + return; + } + if (url.pathname === '/controlled.svg' || url.pathname === '/cycle.svg') { + event.respondWith(new Response( + '', + {headers:{'content-type':'image/svg+xml'}})); + } + }); + self.addEventListener('message', event => { + if (event.data === 'destinations') event.source.postMessage(destinations); + }); + )JS"}, + {"https://connected-resource.test/fallback.js", + "globalThis.__hostFallback=(globalThis.__hostFallback||0)+1;"}, + {"https://connected-resource.test/denied.js", + "globalThis.__deniedBypass=true;"} + }}; + webscene_engine_options options{}; + options.struct_size = sizeof(options); + options.resource_load_callback = load_test_resource; + options.resource_load_user_data = &server; + auto* engine = webscene_engine_create_with_options(&options); + require(engine != nullptr, "connected resource engine creation failed"); + constexpr std::string_view address{"https://connected-resource.test/index.html"}; + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "connected resource navigation failed"); + require(evaluate_until_equals(engine, "location.href", "connected-nav.js", + R"("https://connected-resource.test/index.html")", 2'500) + == R"("https://connected-resource.test/index.html")", + "connected resource document did not become ready"); + + webscene_engine_memory_metrics memory_before{sizeof(webscene_engine_memory_metrics)}; + require(webscene_engine_get_memory_metrics(engine, &memory_before) != 0, + "connected resource memory baseline unavailable"); + const auto rss_before = service_worker_test_peak_rss_bytes(); + execute(engine, R"JS( + globalThis.__connectedGate = {complete:false,error:null,phase:'register',durations:[],destinations:null}; + (async () => { + try { + const changed = new Promise(resolve => navigator.serviceWorker.addEventListener( + 'controllerchange', resolve, {once:true})); + await navigator.serviceWorker.register('service-worker.js', {type:'module'}); + if (!navigator.serviceWorker.controller) await changed; + __connectedGate.phase = 'controlled-script'; + const load = (element, expectError = false) => new Promise((resolve, reject) => { + element.onload = () => expectError + ? reject(new Error('resource unexpectedly loaded')) : resolve('load'); + element.onerror = () => expectError + ? resolve('error') : reject(new Error('resource failed')); + document.body.append(element); + }); + const script = document.createElement('script'); + script.src = 'controlled.js'; + await load(script); script.remove(); + if (__controlledScript !== 1) throw new Error('controlled script body not executed'); + + __connectedGate.phase = 'controlled-style'; + const link = document.createElement('link'); + link.rel = 'stylesheet'; link.href = 'controlled.css'; + await load(link); + if (getComputedStyle(document.getElementById('styled')).color !== 'rgb(12, 34, 56)') { + throw new Error('controlled stylesheet body not applied'); + } + link.remove(); + + __connectedGate.phase = 'controlled-image'; + const image = document.createElement('img'); + image.src = 'controlled.svg'; + await load(image); + if (image.naturalWidth !== 4 || image.naturalHeight !== 3) { + throw new Error(`controlled image dimensions ${image.naturalWidth}x${image.naturalHeight}`); + } + image.remove(); + + __connectedGate.phase = 'fallback'; + const fallback = document.createElement('script'); + fallback.src = 'fallback.js'; + await load(fallback); fallback.remove(); + if (__hostFallback !== 1) throw new Error('unhandled request did not fall through'); + + __connectedGate.phase = 'denied'; + const denied = document.createElement('script'); + denied.src = 'denied.js'; + await load(denied, true); denied.remove(); + if (globalThis.__deniedBypass === true) { + throw new Error('handled rejection bypassed controller admission'); + } + + __connectedGate.phase = 'cycles'; + for (let cycle = 0; cycle < 100; ++cycle) { + const started = performance.now(); + const kind = cycle % 3; + const element = document.createElement(kind === 0 ? 'script' : kind === 1 ? 'link' : 'img'); + if (kind === 0) element.src = `cycle.js?cycle=${cycle}`; + else if (kind === 1) { + element.rel = 'stylesheet'; element.href = `cycle.css?cycle=${cycle}`; + } else element.src = `cycle.svg?cycle=${cycle}`; + await load(element); + element.remove(); + __connectedGate.durations.push(performance.now() - started); + } + __connectedGate.destinations = await new Promise(resolve => { + const listener = event => { + navigator.serviceWorker.removeEventListener('message', listener); + resolve(event.data); + }; + navigator.serviceWorker.addEventListener('message', listener); + navigator.serviceWorker.controller.postMessage('destinations'); + }); + if (__connectedGate.destinations['/controlled.js'] !== 'script' + || __connectedGate.destinations['/controlled.css'] !== 'style' + || __connectedGate.destinations['/controlled.svg'] !== 'image') { + throw new Error(`resource destinations ${JSON.stringify(__connectedGate.destinations)}`); + } + __connectedGate.phase = 'done'; + } catch (error) { + __connectedGate.error = String(error && error.stack || error); + } finally { __connectedGate.complete = true; } + })(); + )JS", "connected-resource-gate.js"); + const auto completed = evaluate_until_equals(engine, "__connectedGate.complete", + "connected-complete.js", "true", 5'000); + require(completed == "true", + "controlled connected resources did not complete at phase " + + evaluate(engine, "__connectedGate.phase", "connected-phase.js")); + require(evaluate(engine, "__connectedGate.error", "connected-error.js") == "null", + "connected resource gate failed: " + + evaluate(engine, "__connectedGate.error", "connected-error-detail.js")); + const auto p95 = std::stod(evaluate(engine, R"JS((() => { + const values = [...__connectedGate.durations].sort((a,b) => a-b); + return values[Math.ceil(values.length * .95) - 1]; + })())JS", "connected-p95.js")); + require(p95 <= 100.0, + "connected resource 100-cycle p95 exceeded 100 ms: " + std::to_string(p95)); + require(webscene_engine_request_low_memory(engine) != 0, + "connected resource low-memory request rejected"); + webscene_engine_memory_metrics memory_after{sizeof(webscene_engine_memory_metrics)}; + for (auto attempt = 0; attempt < 250; ++attempt) { + require(webscene_engine_get_memory_metrics(engine, &memory_after) != 0, + "connected resource final memory metrics unavailable"); + if (memory_after.low_memory_notifications > memory_before.low_memory_notifications) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(memory_after.low_memory_notifications > memory_before.low_memory_notifications, + "connected resource low-memory collection did not complete"); + require(memory_after.v8_used_heap_bytes + <= memory_before.v8_used_heap_bytes + 8U * 1024U * 1024U, + "connected resource cycles retained more than 8 MiB V8 heap"); + const auto rss_after = service_worker_test_peak_rss_bytes(); + require(rss_after <= rss_before + 64U * 1024U * 1024U, + "connected resource cycles exceeded 64 MiB RSS growth"); + execute(engine, R"JS((() => { + const image = document.createElement('img'); + image.src = 'pending.svg'; + image.onload = () => { globalThis.__retiredResourceLoaded = true; }; + document.body.append(image); + })())JS", "connected-pending-navigation.js"); + const auto navigation_started = std::chrono::steady_clock::now(); + constexpr std::string_view next_address{"https://connected-resource.test/next.html"}; + require(webscene_engine_load_url( + engine, next_address.data(), next_address.size()) != 0U, + "navigation did not retire the pending connected resource"); + require(evaluate_until_equals(engine, "location.href", "connected-retired-nav.js", + R"("https://connected-resource.test/next.html")", 2'500) + == R"("https://connected-resource.test/next.html")", + "navigation after pending connected resource did not complete"); + const auto navigation_ms = std::chrono::duration( + std::chrono::steady_clock::now() - navigation_started).count(); + require(navigation_ms <= 500.0, + "pending connected resource retirement exceeded 500 ms"); + std::cout << "Connected resource gate: cycles=100 p95=" << p95 + << "ms heapBefore=" << memory_before.v8_used_heap_bytes + << " heapAfter=" << memory_after.v8_used_heap_bytes + << " rssBefore=" << rss_before + << " rssAfter=" << rss_after + << " retireNavigation=" << navigation_ms << "ms\n"; + webscene_engine_destroy(engine); +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index b9e5fff46..dee6fe8e0 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -192,6 +192,10 @@ int main() test_cache_storage_and_controlled_fetch_broker(); return 0; } + if (selected == "service-worker-connected-resources") { + test_controlled_connected_resource_interception(); + return 0; + } if (selected == "rounded-icon-surfaces") { auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, "rounded icon surface engine creation failed"); diff --git a/tests/WebPlatformSubset/contracts/controlled-connected-denied.js b/tests/WebPlatformSubset/contracts/controlled-connected-denied.js new file mode 100644 index 000000000..1a57cd694 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/controlled-connected-denied.js @@ -0,0 +1 @@ +globalThis.__connectedDeniedBypass = true; diff --git a/tests/WebPlatformSubset/contracts/controlled-connected-fallback.js b/tests/WebPlatformSubset/contracts/controlled-connected-fallback.js new file mode 100644 index 000000000..3f507324c --- /dev/null +++ b/tests/WebPlatformSubset/contracts/controlled-connected-fallback.js @@ -0,0 +1 @@ +globalThis.__connectedHostFallback = (globalThis.__connectedHostFallback || 0) + 1; diff --git a/tests/WebPlatformSubset/contracts/controlled-connected-resources-worker.js b/tests/WebPlatformSubset/contracts/controlled-connected-resources-worker.js new file mode 100644 index 000000000..55fa93cbe --- /dev/null +++ b/tests/WebPlatformSubset/contracts/controlled-connected-resources-worker.js @@ -0,0 +1,28 @@ +const destinations = {}; + +self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + destinations[url.pathname] = event.request.destination; + if (url.pathname.endsWith('/controlled-connected-fallback.js')) return; + if (url.pathname.endsWith('/controlled-connected-denied.js')) { + event.respondWith(Promise.reject(new Error('controller denied resource'))); + return; + } + if (url.pathname.endsWith('/connected-script.js')) { + event.respondWith(new Response( + 'globalThis.__connectedScript=(globalThis.__connectedScript||0)+1;', + {headers:{'content-type':'text/javascript'}})); + } else if (url.pathname.endsWith('/connected-style.css')) { + event.respondWith(new Response('#styled{color:rgb(12, 34, 56)}', + {headers:{'content-type':'text/css'}})); + } else if (url.pathname.endsWith('/connected-image.svg')) { + event.respondWith(new Response( + '', + {headers:{'content-type':'image/svg+xml'}})); + } +}); +self.addEventListener('message', event => { + if (event.data === 'destinations') event.source.postMessage(destinations); +}); diff --git a/tests/WebPlatformSubset/contracts/controlled-connected-resources.html b/tests/WebPlatformSubset/contracts/controlled-connected-resources.html new file mode 100644 index 000000000..ff59e81bf --- /dev/null +++ b/tests/WebPlatformSubset/contracts/controlled-connected-resources.html @@ -0,0 +1,80 @@ + + +Controlled connected resource interception +
controlled
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index cd0977805..6410e3935 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2412,6 +2412,14 @@ "evidence": ["streams-transform-stream-derived", "chromium-153-direct-neutral-contract", "vscode-oss-safari-resource-stream-shape"], "reason": "Candidate third resource-plane stack for WebScene #266. It adds WritableStream/default-writer and TransformStream byte delivery to the lower CacheStorage/request-broker stack and covers the unchanged Code OSS chunk fallback sequence: create a transform, retain its writer, resolve the response with its readable side, write ordered byte chunks, close or abort, and observe writer.closed teardown. The cumulative native gate covers 100 controlled cached requests, exact byte order, browser-shaped brands/descriptors, p95 <= 250 ms, <= 8 MiB retained V8 heap, and <= 64 MiB peak-RSS growth. Interception of parser/image/style loads and unchanged Markdown product acceptance remain later #266 slices.", "nativeNavigation": true + }, + { + "path": "contracts/controlled-connected-resources.html", + "type": "contract", + "capabilities": ["service-worker-connected-script", "service-worker-connected-style", "service-worker-connected-image"], + "evidence": ["wpt-service-workers-fetch-event", "chromium-direct-neutral-contract", "vscode-oss-webview-resource-broker"], + "reason": "Candidate connected-resource slice for WebScene #266/#308. It proves asynchronous controller-first routing for dynamically connected script, stylesheet, and image requests, Request destination identity, unhandled host fallback, and fail-closed handled rejection. Native companion coverage adds client-task nonblocking behavior, 100 script/style/image cycles, p95 <= 100 ms, <= 8 MiB retained V8 heap, and <= 64 MiB peak-RSS growth. Parser-blocking initial document resources, CSS url() images, nested frame hydration, range/cache validators, and exact unchanged Markdown remain cumulative work.", + "nativeNavigation": true } ], "harnessBlocked": [],