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(); + } +})();