diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 8a1af422d..be6b45482 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -4101,8 +4101,11 @@ struct v8_dom_runtime::implementation final { local_context, js_string(isolate, "__webSceneFetchResource"), v8::Function::New(local_context, fetch_resource).ToLocalChecked()).Check(); - if constexpr (bootstrap_snapshot_enabled) return; - constexpr std::string_view source = R"JS( + if constexpr (bootstrap_snapshot_enabled) { + if (!context.IsEmpty() && context.Get(isolate) == local_context) return; + } + constexpr std::string_view source_parts[] = { + R"JS( (() => { class WebSceneHeaders { constructor(initial = undefined) { @@ -4144,10 +4147,280 @@ struct v8_dom_runtime::implementation final { [Symbol.iterator]() { return this.entries(); } } + const readableStreamState = new WeakMap(); + const readableStreamReaderState = new WeakMap(); + const readableStreamControllerState = new WeakMap(); + const requireReadableStream = value => { + const state = readableStreamState.get(value); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + const requireReadableStreamReader = value => { + const state = readableStreamReaderState.get(value); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + const requireReadableStreamController = value => { + const state = readableStreamControllerState.get(value); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + const settleReadableStream = state => { + while (state.reads.length && state.queue.length) { + state.disturbed = true; + state.reads.shift().resolve({value:state.queue.shift(), done:false}); + } + if (state.status === 'readable' && state.closeRequested + && state.queue.length === 0) state.status = 'closed'; + if (state.status === 'closed') { + while (state.reads.length) { + state.reads.shift().resolve({value:undefined, done:true}); + } + state.closedResolve(); + } else if (state.status === 'errored') { + while (state.reads.length) state.reads.shift().reject(state.error); + state.closedReject(state.error); + } + }; + const pullReadableStream = state => { + if (state.status !== 'readable' || state.pulling || !state.pull + || state.closeRequested + || (!state.reads.length && state.queue.length)) return; + state.pulling = true; + Promise.resolve().then(() => state.pull(state.controller)) + .catch(error => state.controller.error(error)) + .finally(() => { + state.pulling = false; + settleReadableStream(state); + if (state.reads.length && !state.queue.length) pullReadableStream(state); + }); + }; + const cancelReadableStream = (state, reason) => { + if (state.status === 'closed') return Promise.resolve(); + if (state.status === 'errored') return Promise.reject(state.error); + state.disturbed = true; + state.queue.length = 0; + state.status = 'closed'; + settleReadableStream(state); + try { + return Promise.resolve(state.cancel ? state.cancel(reason) : undefined); + } catch (error) { return Promise.reject(error); } + }; + class ReadableStreamDefaultController { + constructor() { throw new TypeError('Illegal constructor'); } + get desiredSize() { + const state = requireReadableStreamController(this); + return state.status === 'errored' ? null + : state.status === 'closed' ? 0 : 1 - state.queue.length; + } + enqueue(chunk) { + const state = requireReadableStreamController(this); + if (state.status !== 'readable' || state.closeRequested) { + throw new TypeError('ReadableStream is not readable'); + } + state.queue.push(chunk); + settleReadableStream(state); + } + close() { + const state = requireReadableStreamController(this); + if (state.status !== 'readable' || state.closeRequested) { + throw new TypeError('ReadableStream is not readable'); + } + state.closeRequested = true; + settleReadableStream(state); + } + error(reason = undefined) { + const state = requireReadableStreamController(this); + if (state.status !== 'readable') return; + state.queue.length = 0; + state.status = 'errored'; + state.error = reason; + settleReadableStream(state); + } + } + class ReadableStreamDefaultReader { + constructor(stream) { + const state = requireReadableStream(stream); + if (state.locked) throw new TypeError('ReadableStream is locked'); + state.locked = true; + let closedResolve; + let closedReject; + const closed = new Promise((resolve, reject) => { + closedResolve = resolve; + closedReject = reject; + }); + closed.catch(() => {}); + const reader = {stream, state, closed, closedResolve, closedReject}; + readableStreamReaderState.set(this, reader); + state.closedResolve = closedResolve; + state.closedReject = closedReject; + settleReadableStream(state); + } + get closed() { return requireReadableStreamReader(this).closed; } + read() { + const reader = requireReadableStreamReader(this); + if (!reader.stream) return Promise.reject(new TypeError('Reader released')); + const state = reader.state; + state.disturbed = true; + if (state.queue.length) { + const value = state.queue.shift(); + settleReadableStream(state); + pullReadableStream(state); + return Promise.resolve({value, done:false}); + } + if (state.status === 'closed') { + return Promise.resolve({value:undefined, done:true}); + } + if (state.status === 'errored') return Promise.reject(state.error); + const result = new Promise((resolve, reject) => { + state.reads.push({resolve, reject}); + }); + pullReadableStream(state); + return result; + } + cancel(reason = undefined) { + const reader = requireReadableStreamReader(this); + if (!reader.stream) return Promise.reject(new TypeError('Reader released')); + return cancelReadableStream(reader.state, reason); + } + releaseLock() { + const reader = requireReadableStreamReader(this); + if (!reader.stream) return; + if (reader.state.reads.length) { + throw new TypeError('Cannot release a reader with pending reads'); + } + reader.state.locked = false; + reader.stream = undefined; + } + } + class ReadableStream { + constructor(underlyingSource = {}) { + if (underlyingSource === null || typeof underlyingSource !== 'object') { + throw new TypeError('underlyingSource must be an object'); + } + if (underlyingSource.type !== undefined && underlyingSource.type !== 'bytes') { + throw new RangeError('Unsupported ReadableStream type'); + } + const state = { + queue:[], reads:[], status:'readable', error:undefined, + locked:false, disturbed:false, closeRequested:false, + pulling:false, pull:typeof underlyingSource.pull === 'function' + ? underlyingSource.pull.bind(underlyingSource) : undefined, +)JS", + R"JS( cancel:typeof underlyingSource.cancel === 'function' + ? underlyingSource.cancel.bind(underlyingSource) : undefined, + controller:undefined, closedResolve:()=>{}, closedReject:()=>{} + }; + const controller = Object.create(ReadableStreamDefaultController.prototype); + state.controller = controller; + readableStreamState.set(this, state); + readableStreamControllerState.set(controller, state); + if (typeof underlyingSource.start === 'function') { + let started; + try { started = underlyingSource.start(controller); } + catch (error) { controller.error(error); return; } + Promise.resolve(started) + .then(() => pullReadableStream(state), error => controller.error(error)); + } else { + queueMicrotask(() => pullReadableStream(state)); + } + } + get locked() { return requireReadableStream(this).locked; } + getReader() { requireReadableStream(this); return new ReadableStreamDefaultReader(this); } + cancel(reason = undefined) { + const state = requireReadableStream(this); + if (state.locked) return Promise.reject(new TypeError('ReadableStream is locked')); + return cancelReadableStream(state, reason); + } + tee() { + const state = requireReadableStream(this); + if (state.locked) throw new TypeError('ReadableStream is locked'); + const reader = this.getReader(); + let controllers; + let reading = false; + let canceled = 0; + let cancelReason; + const pump = () => { + if (reading || canceled === 2) return; + reading = true; + reader.read().then(({value, done}) => { + reading = false; + if (done) { + for (const controller of controllers) controller?.close(); + } else { + for (const controller of controllers) controller?.enqueue(value); + } + }, error => { + reading = false; + for (const controller of controllers) controller?.error(error); + }); + }; + const makeBranch = index => new ReadableStream({ + start(controller) { controllers[index] = controller; }, + pull() { pump(); }, + cancel(reason) { + canceled++; + cancelReason ??= reason; + if (canceled === 2) return reader.cancel(cancelReason); + } + }); + controllers = [undefined, undefined]; + const branches = [makeBranch(0), makeBranch(1)]; + return branches; + } + } + Object.defineProperty(ReadableStream.prototype, Symbol.toStringTag, + {value:'ReadableStream', configurable:true}); + Object.defineProperty(ReadableStreamDefaultReader.prototype, Symbol.toStringTag, + {value:'ReadableStreamDefaultReader', configurable:true}); + Object.defineProperty(ReadableStreamDefaultController.prototype, Symbol.toStringTag, + {value:'ReadableStreamDefaultController', configurable:true}); + for (const [prototype, names] of [ + [ReadableStream.prototype, ['locked', 'getReader', 'cancel', 'tee']], + [ReadableStreamDefaultReader.prototype, + ['closed', 'read', 'cancel', 'releaseLock']], + [ReadableStreamDefaultController.prototype, + ['desiredSize', 'enqueue', 'close', 'error']] + ]) { + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + Object.defineProperty(prototype, name, {...descriptor, enumerable:true}); + } + } + + const bodyBytes = body => body instanceof ArrayBuffer + ? new Uint8Array(body.slice(0)) + : ArrayBuffer.isView(body) + ? new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)) + : body instanceof Blob ? body._bytes.slice() + : new TextEncoder().encode(String(body ?? '')); + const consumeBody = async response => { + if (response.bodyUsed) throw new TypeError('Response body already used'); + response._bodyUsed = true; + if (response.body === null) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + for (;;) { + const {value, done} = await reader.read(); + if (done) break; + const bytes = bodyBytes(value); + chunks.push(bytes); + length += bytes.byteLength; + } + const result = new Uint8Array(length); + let offset = 0; + for (const bytes of chunks) { result.set(bytes, offset); offset += bytes.byteLength; } + return result; + }; + class WebSceneResponse { - constructor(body = '', options = {}) { - this._body = body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer.slice(body.byteOffset,body.byteOffset+body.byteLength)) : body instanceof Blob ? body._bytes.slice() : new TextEncoder().encode(String(body ?? '')); - this.bodyUsed = false; + constructor(body = null, options = {}) { + this.body = body === null ? null : body instanceof ReadableStream + ? body : new ReadableStream({start(controller) { + controller.enqueue(bodyBytes(body)); controller.close(); + }}); + this._bodyUsed = false; this.status = Number(options.status ?? 200); this.statusText = String(options.statusText ?? 'OK'); this.url = String(options.url ?? ''); @@ -4156,27 +4429,29 @@ struct v8_dom_runtime::implementation final { this.headers = new WebSceneHeaders(options.headers); } get ok() { return this.status >= 200 && this.status < 300; } - text() { - if (this.bodyUsed) { - return Promise.reject(new TypeError('Response body already used')); - } - this.bodyUsed = true; - return Promise.resolve(new TextDecoder().decode(this._body)); + get bodyUsed() { + return this._bodyUsed || (this.body !== null + && requireReadableStream(this.body).disturbed); } + text() { return consumeBody(this).then(value => new TextDecoder().decode(value)); } json() { return this.text().then(value => JSON.parse(value)); } arrayBuffer() { - if(this.bodyUsed)return Promise.reject(new TypeError('Response body already used')); - this.bodyUsed=true;return Promise.resolve(this._body.slice().buffer); + return consumeBody(this).then(value => value.buffer); } blob() { - if(this.bodyUsed)return Promise.reject(new TypeError('Response body already used')); - this.bodyUsed=true;return Promise.resolve(new Blob([this._body],{type:this.headers.get('content-type')||''})); + return consumeBody(this).then(value => new Blob([value],{type:this.headers.get('content-type')||''})); } clone() { if (this.bodyUsed) throw new TypeError('Response body already used'); - return new WebSceneResponse(this._body, { + let body = null; + if (this.body !== null) { + const branches = this.body.tee(); + this.body = branches[0]; + body = branches[1]; + } + return new WebSceneResponse(body, { status: this.status, statusText: this.statusText, url: this.url, @@ -4220,7 +4495,8 @@ struct v8_dom_runtime::implementation final { const mode = { 'same-origin': 1, cors: 2, 'no-cors': 3 }[request.mode]; const redirect = { follow: 0, error: 1, manual: 2 }[request.redirect]; if (credentials === undefined || mode === undefined || redirect === undefined) { - throw new TypeError('Invalid fetch mode, credentials, or redirect option'); +)JS", + R"JS( throw new TypeError('Invalid fetch mode, credentials, or redirect option'); } let body = ''; if (request.body instanceof FormData) { @@ -4380,6 +4656,15 @@ struct v8_dom_runtime::implementation final { Headers: { value: WebSceneHeaders, writable: true, configurable: true }, + ReadableStream: { + value: ReadableStream, writable: true, configurable: true + }, + ReadableStreamDefaultReader: { + value: ReadableStreamDefaultReader, writable: true, configurable: true + }, + ReadableStreamDefaultController: { + value: ReadableStreamDefaultController, writable: true, configurable: true + }, Request: { value: WebSceneRequest, writable: true, configurable: true }, @@ -4394,7 +4679,11 @@ struct v8_dom_runtime::implementation final { } }); })(); - )JS"; + )JS", + }; + std::string source; + source.reserve(27687); + for (const auto part : source_parts) source.append(part); auto script = v8::Script::Compile( local_context, js_string(isolate, std::string(source).c_str())).ToLocalChecked(); 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 fa03fb00a..5746169c0 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 @@ -350,6 +350,118 @@ globalThis, ServiceWorkerGlobalScope.prototype); const control = globalThis.__webSceneServiceWorkerGlobalControl; delete globalThis.__webSceneServiceWorkerGlobalControl; + const extendableEventState = new WeakMap(); + class ExtendableEvent extends Event { + constructor(type, init = {}) { + super(type, init); + extendableEventState.set(this, { + dispatching:false, pending:[], response:undefined, + responded:false, handledResolve:undefined, + handled:undefined + }); + const state = extendableEventState.get(this); + state.handled = new Promise(resolve => state.handledResolve = resolve); + } + waitUntil(value) { + const state = extendableEventState.get(this); + if (!state || !state.dispatching) { + throw new DOMException( + 'The event is not being dispatched.', 'InvalidStateError'); + } + state.pending.push(Promise.resolve(value)); + } + } + Object.defineProperty(ExtendableEvent.prototype, Symbol.toStringTag, + {value:'ExtendableEvent', configurable:true}); + const fetchEventState = new WeakMap(); + class FetchEvent extends ExtendableEvent { + constructor(type, init) { + if (!init || !(init.request instanceof Request)) { + throw new TypeError('FetchEvent requires a Request'); + } + super(type, init); + fetchEventState.set(this, { + request:init.request, + clientId:String(init.clientId ?? ''), + resultingClientId:String(init.resultingClientId ?? ''), + replacesClientId:String(init.replacesClientId ?? ''), + preloadResponse:Promise.resolve(init.preloadResponse), + }); + } + get request() { + const state = fetchEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.request; + } + get clientId() { + const state = fetchEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.clientId; + } + get resultingClientId() { + const state = fetchEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.resultingClientId; + } + get replacesClientId() { + const state = fetchEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.replacesClientId; + } + get preloadResponse() { + const state = fetchEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.preloadResponse; + } + get handled() { + const state = extendableEventState.get(this); + if (!state) throw new TypeError('Illegal invocation'); + return state.handled; + } + respondWith(value) { + const state = extendableEventState.get(this); + if (!state || !state.dispatching || state.responded) { + throw new DOMException( + 'respondWith() is unavailable.', 'InvalidStateError'); + } + state.responded = true; + state.response = Promise.resolve(value).then(response => { + if (!(response instanceof Response)) { + throw new TypeError('FetchEvent response must be a Response'); + } + return response; + }); + state.response.then( + () => state.handledResolve(), + () => state.handledResolve()); + } + } + Object.defineProperty(FetchEvent.prototype, Symbol.toStringTag, + {value:'FetchEvent', configurable:true}); + for (const [prototype, names] of [ + [ExtendableEvent.prototype, ['waitUntil']], + [FetchEvent.prototype, [ + 'request', 'clientId', 'resultingClientId', + 'replacesClientId', 'preloadResponse', 'handled', + 'respondWith']] + ]) { + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + Object.defineProperty( + prototype, name, {...descriptor, enumerable:true}); + } + } + const dispatchFetch = async init => { + const event = new FetchEvent('fetch', init); + const state = extendableEventState.get(event); + state.dispatching = true; + try { dispatchEvent(event); } + finally { state.dispatching = false; } + if (!state.responded) return undefined; + const response = await state.response; + await Promise.all(state.pending); + return response; + }; function Client() { throw new TypeError('Illegal constructor'); } function WindowClient() { throw new TypeError('Illegal constructor'); } function Clients() { throw new TypeError('Illegal constructor'); } @@ -438,6 +550,12 @@ const clients = Object.create(Clients.prototype); clientsState.add(clients); Object.defineProperties(globalThis, { + ExtendableEvent: { + value:ExtendableEvent, writable:true, configurable:true + }, + FetchEvent: { + value:FetchEvent, writable:true, configurable:true + }, Client: {value:Client, writable:true, configurable:true}, WindowClient: {value:WindowClient, writable:true, configurable:true}, Clients: {value:Clients, writable:true, configurable:true}, @@ -460,6 +578,9 @@ return true; }, configurable:true + }, + __webSceneDispatchServiceWorkerFetch: { + value:dispatchFetch, configurable:true } }); })(); 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 new file mode 100644 index 000000000..b5b72b3b0 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_stream_fetch_tests.inc @@ -0,0 +1,213 @@ +void test_readable_stream_body_and_fetch_event_vertical() +{ + resource_server server{.content = { + {"https://stream-fetch.test/index.html", + "stream and fetch event gate"}, + {"https://stream-fetch.test/frame.html", + "stream nested realm"}, + {"https://stream-fetch.test/service-worker.js", R"JS( + let report = {complete:false}; + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + self.addEventListener('install', event => event.waitUntil(skipWaiting())); + self.addEventListener('fetch', event => { + event.waitUntil(Promise.resolve()); + event.respondWith(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([65, 66])); + controller.enqueue(new Uint8Array([67])); + controller.close(); + } + }), {status:206, headers:{'content-type':'text/plain'}})); + let duplicate = ''; + try { event.respondWith(new Response('late')); } + catch (error) { duplicate = error.name; } + report.duplicateRespondWith = duplicate; + event.handled.then(() => report.handled = true); + }); + self.addEventListener('activate', event => event.waitUntil((async () => { + check(Object.prototype.toString.call(new ReadableStream()) === + '[object ReadableStream]', 'ReadableStream brand'); + check(Object.prototype.toString.call(new FetchEvent('fetch', { + request:new Request('https://stream-fetch.test/data')})) === + '[object FetchEvent]', 'FetchEvent brand'); + check(FetchEvent.prototype instanceof ExtendableEvent, + 'FetchEvent prototype chain'); + check(Object.getOwnPropertyDescriptor( + FetchEvent.prototype, 'respondWith').enumerable, + 'FetchEvent WebIDL descriptor'); + check(Object.getOwnPropertyDescriptor( + ReadableStream.prototype, 'getReader').enumerable, + 'ReadableStream WebIDL descriptor'); + const response = await __webSceneDispatchServiceWorkerFetch({ + request:new Request('https://stream-fetch.test/data'), + clientId:'synthetic-client', resultingClientId:'', replacesClientId:'' + }); + check(response instanceof Response, 'respondWith response brand'); + check(response.status === 206, 'respondWith status'); + check(await response.text() === 'ABC', 'streamed response body'); + check(report.duplicateRespondWith === 'InvalidStateError', + 'duplicate respondWith rejection'); + await Promise.resolve(); + check(report.handled === true, 'FetchEvent handled promise'); + let late = ''; + const outside = new FetchEvent('fetch', { + request:new Request('https://stream-fetch.test/late')}); + try { outside.respondWith(new Response('late')); } + catch (error) { late = error.name; } + check(late === 'InvalidStateError', 'late respondWith rejection'); + report = {complete:true, status:response.status, text:'ABC', + duplicateRespondWith:report.duplicateRespondWith, lateRespondWith:late}; + await clients.claim(); + })())); + self.addEventListener('message', event => { + if (event.data === 'report') event.source.postMessage(report); + }); + )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, "stream/fetch event engine creation failed"); + constexpr std::string_view address{"https://stream-fetch.test/index.html"}; + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "stream/fetch event navigation failed"); + require( + evaluate_until_equals(engine, "location.href", "stream-fetch-navigation.js", + R"("https://stream-fetch.test/index.html")", 2'500) + == R"("https://stream-fetch.test/index.html")", + "stream/fetch event 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, + "stream/fetch memory baseline was unavailable"); + execute(engine, R"JS( + globalThis.__streamFetch = {complete:false, error:null, durations:[], report:null}; + (async () => { + try { + const closeOrder = new ReadableStream({start(controller) { + controller.enqueue('first'); controller.close(); + }}); + const closeReader = closeOrder.getReader(); + let closedEarly = false; + closeReader.closed.then(() => closedEarly = true); + await Promise.resolve(); + if (closedEarly) throw new Error('reader.closed resolved before queued data drained'); + const first = await closeReader.read(); + const end = await closeReader.read(); + await closeReader.closed; + if (first.value !== 'first' || first.done || !end.done) { + throw new Error('close ordering changed queued data'); + } + let wrongReceiver = ''; + try { ReadableStream.prototype.getReader.call({}); } + catch (error) { wrongReceiver = error.name; } + if (wrongReceiver !== 'TypeError') throw new Error('stream receiver brand check'); + let controllerConstructor = ''; + try { new ReadableStreamDefaultController(); } + catch (error) { controllerConstructor = error.name; } + if (controllerConstructor !== 'TypeError') { + throw new Error('controller constructor was not illegal'); + } + const frame = document.createElement('iframe'); + frame.src = 'frame.html'; + document.body.append(frame); + for (let attempt = 0; attempt < 100 + && typeof frame.contentWindow.Response !== 'function'; ++attempt) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + if (typeof frame.contentWindow.ReadableStream !== 'function' + || !(new frame.contentWindow.Response('frame').body + instanceof frame.contentWindow.ReadableStream)) { + throw new Error(`nested realm did not install stream/body globals: ${ + typeof frame.contentWindow.ReadableStream}/${ + typeof frame.contentWindow.Response}`); + } + frame.remove(); + for (let cycle = 0; cycle < 100; ++cycle) { + const start = performance.now(); + let cancelReason = ''; + const canceled = new ReadableStream({ + cancel(reason) { cancelReason = reason; } + }); + const cancelReader = canceled.getReader(); + await cancelReader.cancel('retired'); + if (cancelReason !== 'retired') throw new Error('reader cancellation lost'); + + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([cycle & 255, 13])); + controller.enqueue(new Uint8Array([21])); + controller.close(); + } + }), {headers:{'content-type':'application/octet-stream'}}); + const clone = response.clone(); + const [left, right] = await Promise.all( + [response.arrayBuffer(), clone.arrayBuffer()]); + if (new Uint8Array(left).join(',') !== `${cycle & 255},13,21` + || new Uint8Array(right).join(',') !== `${cycle & 255},13,21` + || !response.bodyUsed || !clone.bodyUsed) { + throw new Error(`tee/body ownership failed at cycle ${cycle}`); + } + __streamFetch.durations.push(performance.now() - start); + } + 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; + __streamFetch.report = await new Promise(resolve => { + const listener = message => { + navigator.serviceWorker.removeEventListener('message', listener); + resolve(message.data); + }; + navigator.serviceWorker.addEventListener('message', listener); + navigator.serviceWorker.controller.postMessage('report'); + }); + if (!__streamFetch.report.complete || __streamFetch.report.text !== 'ABC') { + throw new Error(`controlled response failed: ${JSON.stringify(__streamFetch.report)}`); + } + } catch (error) { + __streamFetch.error = String(error && error.stack || error); + } finally { __streamFetch.complete = true; } + })(); + )JS", "stream-fetch-gate.js"); + require( + evaluate_until_equals(engine, "__streamFetch.complete", + "stream-fetch-complete.js", "true", 10'000) == "true", + "100 stream/body cycles and controlled response did not finish"); + require(evaluate(engine, "__streamFetch.error", "stream-fetch-error.js") == "null", + "stream/fetch event gate failed: " + + evaluate(engine, "__streamFetch.error", "stream-fetch-error-detail.js")); + require(evaluate(engine, "__streamFetch.report.status", "stream-fetch-status.js") + == "206", + "controlled synthetic response lost its status"); + const auto p95 = std::stod(evaluate(engine, R"JS((() => { + const values = [...__streamFetch.durations].sort((a,b) => a-b); + return values[Math.ceil(values.length * .95) - 1]; + })())JS", "stream-fetch-p95.js")); + require(p95 <= 50.0, + "stream/body 100-cycle p95 exceeded 50 ms: " + std::to_string(p95)); + require(webscene_engine_request_low_memory(engine) != 0, + "stream/fetch 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, + "stream/fetch 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, + "stream/fetch low-memory collection did not complete"); + require(memory_after.v8_used_heap_bytes + <= memory_before.v8_used_heap_bytes + 8U * 1024U * 1024U, + "stream/fetch cycles retained more than 8 MiB"); + std::cout << "Readable body/FetchEvent gate: cycles=100 p95=" << p95 + << "ms heapBefore=" << memory_before.v8_used_heap_bytes + << " heapAfter=" << memory_after.v8_used_heap_bytes << '\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 7c5f76e11..e0da6251a 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -89,6 +89,7 @@ uint8_t measure_baseline_fixture_text( #include "native_table_cell_copy_tests.inc" #include "native_v8_runtime_resource_tests.inc" #include "native_v8_runtime_service_worker_tests.inc" +#include "native_v8_runtime_stream_fetch_tests.inc" #include "native_v8_runtime_response_cookie_tests.inc" #include "native_v8_runtime_diagnostics_tests.inc" #include "native_resource_failure_diagnostics_tests.inc" @@ -183,6 +184,10 @@ int main() test_service_worker_client_queue_performance_and_memory_gate(); return 0; } + if (selected == "service-worker-fetch") { + test_readable_stream_body_and_fetch_event_vertical(); + 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 new file mode 100644 index 000000000..8e0a1d268 --- /dev/null +++ b/scripts/tests/test_v8_bootstrap_literals.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Regression coverage for portable V8 bootstrap source literals.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +EXTRACTOR_PATH = ROOT / "tools" / "v8-snapshot" / "extract_bootstraps.py" +RUNTIME_PATH = ( + ROOT + / "experiments" + / "WebScene.NativeEngine.Probe" + / "native" + / "webscene_v8_runtime.cpp" +) + +SPEC = importlib.util.spec_from_file_location("extract_bootstraps", EXTRACTOR_PATH) +assert SPEC is not None and SPEC.loader is not None +EXTRACTOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(EXTRACTOR) + + +class V8BootstrapLiteralTests(unittest.TestCase): + def test_fetch_bootstrap_is_portable_and_byte_equivalent(self) -> None: + source = RUNTIME_PATH.read_text(encoding="utf-8") + parts = EXTRACTOR.extract_parts(source, "void install_fetch_globals") + + self.assertGreater(len(parts), 1) + self.assertTrue( + all( + len(part.encode("utf-8")) <= EXTRACTOR.MAX_RAW_LITERAL_BYTES + for part in parts + ) + ) + self.assertEqual( + "".join(parts).strip() + "\n", + EXTRACTOR.extract(source, "void install_fetch_globals"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/WebPlatformSubset/contracts/readable-stream-fetch-event-worker.js b/tests/WebPlatformSubset/contracts/readable-stream-fetch-event-worker.js new file mode 100644 index 000000000..cb93939f8 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/readable-stream-fetch-event-worker.js @@ -0,0 +1,48 @@ +self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); +self.addEventListener('fetch', event => { + if (!event.request.url.endsWith('/stream-fetch-oracle')) return; + event.waitUntil(Promise.resolve()); + event.respondWith(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([65, 66])); + controller.enqueue(new Uint8Array([67])); + controller.close(); + } + }), {status:206, headers:{'content-type':'text/plain'}})); +}); +self.addEventListener('message', async event => { + if (event.data !== 'probe') return; + try { + const synthetic = new FetchEvent('fetch', { + request:new Request(`${self.location.origin}/synthetic`), + clientId:'oracle-client' + }); + let late = ''; + self.dispatchEvent(synthetic); + try { synthetic.respondWith(new Response('late')); } + catch (error) { late = error.name; } + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([65, 66])); + controller.enqueue(new Uint8Array([67])); + controller.close(); + } + }), {status:206}); + const clone = response.clone(); + const [text, cloneText] = await Promise.all([response.text(), clone.text()]); + event.ports[0].postMessage({ + readableTag:Object.prototype.toString.call(new ReadableStream()), + fetchTag:Object.prototype.toString.call(synthetic), + fetchChain:synthetic instanceof ExtendableEvent, + readableEnumerable:Object.getOwnPropertyDescriptor( + ReadableStream.prototype, 'getReader').enumerable, + fetchEnumerable:Object.getOwnPropertyDescriptor( + FetchEvent.prototype, 'respondWith').enumerable, + late, text, cloneText, status:response.status, + bodyUsed:response.bodyUsed && clone.bodyUsed + }); + } catch (error) { + event.ports[0].postMessage({error:String(error && error.stack || error)}); + } +}); diff --git a/tests/WebPlatformSubset/contracts/readable-stream-fetch-event.html b/tests/WebPlatformSubset/contracts/readable-stream-fetch-event.html new file mode 100644 index 000000000..62c5568c6 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/readable-stream-fetch-event.html @@ -0,0 +1,46 @@ + + +ReadableStream body and FetchEvent vertical + diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index bd6048e5f..da2c73aba 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2374,6 +2374,14 @@ "evidence": ["service-workers-clients-derived", "webmessaging-structured-clone-derived", "chromium-direct-neutral-contract", "vscode-oss-service-worker-message-shape"], "reason": "Candidate third stack for WebScene #265. It proves stable Client/WindowClient identity through Clients.get and matchAll, scope-bound controller source identity, bounded bidirectional structured clone, ArrayBuffer ownership transfer, and MessagePort transfer across the Window and isolated service-worker realms. Focused native gates additionally retire stale generations across controlled navigation, enforce the 256-message/16 MiB queue, and bound 100 transferred round trips to p95 <= 250 ms, retained V8 heap to 8 MiB, and peak-RSS growth to 64 MiB. Exact unchanged Code OSS acceptance and exported task/queue telemetry remain cumulative gates.", "nativeNavigation": true + }, + { + "path": "contracts/readable-stream-fetch-event.html", + "type": "contract", + "capabilities": ["readable-stream-default-reader-controller", "response-stream-body-clone", "fetch-event-idl"], + "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 } ], "harnessBlocked": [], diff --git a/tools/v8-snapshot/extract_bootstraps.py b/tools/v8-snapshot/extract_bootstraps.py index 79355e885..71c7d5d25 100644 --- a/tools/v8-snapshot/extract_bootstraps.py +++ b/tools/v8-snapshot/extract_bootstraps.py @@ -15,8 +15,13 @@ "static constexpr std::string_view intersection_observer_bootstrap_source", ) +# MSVC rejects string literals larger than 16,380 bytes (C2026). Keep enough +# headroom for source-encoding differences while retaining byte-for-byte joins. +MAX_RAW_LITERAL_BYTES = 15_000 -def extract(source: str, marker: str) -> str: + +def extract_parts(source: str, marker: str) -> list[str]: + """Return the ordered raw literals that make up one bootstrap program.""" start = source.find(marker) if start < 0: raise RuntimeError(f"bootstrap marker not found: {marker}") @@ -27,7 +32,18 @@ def extract(source: str, marker: str) -> str: matches = re.findall(r'R"JS\((.*?)\)JS"', scope, re.DOTALL) if not matches: raise RuntimeError(f"raw JavaScript literal not found after: {marker}") - return "".join(matches).strip() + "\n" + oversized = [len(part.encode("utf-8")) for part in matches + if len(part.encode("utf-8")) > MAX_RAW_LITERAL_BYTES] + if oversized: + raise RuntimeError( + f"raw JavaScript literal after {marker} exceeds " + f"{MAX_RAW_LITERAL_BYTES} bytes: {oversized}" + ) + return matches + + +def extract(source: str, marker: str) -> str: + return "".join(extract_parts(source, marker)).strip() + "\n" def main() -> int: