diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index e11eb7b24..d6c112192 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -1422,6 +1422,8 @@ struct v8_dom_runtime::implementation final { pending.context.Reset(info.GetIsolate(), local_context); pending.resolver.Reset(info.GetIsolate(), resolver); const auto notify = self->runtime_work_available; + auto controlled_fetch = self->enqueue_controlled_service_worker_fetch( + resolved, request_context); pending.future = std::async( std::launch::async, [self, @@ -1430,13 +1432,50 @@ struct v8_dom_runtime::implementation final { method, redirect, request_context = std::move(request_context), + controlled_fetch = std::move(controlled_fetch), notify]() mutable { async_fetch_result result; result.resolved_url = resolved; try { resource_response response; auto loaded = false; - if (self->load_resource_callback) { + auto handled_by_service_worker = false; + if (controlled_fetch.valid()) { + if (controlled_fetch.wait_for(std::chrono::seconds(30)) + != std::future_status::ready) { + handled_by_service_worker = true; + result.error = "Service worker fetch timed out"; + } else { + auto controlled = controlled_fetch.get(); + handled_by_service_worker = controlled.handled; + if (controlled.handled) { + if (!controlled.error.empty()) { + result.error = std::move(controlled.error); + } else { + result.loaded = true; + result.status = controlled.status; + result.status_text = std::move( + controlled.status_text); + if (!controlled.url.empty()) { + result.resolved_url = std::move(controlled.url); + } + result.redirected = controlled.redirected; + result.response_type = std::move( + controlled.response_type); + for (auto& header : controlled.headers) { + if (header.first != "set-cookie" + && header.first != "set-cookie2") { + result.headers.push_back(std::move(header)); + } + } + if (method != "HEAD") { + result.body = std::move(controlled.body); + } + } + } + } + } + if (!handled_by_service_worker && self->load_resource_callback) { if (method == "GET" || method == "HEAD") { auto shared = self->load_resource_single_flight( WEBSCENE_RESOURCE_DATA, @@ -1455,7 +1494,7 @@ struct v8_dom_runtime::implementation final { 0, response); } - } else { + } else if (!handled_by_service_worker) { auto path = self->resolve_resource_path(specifier); std::ifstream stream(path, std::ios::binary); if (stream) { @@ -1466,8 +1505,8 @@ struct v8_dom_runtime::implementation final { loaded = true; } } - result.loaded = loaded; - if (loaded) { + if (!handled_by_service_worker) result.loaded = loaded; + if (!handled_by_service_worker && loaded) { self->accept_response_cookies( result.resolved_url, request_context, response); result.status = response.status; @@ -1543,9 +1582,10 @@ struct v8_dom_runtime::implementation final { }); } } - if (result.loaded && method != "HEAD") { + if (!handled_by_service_worker && result.loaded && method != "HEAD") { result.body = std::move(response.content); - } else if (!result.loaded && result.error.empty()) { + } else if (!handled_by_service_worker + && !result.loaded && result.error.empty()) { result.error = "Unable to fetch WebScene resource: " + specifier; } } catch (const std::exception& error) { 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 5746169c0..e6bcf4038 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 @@ -7,6 +7,30 @@ static constexpr size_t maximum_service_worker_queue_messages = 256U; static constexpr size_t maximum_service_worker_queue_bytes = 16U * 1024U * 1024U; + static constexpr size_t maximum_service_worker_fetches = 32U; + 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"}; + std::vector> headers; + std::string client_id; + uint64_t client_generation{}; + std::promise promise; + }; static void set_service_worker_client_properties( v8::Isolate* isolate, @@ -319,7 +343,8 @@ state.get(), v8::kExternalPointerTypeTagDefault)) .ToLocalChecked()).Check(); - constexpr std::string_view bootstrap = R"JS( + constexpr std::string_view bootstrap_parts[] = { + R"JS( (() => { function WorkerGlobalScope() { throw new TypeError('Illegal constructor'); @@ -462,6 +487,174 @@ await Promise.all(state.pending); return response; }; + const dispatchFetchRecord = async init => { + const response = await dispatchFetch(init); + if (response === undefined) return {handled:false}; + const chunks = []; + let bodyLength = 0; + if (response.body !== null) { + const reader = response.body.getReader(); + for (;;) { + const {value, done} = await reader.read(); + if (done) break; + const bytes = value instanceof Uint8Array + ? value : new Uint8Array(value); + bodyLength += bytes.byteLength; + if (bodyLength > 64 * 1024 * 1024) { + await reader.cancel('Service worker response body limit'); + throw new DOMException( + 'Service worker response body exceeds 64 MiB', + 'QuotaExceededError'); + } + chunks.push(bytes); + } + } + const body = new Uint8Array(bodyLength); + let bodyOffset = 0; + for (const chunk of chunks) { + body.set(chunk, bodyOffset); + bodyOffset += chunk.byteLength; + } + return { + handled:true, + status:response.status, + statusText:response.statusText, + headers:[...response.headers], + body, + url:response.url, + redirected:response.redirected, + type:response.type + }; + }; + const cacheState = new WeakMap(); + const cacheStorageState = new WeakMap(); + const cacheKey = request => { + const value = request instanceof Request + ? request : new Request(request); + return `${value.method} ${value.url}`; + }; + const requireCache = value => { + const state = cacheState.get(value); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + const requireCacheStorage = value => { + const state = cacheStorageState.get(value); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + class Cache { + constructor() { throw new TypeError('Illegal constructor'); } + match(request) { + const state = requireCache(this); + const stored = state.entries.get(cacheKey(request)); + return Promise.resolve(stored?.response.clone()); + } + async put(request, response) { + const state = requireCache(this); + const key = cacheKey(request); + if (!key.startsWith('GET ')) { + throw new TypeError('Cache.put requires a GET request'); + } + if (!(response instanceof Response)) { + throw new TypeError('Cache.put requires a Response'); + } + if (response.status === 206 || response.bodyUsed) { + throw new TypeError('Response cannot be cached'); + } + const clone = response.clone(); + const body = new Uint8Array(await clone.arrayBuffer()); + const prior = state.entries.get(key)?.bytes ?? 0; + const nextBytes = state.storage.bytes - prior + body.byteLength; + if (nextBytes > 64 * 1024 * 1024 + || (!state.entries.has(key) && state.entries.size >= 256)) { + throw new DOMException('Cache quota exceeded', 'QuotaExceededError'); + } + state.entries.set(key, {bytes:body.byteLength, + response:new Response(body, { + status:response.status, statusText:response.statusText, + headers:response.headers, url:response.url, +)JS", + R"JS( redirected:response.redirected, type:response.type + })}); + state.storage.bytes = nextBytes; + } + delete(request) { + const state = requireCache(this); + const key = cacheKey(request); + const entry = state.entries.get(key); + if (!entry) return Promise.resolve(false); + state.entries.delete(key); + state.storage.bytes -= entry.bytes; + return Promise.resolve(true); + } + keys() { + const state = requireCache(this); + return Promise.resolve([...state.entries.keys()].map(key => + new Request(key.slice(key.indexOf(' ') + 1), { + method:key.slice(0, key.indexOf(' '))}))); + } + } + class CacheStorage { + constructor() { throw new TypeError('Illegal constructor'); } + open(name) { + const storage = requireCacheStorage(this); + const key = String(name); + let value = storage.caches.get(key); + if (!value) { + if (storage.caches.size >= 32) { + return Promise.reject(new DOMException( + 'CacheStorage quota exceeded', 'QuotaExceededError')); + } + value = Object.create(Cache.prototype); + cacheState.set(value, {entries:new Map(), storage}); + storage.caches.set(key, value); + } + return Promise.resolve(value); + } + has(name) { + return Promise.resolve( + requireCacheStorage(this).caches.has(String(name))); + } + delete(name) { + const storage = requireCacheStorage(this); + const key = String(name); + const value = storage.caches.get(key); + if (!value) return Promise.resolve(false); + for (const entry of requireCache(value).entries.values()) { + storage.bytes -= entry.bytes; + } + storage.caches.delete(key); + return Promise.resolve(true); + } + keys() { + return Promise.resolve( + [...requireCacheStorage(this).caches.keys()]); + } + async match(request) { + const storage = requireCacheStorage(this); + for (const cache of storage.caches.values()) { + const response = await cache.match(request); + if (response) return response; + } + return undefined; + } + } + for (const [prototype, tag, names] of [ + [Cache.prototype, 'Cache', ['match', 'put', 'delete', 'keys']], + [CacheStorage.prototype, 'CacheStorage', + ['open', 'has', 'delete', 'keys', 'match']] + ]) { + Object.defineProperty(prototype, Symbol.toStringTag, + {value:tag, configurable:true}); + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + Object.defineProperty(prototype, name, + {...descriptor, enumerable:true}); + } + } + const caches = Object.create(CacheStorage.prototype); + cacheStorageState.set(caches, {caches:new Map(), bytes:0}); function Client() { throw new TypeError('Illegal constructor'); } function WindowClient() { throw new TypeError('Illegal constructor'); } function Clients() { throw new TypeError('Illegal constructor'); } @@ -556,6 +749,11 @@ FetchEvent: { value:FetchEvent, writable:true, configurable:true }, + Cache: {value:Cache, writable:true, configurable:true}, + CacheStorage: { + value:CacheStorage, writable:true, configurable:true + }, + caches: {value:caches, enumerable:true, configurable:true}, Client: {value:Client, writable:true, configurable:true}, WindowClient: {value:WindowClient, writable:true, configurable:true}, Clients: {value:Clients, writable:true, configurable:true}, @@ -581,10 +779,16 @@ }, __webSceneDispatchServiceWorkerFetch: { value:dispatchFetch, configurable:true + }, + __webSceneDispatchServiceWorkerFetchRecord: { + value:dispatchFetchRecord, configurable:true } }); })(); - )JS"; + )JS", + }; + std::string bootstrap; + for (const auto part : bootstrap_parts) bootstrap.append(part); std::string error; if (!child->execute_in_context( realm, bootstrap, "webscene:service-worker-bootstrap", error)) { @@ -629,12 +833,24 @@ if (!state->stopped) state->activated = true; } state->ready.notify_all(); + struct pending_fetch final { + std::shared_ptr request; + v8::Global promise; + }; + std::vector pending_fetches; for (;;) { std::optional message; + std::shared_ptr fetch_request; { std::unique_lock lock(state->mutex); - state->ready.wait_for(lock, std::chrono::milliseconds(100), [&] { - return state->stopped || !state->incoming.empty(); + state->ready.wait_for( + lock, + pending_fetches.empty() + ? std::chrono::milliseconds(100) + : std::chrono::milliseconds(1), + [&] { + return state->stopped || !state->incoming.empty() + || !state->fetches.empty(); }); if (state->stopped) break; if (!state->incoming.empty()) { @@ -644,11 +860,105 @@ state->incoming_bytes = bytes > state->incoming_bytes ? 0U : state->incoming_bytes - bytes; } + if (!state->fetches.empty()) { + fetch_request = std::move(state->fetches.front()); + state->fetches.pop_front(); + } } v8::Isolate::Scope isolate_scope(child->isolate); v8::HandleScope handles(child->isolate); auto realm = child->context.Get(child->isolate); v8::Context::Scope context_scope(realm); + if (fetch_request != nullptr) { + service_worker_fetch_result failure; + failure.handled = true; + v8::TryCatch caught(child->isolate); + v8::Local helper_value; + v8::Local request_constructor_value; + if (!realm->Global()->Get( + realm, + js_string(child->isolate, + "__webSceneDispatchServiceWorkerFetchRecord")) + .ToLocal(&helper_value) + || !helper_value->IsFunction() + || !realm->Global()->Get( + realm, + js_string(child->isolate, "Request")) + .ToLocal(&request_constructor_value) + || !request_constructor_value->IsFunction()) { + failure.error = "Service worker fetch dispatcher is unavailable"; + fetch_request->promise.set_value(std::move(failure)); + std::lock_guard lock(state->mutex); + if (state->active_fetches > 0U) --state->active_fetches; + } else { + auto options = v8::Object::New(child->isolate); + options->Set( + realm, + js_string(child->isolate, "method"), + js_dom_string(child->isolate, fetch_request->method)).Check(); + auto headers = v8::Array::New( + child->isolate, + static_cast(fetch_request->headers.size())); + for (uint32_t index = 0U; + index < fetch_request->headers.size(); ++index) { + auto pair = v8::Array::New(child->isolate, 2); + pair->Set( + realm, 0, + js_dom_string(child->isolate, + fetch_request->headers[index].first)).Check(); + pair->Set( + realm, 1, + js_dom_string(child->isolate, + fetch_request->headers[index].second)).Check(); + headers->Set(realm, index, pair).Check(); + } + options->Set( + realm, js_string(child->isolate, "headers"), headers).Check(); + v8::Local request_arguments[]{ + js_dom_string(child->isolate, fetch_request->url), options}; + v8::Local request_value; + 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)); + std::lock_guard lock(state->mutex); + if (state->active_fetches > 0U) --state->active_fetches; + } else { + auto init = v8::Object::New(child->isolate); + init->Set( + realm, js_string(child->isolate, "request"), + request_value).Check(); + init->Set( + realm, js_string(child->isolate, "clientId"), + js_dom_string(child->isolate, + fetch_request->client_id)).Check(); + init->Set( + realm, js_string(child->isolate, "resultingClientId"), + js_string(child->isolate, "")).Check(); + init->Set( + realm, js_string(child->isolate, "replacesClientId"), + js_string(child->isolate, "")).Check(); + v8::Local arguments[]{init}; + v8::Local dispatched; + if (!helper_value.As()->Call( + realm, realm->Global(), 1, arguments) + .ToLocal(&dispatched) + || !dispatched->IsPromise()) { + failure.error = child->describe_reported_exception( + caught, realm); + fetch_request->promise.set_value(std::move(failure)); + std::lock_guard lock(state->mutex); + if (state->active_fetches > 0U) --state->active_fetches; + } else { + pending_fetch pending; + pending.request = std::move(fetch_request); + pending.promise.Reset( + child->isolate, dispatched.As()); + pending_fetches.push_back(std::move(pending)); + } + } + } + } if (message.has_value()) { v8::TryCatch caught(child->isolate); std::vector> ports; @@ -740,6 +1050,93 @@ } child->drain_tasks(); child->perform_microtask_checkpoint(); + for (auto iterator = pending_fetches.begin(); + iterator != pending_fetches.end();) { + auto promise = iterator->promise.Get(child->isolate); + if (promise->State() == v8::Promise::kPending) { + ++iterator; + continue; + } + service_worker_fetch_result result; + result.handled = true; + if (promise->State() == v8::Promise::kRejected) { + result.error = to_utf8(child->isolate, promise->Result()); + } else if (!promise->Result()->IsObject()) { + result.error = "Service worker fetch result is invalid"; + } else { + auto object = promise->Result().As(); + const auto property = [&](const char* name) + -> v8::Local { + v8::Local value; + if (!object->Get( + realm, js_string(child->isolate, name)) + .ToLocal(&value)) return v8::Undefined(child->isolate); + return value; + }; + result.handled = property("handled")->BooleanValue( + child->isolate); + if (result.handled) { + result.status = property("status")->Uint32Value( + realm).FromMaybe(0U); + result.status_text = to_utf8( + child->isolate, property("statusText")); + result.url = to_utf8(child->isolate, property("url")); + result.response_type = to_utf8( + child->isolate, property("type")); + result.redirected = property("redirected")->BooleanValue( + child->isolate); + auto body = property("body"); + if (!body->IsArrayBufferView()) { + result.error = "Service worker response body is invalid"; + } else { + auto view = body.As(); + if (view->ByteLength() + > maximum_service_worker_fetch_body_bytes) { + result.error = "Service worker response body exceeds 64 MiB"; + } else { + const auto backing = view->Buffer()->GetBackingStore(); + const auto* bytes = static_cast( + backing->Data()) + view->ByteOffset(); + result.body.assign(bytes, view->ByteLength()); + } + } + auto headers = property("headers"); + if (headers->IsArray()) { + auto values = headers.As(); + for (uint32_t index = 0U; + index < values->Length(); ++index) { + v8::Local entry_value; + if (!values->Get(realm, index).ToLocal(&entry_value) + || !entry_value->IsArray()) continue; + auto entry = entry_value.As(); + v8::Local name; + v8::Local value; + if (entry->Get(realm, 0).ToLocal(&name) + && entry->Get(realm, 1).ToLocal(&value)) { + result.headers.emplace_back( + to_utf8(child->isolate, name), + to_utf8(child->isolate, value)); + } + } + } + } + } + { + 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->promise.Reset(); + iterator = pending_fetches.erase(iterator); + } } { std::lock_guard lock(state->mutex); @@ -1219,6 +1616,81 @@ js_string(isolate, "Unknown service worker control operation"))); } + std::shared_future + enqueue_controlled_service_worker_fetch( + const std::string& url, + const resource_request_context& request_context) + { + if (service_worker_realm || isolate == nullptr || context.IsEmpty()) { + return {}; + } + const auto local_context = isolate->GetCurrentContext(); + service_worker_client_state* client = nullptr; + for (auto& [id, candidate] : service_worker_clients) { + static_cast(id); + if (!candidate.context.IsEmpty() + && candidate.context.Get(isolate) == local_context) { + client = &candidate; + break; + } + } + if (client == nullptr) return {}; + std::shared_ptr runtime; + size_t matched_scope = 0U; + for (auto& [scope, registration] : service_worker_registrations) { + if (registration.runtime == nullptr + || !client->descriptor.url.starts_with(scope) + || scope.size() < matched_scope) continue; + std::lock_guard lock(registration.runtime->mutex); + if (registration.runtime->stopped + || !registration.runtime->activated + || !registration.runtime->claimed) continue; + runtime = registration.runtime; + matched_scope = scope.size(); + } + if (runtime == nullptr) return {}; + auto request = std::make_shared(); + request->url = url; + request->method = request_context.method.empty() + ? "GET" : request_context.method; + if (!request_context.content_type.empty()) { + request->headers.emplace_back( + "content-type", request_context.content_type); + } + if (!request_context.cookie.empty()) { + request->headers.emplace_back("cookie", request_context.cookie); + } + request->client_id = client->descriptor.id; + request->client_generation = client->descriptor.generation; + auto future = request->promise.get_future().share(); + { + std::lock_guard lock(runtime->mutex); + const auto runtime_client = runtime->clients.find(request->client_id); + if (runtime->stopped || runtime_client == runtime->clients.end() + || runtime_client->second.generation + != request->client_generation) { + service_worker_fetch_result result; + result.handled = true; + result.error = "Service worker fetch client was retired"; + request->promise.set_value(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)); + return future; + } + ++runtime->active_fetches; + runtime->fetch_high_water = std::max( + runtime->fetch_high_water, runtime->active_fetches); + runtime->fetches.push_back(request); + } + runtime->ready.notify_all(); + return future; + } + bool has_service_worker_messages() const { for (const auto& [scope, registration] : service_worker_registrations) { @@ -1511,10 +1983,13 @@ std::unordered_map clients; std::deque incoming; std::deque outgoing; + std::deque> fetches; size_t incoming_bytes{}; size_t outgoing_bytes{}; size_t incoming_high_water{}; size_t outgoing_high_water{}; + size_t fetch_high_water{}; + size_t active_fetches{}; std::function notify_client; bool stopped{false}; bool activated{false}; @@ -1530,8 +2005,10 @@ clients.clear(); incoming.clear(); outgoing.clear(); + fetches.clear(); incoming_bytes = 0U; outgoing_bytes = 0U; + active_fetches = 0U; } ready.notify_all(); if (thread.joinable()) thread.join(); 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 b5b72b3b0..1c685c299 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 @@ -211,3 +211,181 @@ void test_readable_stream_body_and_fetch_event_vertical() << " heapAfter=" << memory_after.v8_used_heap_bytes << '\n'; webscene_engine_destroy(engine); } + +void test_cache_storage_and_controlled_fetch_broker() +{ + resource_server server{.content = { + {"https://resource-plane.test/index.html", + "resource plane broker gate"}, + {"https://resource-plane.test/host.txt", "host-fallback"}, + {"https://resource-plane.test/service-worker.js", R"JS( + let fetches = 0; + let misses = 0; + let emptyMatch = false; + self.addEventListener('install', event => event.waitUntil(skipWaiting())); + self.addEventListener('activate', event => event.waitUntil((async () => { + const cache = await caches.open('resource-plane-v1'); + emptyMatch = await cache.match( + new Request('https://resource-plane.test/never')) === undefined; + await clients.claim(); + })())); + self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + if (url.pathname === '/broker-too-large') { + event.respondWith(new Response(new ReadableStream({start(controller) { + const chunk = new Uint8Array(1024 * 1024); + for (let index = 0; index < 65; ++index) controller.enqueue(chunk); + controller.close(); + }}))); + return; + } + if (url.pathname === '/broker-error') { + event.respondWith(Promise.reject(new Error('broker failure'))); + return; + } + if (url.pathname !== '/broker') return; + event.respondWith((async () => { + ++fetches; + const cache = await caches.open('resource-plane-v1'); + const known = await cache.match(event.request); + if (known) return known; + ++misses; + const response = new Response('broker-ok', { + status:200, + headers:{'content-type':'text/plain', 'x-resource-plane':'native'} + }); + const stored = cache.put(event.request, response.clone()); + await stored; + return response; + })()); + }); + self.addEventListener('message', event => { + if (event.data !== 'report') return; + event.source.postMessage({ + fetches, misses, emptyMatch, + cachesTag:Object.prototype.toString.call(caches), + cacheStorageBrand:caches instanceof CacheStorage, + cacheMethodEnumerable:Object.getOwnPropertyDescriptor( + CacheStorage.prototype, 'open').enumerable + }); + }); + )JS"} + }}; + 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, "resource-plane engine creation failed"); + constexpr std::string_view address{"https://resource-plane.test/index.html"}; + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "resource-plane navigation failed"); + require( + evaluate_until_equals(engine, "location.href", "resource-plane-navigation.js", + R"("https://resource-plane.test/index.html")", 2'500) + == R"("https://resource-plane.test/index.html")", + "resource-plane 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, + "resource-plane memory baseline was unavailable"); + const auto rss_before = service_worker_test_peak_rss_bytes(); + execute(engine, R"JS( + globalThis.__resourcePlane = { + complete:false, error:null, durations:[], report:null, + hostFallback:null, rejected:null, oversizedRejected:null + }; + (async () => { + try { + const changed = new Promise(resolve => navigator.serviceWorker.addEventListener( + 'controllerchange', resolve, {once:true})); + const registration = await navigator.serviceWorker.register( + 'service-worker.js', {type:'module'}); + if (!navigator.serviceWorker.controller) await changed; + for (let cycle = 0; cycle < 100; ++cycle) { + const start = performance.now(); + const response = await fetch('/broker'); + const text = await response.text(); + if (response.status !== 200 || text !== 'broker-ok' + || response.headers.get('x-resource-plane') !== 'native') { + throw new Error(`controlled response mismatch at ${cycle}: ${ + response.status}/${text}`); + } + __resourcePlane.durations.push(performance.now() - start); + } + __resourcePlane.hostFallback = await (await fetch('/host.txt')).text(); + try { await fetch('/broker-error'); } + catch (error) { __resourcePlane.rejected = String(error); } + try { await fetch('/broker-too-large'); } + catch (error) { __resourcePlane.oversizedRejected = String(error); } + __resourcePlane.report = await new Promise(resolve => { + const listener = event => { + navigator.serviceWorker.removeEventListener('message', listener); + resolve(event.data); + }; + navigator.serviceWorker.addEventListener('message', listener); + navigator.serviceWorker.controller.postMessage('report'); + }); + await registration.unregister(); + } catch (error) { + __resourcePlane.error = String(error && error.stack || error); + } finally { __resourcePlane.complete = true; } + })(); + )JS", "resource-plane-gate.js"); + require( + evaluate_until_equals(engine, "__resourcePlane.complete", + "resource-plane-complete.js", "true", 30'000) == "true", + "resource-plane 100-cycle gate did not finish"); + require(evaluate(engine, "__resourcePlane.error", "resource-plane-error.js") == "null", + "resource-plane gate failed: " + + evaluate(engine, "__resourcePlane.error", "resource-plane-error-detail.js")); + require(evaluate(engine, "__resourcePlane.hostFallback", + "resource-plane-fallback.js") == R"("host-fallback")", + "unhandled controlled fetch did not fall back to the host loader"); + require(evaluate(engine, "__resourcePlane.rejected !== null", + "resource-plane-rejection.js") == "true", + "rejected respondWith promise did not reject the controlled fetch"); + require(evaluate(engine, R"JS( + __resourcePlane.oversizedRejected?.includes('64 MiB') === true + )JS", "resource-plane-oversized.js") == "true", + "oversized service-worker response did not fail at the 64 MiB bound"); + require(evaluate(engine, R"JS((() => { + const value = __resourcePlane.report; + return value.fetches === 100 && value.misses === 1 && value.emptyMatch + && value.cachesTag === '[object CacheStorage]' && value.cacheStorageBrand + && value.cacheMethodEnumerable; + })())JS", "resource-plane-report.js") == "true", + "CacheStorage/controlled broker worker contract failed: " + + evaluate(engine, "JSON.stringify(__resourcePlane.report)", + "resource-plane-report-detail.js")); + const auto p95 = std::stod(evaluate(engine, R"JS((() => { + const values = [...__resourcePlane.durations].sort((a,b) => a-b); + return values[Math.ceil(values.length * .95) - 1]; + })())JS", "resource-plane-p95.js")); + require(p95 <= 250.0, + "controlled resource fetch p95 exceeded 250 ms: " + std::to_string(p95)); + require(webscene_engine_request_low_memory(engine) != 0, + "resource-plane low-memory request was 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, + "resource-plane final memory metrics were 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, + "resource-plane low-memory collection did not complete"); + require(memory_after.v8_used_heap_bytes + <= memory_before.v8_used_heap_bytes + 8U * 1024U * 1024U, + "resource-plane cycles retained more than 8 MiB"); + const auto rss_after = service_worker_test_peak_rss_bytes(); + require(rss_after <= rss_before + 64U * 1024U * 1024U, + "resource-plane cycles grew peak RSS by more than 64 MiB"); + std::cout << "Resource-plane 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 << '\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 9d71f128f..b9e5fff46 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -188,6 +188,10 @@ int main() test_readable_stream_body_and_fetch_event_vertical(); return 0; } + if (selected == "service-worker-resource-plane") { + test_cache_storage_and_controlled_fetch_broker(); + 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/scripts/tests/test_v8_bootstrap_literals.py b/scripts/tests/test_v8_bootstrap_literals.py index 8e0a1d268..f18c382d5 100644 --- a/scripts/tests/test_v8_bootstrap_literals.py +++ b/scripts/tests/test_v8_bootstrap_literals.py @@ -5,6 +5,7 @@ import importlib.util import pathlib +import re import unittest @@ -17,6 +18,9 @@ / "native" / "webscene_v8_runtime.cpp" ) +SERVICE_WORKER_PATH = RUNTIME_PATH.with_name( + "webscene_v8_runtime_service_workers.inc" +) SPEC = importlib.util.spec_from_file_location("extract_bootstraps", EXTRACTOR_PATH) assert SPEC is not None and SPEC.loader is not None @@ -41,6 +45,18 @@ def test_fetch_bootstrap_is_portable_and_byte_equivalent(self) -> None: EXTRACTOR.extract(source, "void install_fetch_globals"), ) + def test_service_worker_bootstrap_literals_are_portable(self) -> None: + source = SERVICE_WORKER_PATH.read_text(encoding="utf-8") + parts = re.findall(r'R"JS\((.*?)\)JS"', source, re.DOTALL) + + self.assertGreater(len(parts), 1) + self.assertTrue( + all( + len(part.encode("utf-8")) <= EXTRACTOR.MAX_RAW_LITERAL_BYTES + for part in parts + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch-worker.js b/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch-worker.js new file mode 100644 index 000000000..bc9b658cc --- /dev/null +++ b/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch-worker.js @@ -0,0 +1,39 @@ +let fetches = 0; +let misses = 0; +let emptyMatch = false; + +self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', event => event.waitUntil((async () => { + const cache = await caches.open('cache-broker-v1'); + emptyMatch = await cache.match( + new Request(`${self.location.origin}/never`)) === undefined; + await self.clients.claim(); +})())); +self.addEventListener('fetch', event => { + if (!event.request.url.endsWith('/cache-broker-oracle')) return; + event.respondWith((async () => { + ++fetches; + const cache = await caches.open('cache-broker-v1'); + const known = await cache.match(event.request); + if (known) return known; + ++misses; + const response = new Response('broker-ok', { + status:200, + headers:{'content-type':'text/plain', 'x-resource-plane':'native'} + }); + await cache.put(event.request, response.clone()); + return response; + })()); +}); +self.addEventListener('message', async event => { + if (event.data !== 'probe') return; + const cache = await caches.open('cache-broker-v1'); + event.ports[0].postMessage({ + fetches, misses, emptyMatch, + cachesTag:Object.prototype.toString.call(caches), + cacheStorageBrand:caches instanceof CacheStorage, + cacheTag:Object.prototype.toString.call(cache), + cacheMethodEnumerable:Object.getOwnPropertyDescriptor( + CacheStorage.prototype, 'open').enumerable + }); +}); diff --git a/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch.html b/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch.html new file mode 100644 index 000000000..5f06bf9c7 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/cache-storage-controlled-fetch.html @@ -0,0 +1,51 @@ + + +CacheStorage and controlled fetch broker + diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index f51e0e556..1a5a064e6 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2396,6 +2396,14 @@ "evidence": ["streams-readable-stream-derived", "service-workers-fetch-event-derived", "chromium-153-direct-neutral-contract", "vscode-oss-service-worker-response-shape"], "reason": "Candidate first resource-plane stack for WebScene #266. It covers ReadableStream/default reader/controller brands and selected WebIDL descriptors, byte-chunk Response body clone/tee/bodyUsed behavior, and FetchEvent/ExtendableEvent identity in a real isolated service-worker realm. The native companion gate adds active-dispatch respondWith/waitUntil/handled behavior, duplicate and late response rejection, a streamed synthetic 206 response, top-level plus iframe realms, 100 body ownership cycles, p95 <= 50 ms, and <= 8 MiB retained heap. CacheStorage, controlled resource routing, TransformStream fallback, the Code client broker, and unchanged Markdown remain later #266 stack entries.", "nativeNavigation": true + }, + { + "path": "contracts/cache-storage-controlled-fetch.html", + "type": "contract", + "capabilities": ["cache-storage-empty-match", "cache-response-clone", "controlled-service-worker-fetch-broker"], + "evidence": ["service-workers-cache-derived", "chromium-153-direct-neutral-contract", "vscode-oss-resource-broker-shape"], + "reason": "Candidate second resource-plane stack for WebScene #266. It provides bounded worker-local CacheStorage with empty-match and cloned-response semantics plus asynchronous controlled Window.fetch dispatch with unhandled host fallback and rejected-response propagation. The native companion gate covers 100 controlled requests, one cache miss followed by stable clones, WebIDL brands/descriptors, unregister teardown, p95 <= 250 ms, <= 8 MiB retained V8 heap, and <= 64 MiB peak-RSS growth. WritableStream/TransformStream fallback, interception of parser/image/style loads, and unchanged Markdown product acceptance remain later #266 slices.", + "nativeNavigation": true } ], "harnessBlocked": [],