From f08888b57d382947dee16684c8ea87a147ca4cb7 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 22:45:06 +0800 Subject: [PATCH] fix(fetch): create binary body results in receiver realm Allocate Body.arrayBuffer() and Body.bytes() results in the receiver's creation context for buffered, author-stream and pending network bodies. Retain that context across asynchronous completion and recognize readable bodies by their internal brand when methods are borrowed across realms. Add Request/Response regression coverage for both iframe borrowing directions, null bodies, byte contents, asynchronous and cloned streams, prototype changes, network rewrapping, and original stream error identity. Validation: cargo fmt --all; strict workspace Clippy; nextest (17,958 passed, 13 skipped). The 81-case WPT selection improves from 73 to 75 passing cases with no regressions or new failed subtests. All four CLI regression groups pass (176 checks). --- .../wpt-cross-current/passed-cases.txt | 2 + .../fixtures/runtime/fetch_body_realm.js | 92 +++++++++++++++++++ moli-core/tests/web_apis.rs | 2 + moli-core/tests/web_apis/fetch_body_realm.rs | 38 ++++++++ .../src/network_host/body_source.rs | 83 ++++++++++++----- .../body_source/stream_consumer.rs | 34 +++++-- 6 files changed, 217 insertions(+), 34 deletions(-) create mode 100644 moli-core/tests/fixtures/runtime/fetch_body_realm.js create mode 100644 moli-core/tests/web_apis/fetch_body_realm.rs diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 2962c2dca6..af40396c1a 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -5213,6 +5213,8 @@ fetch/api/request/url-encoding.html fetch/api/response/json.any.js?moli-wpt-any=dedicatedworker fetch/api/response/json.any.js?moli-wpt-any=window fetch/api/response/multi-globals/url-parsing.html +fetch/api/response/response-arraybuffer-realm.window.js?moli-wpt-script=window +fetch/api/response/response-blob-realm.any.js?moli-wpt-any=window fetch/api/response/response-body-read-task-handling.html fetch/api/response/response-clone.any.js?moli-wpt-any=dedicatedworker fetch/api/response/response-clone.any.js?moli-wpt-any=window diff --git a/moli-core/tests/fixtures/runtime/fetch_body_realm.js b/moli-core/tests/fixtures/runtime/fetch_body_realm.js new file mode 100644 index 0000000000..01505a8b35 --- /dev/null +++ b/moli-core/tests/fixtures/runtime/fetch_body_realm.js @@ -0,0 +1,92 @@ +async function runFetchBodyRealmProbe(scenario, bodyURL, networkBytes) { + if (document.readyState !== 'complete') { + await new Promise(resolve => addEventListener('load', resolve, {once: true})); + } + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => frame.onload = resolve); + frame.srcdoc = ''; + document.body.append(frame); + await loaded; + const errors = []; + let checked = 0; + const check = (condition, label) => { if (!condition) errors.push(label); }; + try { + for (const direction of ['parent-method', 'child-method']) { + const methodRealm = direction === 'parent-method' ? self : frame.contentWindow; + const receiverRealm = direction === 'parent-method' ? frame.contentWindow : self; + const expectedBufferPrototype = receiverRealm.ArrayBuffer.prototype; + const expectedBytesPrototype = receiverRealm.Uint8Array.prototype; + for (const type of ['Request', 'Response']) { + const sources = scenario === 'network' + ? ['network', 'rewrapped-network', 'cloned-network'] + : ['null', 'buffered', 'receiver-stream', 'method-stream', 'async-stream', 'changed-prototype', 'cloned-stream']; + for (const source of sources) { + for (const method of ['arrayBuffer', 'bytes']) { + const label = [direction, type, source, method].join('/'); + try { + let expected = source === 'null' ? [] : [65, 0, 66, 255]; + let body = source === 'null' ? null : new receiverRealm.Uint8Array(expected); + let object; + if (scenario === 'network') { + expected = networkBytes; + const fetchRealm = source === 'rewrapped-network' ? methodRealm : receiverRealm; + object = await fetchRealm.fetch(bodyURL); + body = object.body; + if (type === 'Request' || source === 'rewrapped-network') object = undefined; + } else if (source.endsWith('stream')) { + const streamRealm = source === 'receiver-stream' ? receiverRealm : methodRealm; + body = new streamRealm.ReadableStream({start(controller) { + const send = () => { + controller.enqueue(new streamRealm.Uint8Array([9, ...expected, 9]).subarray(1, 5)); + controller.close(); + }; + if (source === 'async-stream') setTimeout(send, 5); else send(); + }}); + } + if (!object) { + object = type === 'Request' + ? new receiverRealm.Request(bodyURL, {method: 'POST', body, duplex: 'half'}) + : new receiverRealm.Response(body); + } + if (source.startsWith('cloned-')) object = object.clone(); + if (source === 'changed-prototype') { + Object.setPrototypeOf(object, methodRealm[type].prototype); + } + const result = await methodRealm[type].prototype[method].call(object); + const buffer = method === 'bytes' ? result.buffer : result; + check(Object.getPrototypeOf(buffer) === expectedBufferPrototype, label + ': buffer realm'); + if (method === 'bytes') { + check(Object.getPrototypeOf(result) === expectedBytesPrototype, label + ': bytes realm'); + } + const bytes = new Uint8Array(buffer); + check(bytes.length === expected.length && bytes.every((byte, index) => byte === expected[index]), label + ': contents'); + check(object.bodyUsed === (source !== 'null'), label + ': bodyUsed'); + checked++; + } catch (error) { + errors.push(label + ': ' + String(error)); + } + } + } + if (scenario !== 'network') { + for (const method of ['arrayBuffer', 'bytes']) { + const reason = new receiverRealm.Error('stream failure'); + const body = new receiverRealm.ReadableStream({start(controller) {controller.error(reason)}}); + const object = type === 'Request' + ? new receiverRealm.Request(bodyURL, {method: 'POST', body, duplex: 'half'}) + : new receiverRealm.Response(body); + try { + await methodRealm[type].prototype[method].call(object); + errors.push(direction + '/' + type + '/' + method + ': error stream fulfilled'); + } catch (error) { + check(error === reason, direction + '/' + type + '/' + method + ': error identity'); + } + checked++; + } + } + } + } + } finally { + frame.remove(); + } + return {errors, checked}; +} diff --git a/moli-core/tests/web_apis.rs b/moli-core/tests/web_apis.rs index 508d29855c..113d120f5a 100644 --- a/moli-core/tests/web_apis.rs +++ b/moli-core/tests/web_apis.rs @@ -20,6 +20,8 @@ mod callback_cleanup; mod event_dispatch; #[path = "web_apis/fetch_body_native.rs"] mod fetch_body_native; +#[path = "web_apis/fetch_body_realm.rs"] +mod fetch_body_realm; #[path = "web_apis/pipe_disturbed.rs"] mod pipe_disturbed; #[path = "web_apis/request_init.rs"] diff --git a/moli-core/tests/web_apis/fetch_body_realm.rs b/moli-core/tests/web_apis/fetch_body_realm.rs new file mode 100644 index 0000000000..0d33130bd1 --- /dev/null +++ b/moli-core/tests/web_apis/fetch_body_realm.rs @@ -0,0 +1,38 @@ +use super::*; + +async fn assert_binary_body_realms(scenario: &str, expected_cases: usize) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let expected = b"
naive-\xe4\xbd\xa0\xe5\xa5\xbd
"; + let source = format!( + "{}\nrunFetchBodyRealmProbe({}, {}, {}).then(finish, error => finish({{error: String(error)}}));", + include_str!("../fixtures/runtime/fetch_body_realm.js"), + serde_json::to_string(scenario)?, + serde_json::to_string(&server.url("/streaming/chunked-html"))?, + serde_json::to_string(expected.as_slice())?, + ); + for target in ["window", "child"] { + let observed = tokio::time::timeout( + Duration::from_secs(20), + super::event_dispatch::run_probe(&browser, &server, target, &source), + ) + .await??; + assert_eq!( + observed, + serde_json::json!({"errors": [], "checked": expected_cases}), + "{scenario}/{target}", + ); + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn fetch_binary_body_results_use_receiver_realm() -> Result<()> { + assert_binary_body_realms("memory", 64).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn fetch_binary_network_body_results_use_receiver_realm() -> Result<()> { + assert_binary_body_realms("network", 24).await +} diff --git a/moli-renderer-v8/src/network_host/body_source.rs b/moli-renderer-v8/src/network_host/body_source.rs index cd30d1d68f..9cc3950a59 100644 --- a/moli-renderer-v8/src/network_host/body_source.rs +++ b/moli-renderer-v8/src/network_host/body_source.rs @@ -240,8 +240,8 @@ impl Drop for NetworkBodySourceState { enum PendingBodyMaterializationKind { Text, Json, - ArrayBuffer, - Bytes, + ArrayBuffer(v8::Global), + Bytes(v8::Global), Blob { mime_type: String }, FormData { content_type: String }, } @@ -289,28 +289,41 @@ pub(in crate::network_host) enum NetworkBodyConsumptionKind { FormData { content_type: String }, } -impl From for PendingBodyMaterializationKind { - fn from(kind: NetworkBodyConsumptionKind) -> Self { +impl PendingBodyMaterializationKind { + fn new<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + kind: NetworkBodyConsumptionKind, + ) -> Self { match kind { NetworkBodyConsumptionKind::Text => Self::Text, NetworkBodyConsumptionKind::Json => Self::Json, - NetworkBodyConsumptionKind::ArrayBuffer => Self::ArrayBuffer, - NetworkBodyConsumptionKind::Bytes => Self::Bytes, + NetworkBodyConsumptionKind::ArrayBuffer | NetworkBodyConsumptionKind::Bytes => { + // Fetch explicitly allocates binary results in the Body + // receiver's relevant realm, even for a borrowed method or a + // stream created in another realm. Retain it until completion. + let realm = object + .get_creation_context(scope) + .expect("body owner must have a creation context"); + let realm = v8::Global::new(scope, realm); + if matches!(kind, NetworkBodyConsumptionKind::ArrayBuffer) { + Self::ArrayBuffer(realm) + } else { + Self::Bytes(realm) + } + } NetworkBodyConsumptionKind::Blob { mime_type } => Self::Blob { mime_type }, NetworkBodyConsumptionKind::FormData { content_type } => { Self::FormData { content_type } } } } -} - -impl PendingBodyMaterializationKind { fn clone_for_ready(&self) -> Self { match self { Self::Text => Self::Text, Self::Json => Self::Json, - Self::ArrayBuffer => Self::ArrayBuffer, - Self::Bytes => Self::Bytes, + Self::ArrayBuffer(realm) => Self::ArrayBuffer(realm.clone()), + Self::Bytes(realm) => Self::Bytes(realm.clone()), Self::Blob { mime_type } => Self::Blob { mime_type: mime_type.clone(), }, @@ -1226,6 +1239,7 @@ pub(in crate::network_host) fn consume_filtered_response_internal_body_value_fro { return Some(consume_readable_body_stream( scope, + object, stream, kind, Some(chunk_callback), @@ -1280,7 +1294,7 @@ fn consume_network_body_value_from_source_inner<'s>( if let Some(chunk_callback) = chunk_callback && let Some(stream) = readable_body_stream_from_object(scope, object) { - return consume_readable_body_stream(scope, stream, kind, Some(chunk_callback)); + return consume_readable_body_stream(scope, object, stream, kind, Some(chunk_callback)); } let Some(id) = registry_body_source_id(scope, source) else { return (NetworkBodyConsumption::Failed, None); @@ -1289,7 +1303,7 @@ fn consume_network_body_value_from_source_inner<'s>( return (NetworkBodyConsumption::Failed, None); }; let promise = resolver.get_promise(scope); - let materialization_kind = PendingBodyMaterializationKind::from(kind); + let materialization_kind = PendingBodyMaterializationKind::new(scope, object, kind); let mut ready = None; let mut rejected = None; if let Some(host) = context_host_mut(scope) { @@ -1339,7 +1353,7 @@ fn consume_network_body_value_from_source_inner<'s>( if explicit_source.is_none() && let Some(stream) = readable_body_stream_from_object(scope, object) { - return consume_readable_body_stream(scope, stream, kind, chunk_callback); + return consume_readable_body_stream(scope, object, stream, kind, chunk_callback); } let bytes = match try_network_body_bytes_from_storage(scope, source, true) { @@ -1350,7 +1364,8 @@ fn consume_network_body_value_from_source_inner<'s>( Ok(None) => return (NetworkBodyConsumption::Failed, None), Err(_) => return (NetworkBodyConsumption::Failed, None), }; - match body_materialization_value(scope, &bytes, PendingBodyMaterializationKind::from(kind)) { + let kind = PendingBodyMaterializationKind::new(scope, object, kind); + match body_materialization_value(scope, &bytes, kind) { Ok(value) => (NetworkBodyConsumption::Ready(value), None), Err(error) => (NetworkBodyConsumption::Rejected(error), None), } @@ -1775,17 +1790,11 @@ fn body_materialization_value<'s>( .unwrap_or_else(|| v8::undefined(&scope).into()) }) } - PendingBodyMaterializationKind::ArrayBuffer => { - blob::array_buffer_from_bytes(scope, bytes.to_vec()) - .map(Into::into) - .ok_or_else(|| v8::undefined(scope).into()) + PendingBodyMaterializationKind::ArrayBuffer(realm) => { + binary_body_materialization_value(scope, bytes, realm, false) } - PendingBodyMaterializationKind::Bytes => { - let byte_len = bytes.len(); - blob::array_buffer_from_bytes(scope, bytes.to_vec()) - .and_then(|buffer| v8::Uint8Array::new(scope, buffer, 0, byte_len)) - .map(Into::into) - .ok_or_else(|| v8::undefined(scope).into()) + PendingBodyMaterializationKind::Bytes(realm) => { + binary_body_materialization_value(scope, bytes, realm, true) } PendingBodyMaterializationKind::Blob { mime_type } => { blob::build_blob_object(scope, bytes.to_vec(), mime_type) @@ -1821,6 +1830,30 @@ fn body_materialization_value<'s>( } } +fn binary_body_materialization_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + bytes: &[u8], + realm: v8::Global, + as_uint8_array: bool, +) -> Result, v8::Local<'s, v8::Value>> { + let realm = v8::Local::new(scope, realm); + let value = { + let scope = &mut v8::ContextScope::new(scope, realm); + blob::array_buffer_from_bytes(scope, bytes.to_vec()) + .and_then(|buffer| { + if as_uint8_array { + v8::Uint8Array::new(scope, buffer, 0, bytes.len()).map(Into::into) + } else { + Some(v8::Local::::from(buffer)) + } + }) + .map(|value| v8::Global::new(scope, value)) + }; + value + .map(|value| v8::Local::new(scope, value)) + .ok_or_else(|| v8::undefined(scope).into()) +} + fn registry_body_source_id<'s>( scope: &mut v8::PinScope<'s, '_>, source: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/network_host/body_source/stream_consumer.rs b/moli-renderer-v8/src/network_host/body_source/stream_consumer.rs index 532d9f5fc9..20fb56d4d5 100644 --- a/moli-renderer-v8/src/network_host/body_source/stream_consumer.rs +++ b/moli-renderer-v8/src/network_host/body_source/stream_consumer.rs @@ -10,6 +10,7 @@ use crate::context_bootstrap::{ use crate::util::set_null_prototype; const STREAM: &str = "__moliBodyConsumerStream"; +const BODY_OWNER: &str = "__moliBodyConsumerOwner"; const RESOLVER: &str = "__moliBodyConsumerResolver"; const CHUNKS: &str = "__moliBodyConsumerChunks"; const KIND: &str = "__moliBodyConsumerKind"; @@ -26,6 +27,8 @@ const REASON: &str = "__moliBodyConsumerReason"; #[derive(WebApiObject)] #[webapi(plain)] struct BodyConsumerDeclaration<'scope> { + #[webapi(slot = BODY_OWNER)] + body_owner: v8::Local<'scope, v8::Object>, #[webapi(slot = STREAM)] stream: v8::Local<'scope, v8::Object>, #[webapi(slot = RESOLVER)] @@ -134,6 +137,7 @@ impl<'s> Consumer<'s> { pub(super) fn consume_readable_body_stream<'s>( scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, stream: v8::Local<'s, v8::Object>, kind: NetworkBodyConsumptionKind, chunk_callback: Option>, @@ -157,9 +161,17 @@ pub(super) fn consume_readable_body_stream<'s>( let chunks = v8::Array::new(scope, 0); set_null_prototype(scope, chunks.into()); let consumer = Consumer( - BodyConsumerDeclaration::new(stream, resolver.into(), chunks, kind, mime, chunk_callback) - .bind(scope) - .expect("body consumer declaration must bind"), + BodyConsumerDeclaration::new( + object, + stream, + resolver.into(), + chunks, + kind, + mime, + chunk_callback, + ) + .bind(scope) + .expect("body consumer declaration must bind"), ); let callbacks = [ v8::Function::builder(chunk_callback_step) @@ -295,20 +307,24 @@ fn materialize_callback<'s>( .to_rust_string_lossy(scope) .as_str() { - "text" => PendingBodyMaterializationKind::Text, - "json" => PendingBodyMaterializationKind::Json, - "arrayBuffer" => PendingBodyMaterializationKind::ArrayBuffer, - "bytes" => PendingBodyMaterializationKind::Bytes, - "blob" => PendingBodyMaterializationKind::Blob { mime_type: mime }, - "formData" => PendingBodyMaterializationKind::FormData { content_type: mime }, + "text" => NetworkBodyConsumptionKind::Text, + "json" => NetworkBodyConsumptionKind::Json, + "arrayBuffer" => NetworkBodyConsumptionKind::ArrayBuffer, + "bytes" => NetworkBodyConsumptionKind::Bytes, + "blob" => NetworkBodyConsumptionKind::Blob { mime_type: mime }, + "formData" => NetworkBodyConsumptionKind::FormData { content_type: mime }, _ => unreachable!("body materialization kind is private"), }; + let object = v8::Local::try_from(consumer.value(scope, BODY_OWNER)) + .expect("body materialization owner must exist"); + let kind = PendingBodyMaterializationKind::new(scope, object, kind); resolve_body_materialization(scope, resolver, bytes, kind); } else { let error = consumer.value(scope, REASON); let _ = resolver.reject(scope, error); } for slot in [ + BODY_OWNER, STREAM, RESOLVER, CHUNKS,