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).
This commit is contained in:
ldm0
2026-09-14 17:03:03 +08:00
parent a5f1fee323
commit f08888b57d
6 changed files with 217 additions and 34 deletions
@@ -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
+92
View File
@@ -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 = '<!doctype html><meta charset=utf-8><body>';
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};
}
+2
View File
@@ -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"]
@@ -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"<!doctype html><html><body><main id=\"stream\">naive-\xe4\xbd\xa0\xe5\xa5\xbd</main><script>document.body.setAttribute('data-stream-script','seen');</script></body></html>";
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
}
@@ -240,8 +240,8 @@ impl Drop for NetworkBodySourceState {
enum PendingBodyMaterializationKind {
Text,
Json,
ArrayBuffer,
Bytes,
ArrayBuffer(v8::Global<v8::Context>),
Bytes(v8::Global<v8::Context>),
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<NetworkBodyConsumptionKind> 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<v8::Context>,
as_uint8_array: bool,
) -> Result<v8::Local<'s, v8::Value>, 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::<v8::Value>::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>,
@@ -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<v8::Local<'s, v8::Function>>,
@@ -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,