From 137a8e3859357de139458d99f4eb77ad2ef2d117 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 28 Sep 2026 07:35:28 +0800 Subject: [PATCH] fix(fetch): preserve popup identity for body completion tasks Bind internal browser tasks to the callback's registered Window identity and creation context. A popup opened by an iframe shares the opener's V8 context; authorizing that global as a public Window receiver could discard the popup's networking task and leave Response body promises pending. Keep registry retirement checks and public timer receiver authorization. Cover all six Body methods, conversion errors, and networking task boundaries for main-window and iframe popup openers. --- moli-renderer-v8/src/host/timers.rs | 27 +++++++--- .../src/script_vm/tests/body_completion.rs | 26 +++++++++ .../tests/fixtures/body-completion-popups.js | 53 +++++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/body-completion-popups.js diff --git a/moli-renderer-v8/src/host/timers.rs b/moli-renderer-v8/src/host/timers.rs index ece0521431..1595975b2e 100644 --- a/moli-renderer-v8/src/host/timers.rs +++ b/moli-renderer-v8/src/host/timers.rs @@ -653,14 +653,29 @@ impl HostTimeoutScheduler { let Some(callback) = scheduled_timer_function(scope, callback, receiver) else { return 0; }; - let Some(owner) = scheduled_timer_owner_for_target( - scope, - HostTimerOwner::Window, - Some(receiver), - context, - ) else { + let Some(identity) = callback.relevant_identity else { return 0; }; + if !context_host_ptr_from_global_bridge(scope).is_some_and(|host_ptr| { + unsafe { &*host_ptr }.window_execution_context_identity_is_current(identity) + }) { + return 0; + } + // Internal browser callbacks are created in their task destination. + // Capture that exact identity instead of authorizing the shared V8 + // global as a public Window receiver: a popup can alias a child realm + // while owning a different Window and task lifetime. + let binding = WindowExecutionContextBinding::new( + identity.owner(), + identity.dispatch_scope(), + identity.realm_token(), + callback.relevant_context.clone(), + ); + let owner = ScheduledTimerOwner::Window(ScheduledWindowTimerTarget::new( + identity.owner(), + identity.dispatch_scope(), + Some(binding), + )); self.scheduler .schedule_after( ScheduledTimerTask { diff --git a/moli-renderer-v8/src/script_vm/tests/body_completion.rs b/moli-renderer-v8/src/script_vm/tests/body_completion.rs index 7930322de4..813a09d79a 100644 --- a/moli-renderer-v8/src/script_vm/tests/body_completion.rs +++ b/moli-renderer-v8/src/script_vm/tests/body_completion.rs @@ -42,6 +42,32 @@ async fn body_completion_queues_native_network_success_and_errors() { assert_eq!(result["failures"], serde_json::json!([]), "{result}"); } +#[tokio::test(flavor = "current_thread")] +async fn body_completion_tasks_reach_popups_opened_by_child_windows() { + let server = super::fetch_resource_timing::timing_server().await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = + new_storage_page_task_executor_test_vm_with_loader(&format!("{}/", server.origin), &loader); + let fixture = include_str!("../../../tests/fixtures/body-completion-popups.js") + .trim() + .trim_end_matches(';'); + vm.eval(&format!( + "globalThis.bodyPopupResult = null; ({fixture}).then(value => bodyPopupResult = value, error => bodyPopupResult = JSON.stringify({{error:String(error)}}));" + )).unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(bodyPopupResult !== null)", + "true", + "popup Body completion task probe", + ) + .await; + let result: serde_json::Value = + serde_json::from_str(&vm.eval("bodyPopupResult").unwrap()).unwrap(); + assert_eq!(result["total"], 12, "{result}"); + assert_eq!(result["failures"], serde_json::json!([]), "{result}"); +} + #[tokio::test(flavor = "current_thread")] async fn body_completion_tasks_follow_receiver_realm_lifetime() { let loader = static_http_loader(std::iter::empty::()); diff --git a/moli-renderer-v8/tests/fixtures/body-completion-popups.js b/moli-renderer-v8/tests/fixtures/body-completion-popups.js new file mode 100644 index 0000000000..eeeb3e0906 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/body-completion-popups.js @@ -0,0 +1,53 @@ +(async () => { + async function probe(origin) { + const popup = window.open('about:blank'); + const failures = []; + let total = 0; + try { + for (const method of ['bytes', 'arrayBuffer', 'blob', 'text', 'json', 'formData']) { + total++; + try { + const response = await popup.fetch(origin + '/seen?target=absent'); + let settled = false; + const consumed = response[method]().then( + value => { settled = true; return {value}; }, + error => { settled = true; return {error}; }, + ); + for (let i = 0; i < 8; i++) await Promise.resolve(); + if (settled) throw new Error('conversion did not wait for a fetch task'); + const output = await consumed; + if (method === 'formData') { + // Conversion errors belong to the invoked Body method's realm. + const MethodTypeError = response.formData.constructor('return TypeError')(); + if (!(output.error instanceof MethodTypeError)) throw new Error('expected a conversion TypeError'); + } else { + if ('error' in output) throw output.error; + const actual = method === 'json' ? String(output.value) + : method === 'text' ? output.value + : method === 'blob' ? await output.value.text() + : new TextDecoder().decode(output.value); + if (actual !== 'false') throw new Error('unexpected body: ' + actual); + } + } catch (error) { + failures.push(method + ': ' + error); + } + } + } finally { + popup.close(); + } + return {total, failures}; + } + const origin = location.origin; + const main = await probe(origin); + const frame = document.createElement('iframe'); + document.body.append(frame); + try { + const child = await frame.contentWindow.eval('(' + probe.toString() + ')(' + JSON.stringify(origin) + ')'); + return JSON.stringify({ + total: main.total + child.total, + failures: main.failures.map(value => 'main: ' + value).concat(child.failures.map(value => 'child: ' + value)), + }); + } finally { + frame.remove(); + } +})();