fix(script): queue Window promise rejection notifications as DOM tasks

Promise rejection events were dispatched during microtask checkpoints or
synchronously when a late handler was attached. Queue both events on the
shared DOM-manipulation source, snapshot each realm's notification batch
at the checkpoint, and recheck handlers immediately before notification.

Keep V8 payloads in the originating Host, authorize exact Document owners
through the Page dispatcher, and restore the registered Window realm for
each event. Retire stale payloads and clear retained handles at teardown.

Exercise FIFO order across checkpoints, late handling, realm routing, and
Document replacement through the production selected-task harness. Update
existing notification fixtures and assert that suppressed rejections leave
no notification task queued.

Validation:
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- 254 related WPT cases: all 240 previously passing cases remain passing
- promise-rejection-events.html: 40/43 subtests pass, up from 33/43
- cargo nextest run --no-fail-fast: 17,849 passed, 13 skipped
This commit is contained in:
ldm0
2026-09-13 17:02:39 +08:00
parent 42717d54f0
commit c66bd58e98
22 changed files with 714 additions and 68 deletions
@@ -303,6 +303,8 @@ impl JsContextHost {
super::file_entry_file_callbacks::FileEntryFileCallbackState::default(),
script_preparation_errors:
super::script_preparation_errors::ScriptPreparationErrorState::default(),
promise_rejection_tasks:
super::promise_rejection_tasks::PromiseRejectionTaskState::default(),
pending_selectedcontent_updates: HashSet::new(),
user_interaction_tasks:
super::user_interaction_tasks::UserInteractionTaskState::default(),
@@ -752,6 +754,16 @@ impl JsContextHost {
.script_preparation_error()
}
pub(crate) fn page_promise_rejection_sender(
&self,
) -> crate::page_task_queue::RendererPagePromiseRejectionSender {
self.page_task_capabilities
.get()
.expect("a live Page Window must install its task capabilities before promise rejection admission")
.dom_manipulation()
.promise_rejection()
}
pub(crate) fn page_file_reading_sender(
&self,
) -> crate::page_task_queue::RendererPageFileReadingSender {
@@ -101,6 +101,7 @@ mod host_loads;
mod image_decodes;
mod image_loads;
mod image_resources;
mod promise_rejection_tasks;
mod script_preparation_errors;
pub(crate) use image_resources::{
CssImageResourceAdmission, CssImageResourceRequestIdentity, ImageResponseDescriptor,
@@ -919,6 +920,7 @@ pub(crate) struct JsContextHost {
misc_platform_api_tasks: misc_platform_api_tasks::MiscPlatformApiTaskState,
file_entry_file_callbacks: file_entry_file_callbacks::FileEntryFileCallbackState,
script_preparation_errors: script_preparation_errors::ScriptPreparationErrorState,
promise_rejection_tasks: promise_rejection_tasks::PromiseRejectionTaskState,
pending_selectedcontent_updates: HashSet<DomHandle>,
user_interaction_tasks: user_interaction_tasks::UserInteractionTaskState,
pending_image_load_events: HashMap<DomHandle, PendingImageLoadEvent>,
@@ -1320,6 +1322,8 @@ impl JsContextHost {
return;
}
self.page_context_resources_closed = true;
self.promise_rejection_tasks =
promise_rejection_tasks::PromiseRejectionTaskState::default();
self.retire_all_document_resource_loaders();
self.page_default_context = None;
self.v8_finalizers.clear_for_context_teardown();
@@ -0,0 +1,72 @@
use crate::{
page_task_queue::{RendererPagePromiseRejectionTaskId, RendererPagePromiseRejectionTaskKind},
script_vm::PromiseRejectionTaskPayload,
};
use super::window_document_tasks::{ExactWindowDocumentTaskLedger, PendingExactWindowDocumentTask};
use super::{JsContextHost, WindowDocumentTaskTarget};
pub(super) type PromiseRejectionTaskState = ExactWindowDocumentTaskLedger<
RendererPagePromiseRejectionTaskId,
RendererPagePromiseRejectionTaskKind,
PromiseRejectionTaskPayload,
>;
impl JsContextHost {
pub(crate) fn queue_promise_rejection_task(
&mut self,
target: WindowDocumentTaskTarget,
kind: RendererPagePromiseRejectionTaskKind,
payload: PromiseRejectionTaskPayload,
) -> bool {
let task_id = self
.promise_rejection_tasks
.allocate_task_id(RendererPagePromiseRejectionTaskId::from_raw);
self.promise_rejection_tasks
.push(PendingExactWindowDocumentTask::new(
task_id, target, kind, payload,
));
if self
.page_promise_rejection_sender()
.send(target, task_id, kind)
.is_ok()
{
return true;
}
self.promise_rejection_tasks
.remove_exact(task_id, target, kind);
false
}
pub(crate) fn current_pending_promise_rejection_target(
&self,
task_id: RendererPagePromiseRejectionTaskId,
) -> Option<(
WindowDocumentTaskTarget,
RendererPagePromiseRejectionTaskKind,
)> {
let pending = self.promise_rejection_tasks.pending(task_id)?;
let target = self.current_window_document_task_target_for_dispatch_scope(
pending.target().dispatch_scope(),
)?;
Some((target, pending.kind()))
}
pub(crate) fn take_pending_promise_rejection_task(
&mut self,
task_id: RendererPagePromiseRejectionTaskId,
target: WindowDocumentTaskTarget,
kind: RendererPagePromiseRejectionTaskKind,
) -> Option<PromiseRejectionTaskPayload> {
self.promise_rejection_tasks
.remove_exact(task_id, target, kind)
.map(PendingExactWindowDocumentTask::into_payload)
}
pub(crate) fn discard_pending_promise_rejection_task(
&mut self,
task_id: RendererPagePromiseRejectionTaskId,
) -> bool {
self.promise_rejection_tasks.remove(task_id).is_some()
}
}
+7
View File
@@ -38,6 +38,7 @@ mod popup_close;
mod popup_load_event;
mod post_domcontentloaded_runtime;
mod post_parse_owner_work;
mod promise_rejection;
mod ready_signal;
mod rendering_update;
mod resource_completions;
@@ -386,6 +387,12 @@ pub(crate) use self::post_parse_owner_work::{
PostParseLifecycleQueueStats, PostParseLifecycleWork, PostParsePageOwnedWork,
post_parse_lifecycle_queue_stats,
};
pub(crate) use self::promise_rejection::{
PagePromiseRejectionTargetEffect, PagePromiseRejectionTurnAction,
PagePromiseRejectionTurnOutcome, RendererPagePromiseRejectionOwner,
RendererPagePromiseRejectionSender, RendererPagePromiseRejectionTask,
RendererPagePromiseRejectionTaskId, RendererPagePromiseRejectionTaskKind,
};
use self::ready_signal::RendererPageTaskReadySignal;
#[cfg(test)]
pub(crate) use self::rendering_update::RendererPageRenderingUpdateHead;
@@ -34,6 +34,10 @@ use super::{
RendererPagePopupLoadEventOwner, RendererPagePopupLoadEventSender,
RendererPagePopupLoadEventTask,
},
promise_rejection::{
RendererPagePromiseRejectionOwner, RendererPagePromiseRejectionSender,
RendererPagePromiseRejectionTask,
},
script_preparation_error::{
RendererPageScriptPreparationErrorOwner, RendererPageScriptPreparationErrorSender,
RendererPageScriptPreparationErrorTask,
@@ -72,6 +76,7 @@ pub(crate) enum RendererPageDomManipulationOwner {
ElementToggle(RendererPageElementToggleEventOwner),
FileEntryFileCallback(RendererPageFileEntryFileCallbackOwner),
ScriptPreparationError(RendererPageScriptPreparationErrorOwner),
PromiseRejection(RendererPagePromiseRejectionOwner),
ImageLoadEvent(RendererPageImageLoadEventOwner),
PopupLoadEvent(RendererPagePopupLoadEventOwner),
PopupClose(RendererPagePopupCloseOwner),
@@ -90,6 +95,7 @@ pub(crate) enum RendererPageDomManipulationTask {
ElementToggle(RendererPageElementToggleEventTask),
FileEntryFileCallback(RendererPageFileEntryFileCallbackTask),
ScriptPreparationError(RendererPageScriptPreparationErrorTask),
PromiseRejection(RendererPagePromiseRejectionTask),
ImageLoadEvent(RendererPageImageLoadEventTask),
PopupLoadEvent(RendererPagePopupLoadEventTask),
PopupClose(RendererPagePopupCloseTask),
@@ -118,6 +124,9 @@ impl RendererPageDomManipulationTask {
Self::ScriptPreparationError(task) => {
RendererPageDomManipulationOwner::ScriptPreparationError(task.owner())
}
Self::PromiseRejection(task) => {
RendererPageDomManipulationOwner::PromiseRejection(task.owner())
}
Self::ImageLoadEvent(task) => {
RendererPageDomManipulationOwner::ImageLoadEvent(task.owner())
}
@@ -158,6 +167,7 @@ pub(crate) enum PageDomManipulationTurnAction {
ElementToggle(super::PageElementToggleEventTurnAction),
FileEntryFileCallback(super::PageFileEntryFileCallbackTurnAction),
ScriptPreparationError(super::PageScriptPreparationErrorTurnAction),
PromiseRejection(super::PagePromiseRejectionTurnAction),
ImageLoadEvent(super::PageImageLoadEventTurnAction),
PopupLoadEvent(super::PagePopupLoadEventTurnAction),
PopupClose(super::PagePopupCloseTurnAction),
@@ -216,6 +226,10 @@ impl RendererPageDomManipulationSender {
RendererPageScriptPreparationErrorSender::new(self.route.clone(), self.root_document)
}
pub(crate) fn promise_rejection(&self) -> RendererPagePromiseRejectionSender {
RendererPagePromiseRejectionSender::new(self.route.clone(), self.root_document)
}
pub(crate) fn image_load_event(&self) -> RendererPageImageLoadEventSender {
RendererPageImageLoadEventSender::new(self.route.clone(), self.root_document)
}
@@ -0,0 +1,79 @@
use crate::{
native_bridge::WindowDocumentTaskTarget,
runtime::{PageOwnerTurnOutcome, RendererDocumentToken},
};
use super::{
PageWindowDocumentTaskTargetEffect, PageWindowDocumentTaskTurnAction,
RendererPageWindowDocumentTask, RendererPageWindowDocumentTaskOwner,
dom_manipulation::{RendererPageDomManipulationRoute, RendererPageDomManipulationTask},
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct RendererPagePromiseRejectionTaskId(u64);
impl RendererPagePromiseRejectionTaskId {
pub(crate) const fn from_raw(raw: u64) -> Self {
Self(raw)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RendererPagePromiseRejectionTaskKind {
Unhandled,
Handled,
}
pub(crate) type RendererPagePromiseRejectionOwner = RendererPageWindowDocumentTaskOwner;
pub(crate) type RendererPagePromiseRejectionTask = RendererPageWindowDocumentTask<
RendererPagePromiseRejectionTaskId,
RendererPagePromiseRejectionTaskKind,
>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct RendererPagePromiseRejectionRouteClosed;
/// Both promise rejection events use the HTML DOM-manipulation FIFO. The
/// checkpoint only queues a notification; it must never run its listeners.
#[derive(Clone, Debug)]
pub(crate) struct RendererPagePromiseRejectionSender {
route: RendererPageDomManipulationRoute,
root_document: RendererDocumentToken,
}
impl RendererPagePromiseRejectionSender {
pub(super) fn new(
route: RendererPageDomManipulationRoute,
root_document: RendererDocumentToken,
) -> Self {
Self {
route,
root_document,
}
}
pub(crate) fn send(
&self,
target: WindowDocumentTaskTarget,
task_id: RendererPagePromiseRejectionTaskId,
kind: RendererPagePromiseRejectionTaskKind,
) -> Result<(), RendererPagePromiseRejectionRouteClosed> {
self.route
.send(RendererPageDomManipulationTask::PromiseRejection(
RendererPagePromiseRejectionTask::new(
RendererPagePromiseRejectionOwner::new(self.root_document, target),
task_id,
kind,
),
))
.map_err(|_| RendererPagePromiseRejectionRouteClosed)
}
}
pub(crate) type PagePromiseRejectionTargetEffect = PageWindowDocumentTaskTargetEffect;
pub(crate) type PagePromiseRejectionTurnAction = PageWindowDocumentTaskTurnAction<
RendererPagePromiseRejectionTaskId,
RendererPagePromiseRejectionTaskKind,
>;
pub(crate) type PagePromiseRejectionTurnOutcome =
PageOwnerTurnOutcome<PagePromiseRejectionTurnAction>;
+1
View File
@@ -113,6 +113,7 @@ pub(crate) use self::page_vm::AuthorizedCurrentPageNavigationApiTask;
pub(crate) use self::page_vm::AuthorizedCurrentPageOpfsTask;
pub(crate) use self::page_vm::AuthorizedCurrentPagePopupClose;
pub(crate) use self::page_vm::AuthorizedCurrentPagePopupLoadEvent;
pub(crate) use self::page_vm::AuthorizedCurrentPagePromiseRejection;
pub(crate) use self::page_vm::AuthorizedCurrentPageRenderingUpdate;
pub(crate) use self::page_vm::AuthorizedCurrentPageScriptPreparationError;
pub(crate) use self::page_vm::AuthorizedCurrentPageServiceWorkerClientMessage;
@@ -133,6 +133,7 @@ mod page_parser_async_module_admission;
mod page_parser_owned_module_continuation;
mod page_popup_close;
mod page_popup_load_event;
mod page_promise_rejection;
mod page_rendering_update;
#[cfg(test)]
mod page_rendering_update_body_test_support;
@@ -225,6 +226,7 @@ pub(crate) use page_navigation_api_task::AuthorizedCurrentPageNavigationApiTask;
pub(crate) use page_opfs_task::AuthorizedCurrentPageOpfsTask;
pub(crate) use page_popup_close::AuthorizedCurrentPagePopupClose;
pub(crate) use page_popup_load_event::AuthorizedCurrentPagePopupLoadEvent;
pub(crate) use page_promise_rejection::AuthorizedCurrentPagePromiseRejection;
pub(crate) use page_rendering_update::AuthorizedCurrentPageRenderingUpdate;
pub(crate) use page_resource_completion::{
AuthorizedCurrentChildDocumentLoadCompletion, AuthorizedCurrentChildModuleFetchCompletion,
@@ -32,6 +32,9 @@ impl PageVm {
.map(|outcome| {
outcome.map_action(PageDomManipulationTurnAction::ScriptPreparationError)
}),
RendererPageDomManipulationTask::PromiseRejection(task) => self
.apply_selected_page_promise_rejection_turn(task)
.map(|outcome| outcome.map_action(PageDomManipulationTurnAction::PromiseRejection)),
RendererPageDomManipulationTask::ImageLoadEvent(task) => self
.apply_selected_page_image_load_event_turn(task)
.map(|outcome| outcome.map_action(PageDomManipulationTurnAction::ImageLoadEvent)),
@@ -34,6 +34,9 @@ impl PageVm {
PageDomManipulationTurnAction::ScriptPreparationError(action) => {
action.into_page_task_completion()
}
PageDomManipulationTurnAction::PromiseRejection(action) => {
action.into_page_task_completion()
}
PageDomManipulationTurnAction::ImageLoadEvent(action) => {
action.into_page_task_completion()
}
@@ -20,6 +20,7 @@ pub(crate) enum PageDomManipulationTestFamily {
ElementToggle,
FileEntryFileCallback,
ScriptPreparationError,
PromiseRejection,
ImageLoadEvent,
PopupLoadEvent,
ConnectedStyleEvent,
@@ -50,6 +51,9 @@ impl PageDomManipulationTestFamily {
) | (
Self::ScriptPreparationError,
RendererPageDomManipulationOwner::ScriptPreparationError(_)
) | (
Self::PromiseRejection,
RendererPageDomManipulationOwner::PromiseRejection(_)
) | (
Self::ImageLoadEvent,
RendererPageDomManipulationOwner::ImageLoadEvent(_)
@@ -0,0 +1,64 @@
use crate::page_task_queue::{
PagePromiseRejectionTargetEffect, PagePromiseRejectionTurnAction,
PagePromiseRejectionTurnOutcome, RendererPagePromiseRejectionTask,
};
use super::{
AuthorizedCurrentWindowDocumentTask, IntoPageTaskCompletion, PageTaskCompletion, PageVm,
};
impl IntoPageTaskCompletion for PagePromiseRejectionTurnAction {
fn into_page_task_completion(self) -> PageTaskCompletion {
match self.target_effect {
PagePromiseRejectionTargetEffect::DispatchedToCurrentOwner => {
PageTaskCompletion::CallbackCompletion
}
PagePromiseRejectionTargetEffect::CurrentOwnerHadNoEventTarget => {
PageTaskCompletion::CheckpointOnly
}
PagePromiseRejectionTargetEffect::DiscardedStaleOwner { .. } => {
PageTaskCompletion::NoCompletion
}
}
}
}
pub(crate) type AuthorizedCurrentPagePromiseRejection =
AuthorizedCurrentWindowDocumentTask<RendererPagePromiseRejectionTask>;
impl PageVm {
pub(in crate::runtime) fn apply_selected_page_promise_rejection_turn(
&mut self,
task: RendererPagePromiseRejectionTask,
) -> anyhow::Result<PagePromiseRejectionTurnOutcome> {
let owner = task.owner();
let task_id = task.task_id();
let kind = task.kind();
let current = self.vm().current_pending_promise_rejection_owner(
task_id,
self.document_lifecycle.identity().document,
);
let target_effect =
match self.authorize_current_window_document_task(task, owner, kind, current) {
Ok(authorization) => self
.vm_mut()
.apply_current_promise_rejection_body(authorization)?,
Err(stale) => {
if stale.may_discard_local_payload() {
self.vm_mut().discard_stale_promise_rejection_task(task_id);
}
PagePromiseRejectionTargetEffect::DiscardedStaleOwner {
current_owner: stale.current_owner(),
}
}
};
Ok(PagePromiseRejectionTurnOutcome::new(
PagePromiseRejectionTurnAction {
owner,
task_id,
kind,
target_effect,
},
))
}
}
@@ -159,6 +159,7 @@ mod opfs;
mod parser_written_script_residence;
mod popup_document_completion;
mod preferred_aspect_ratio;
mod promise_rejection;
mod rendering_update;
mod script_preparation_error;
mod service_worker;
@@ -0,0 +1,49 @@
use super::*;
use crate::page_task_queue::RendererPageDomManipulationTask;
#[tokio::test(flavor = "current_thread")]
async fn promise_rejection_tasks_retire_with_their_exact_document() {
run_page_vm_async_test(async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
for handled in [false, true] {
let (mut page_vm, _, _) = page_vm_with_bound_task_sources_and_owner_wake(
&loader, Url::parse("https://example.com/stale-rejection")?,
);
page_vm.vm_mut().eval(r#"
globalThis.events = [];
onunhandledrejection = event => { events.push(event.type); event.preventDefault(); };
onrejectionhandled = event => events.push(event.type);
globalThis.rejected = Promise.reject('retired document');
"#)?;
if handled {
assert!(page_vm.run_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::DomManipulation(PageDomManipulationTestFamily::PromiseRejection),
&loader,
).await?);
page_vm.vm_mut().eval("rejected.catch(() => {});")?;
}
let task = page_vm.take_dom_manipulation_body_task_for_test(
PageDomManipulationTestFamily::PromiseRejection,
).expect("queued rejection event");
let RendererPageDomManipulationTask::PromiseRejection(rejection) = &task else {
unreachable!("rejection task selector")
};
let task_id = rejection.task_id();
let previous = page_vm.vm().current_main_document_task_owner().unwrap();
page_vm.vm_mut().eval(r#"
document.open(); document.write('<!doctype html><body>replacement</body>'); document.close();
"#)?;
let current = page_vm.vm().current_main_document_task_owner().unwrap();
assert_ne!(previous, current);
page_vm.vm_mut().eval_without_microtask_checkpoint_for_test(
"globalThis.checkpoints = 0; Promise.resolve().then(() => checkpoints++);",
)?;
page_vm.run_claimed_dom_manipulation_task_through_selected_dispatcher_for_test(task, &loader).await?;
assert_eq!(page_vm.vm_mut().eval_without_microtask_checkpoint_for_test("String(checkpoints)")?, "0",
"a retired notification must not checkpoint the replacement document");
assert_eq!(page_vm.vm_mut().eval("String(events.length)")?, if handled { "1" } else { "0" });
assert!(!page_vm.vm_mut().discard_stale_promise_rejection_task(task_id));
}
Ok::<_, anyhow::Error>(())
}).await.expect("promise rejection exact-document retirement");
}
+2
View File
@@ -718,6 +718,7 @@ mod indexed_db_task_body;
mod input_dispatch;
mod input_helpers;
mod inspector;
mod promise_rejection_task;
mod script_preparation_error;
pub(crate) use inspector::{dispatch_inspector_io_owner_wake, dispatch_inspector_main_owner_wake};
mod isolated_worlds;
@@ -869,6 +870,7 @@ pub(crate) use inspector::{
};
use isolated_worlds::*;
pub(crate) use runtime_bindings::PromiseRejectDispatchSlot;
pub(crate) use runtime_bindings::PromiseRejectionTaskPayload;
pub(crate) use runtime_bindings::perform_microtask_checkpoint_and_report_pending_promise_rejections;
use runtime_bindings::*;
pub(crate) use runtime_work::*;
@@ -0,0 +1,65 @@
use anyhow::{Result, anyhow};
use super::{ScriptVm, runtime_bindings::dispatch_promise_rejection_task};
use crate::{
page_task_queue::{
PagePromiseRejectionTargetEffect, RendererPagePromiseRejectionOwner,
RendererPagePromiseRejectionTaskId, RendererPagePromiseRejectionTaskKind,
},
runtime::AuthorizedCurrentPagePromiseRejection,
};
impl ScriptVm {
pub(crate) fn current_pending_promise_rejection_owner(
&self,
task_id: RendererPagePromiseRejectionTaskId,
root_document: crate::runtime::RendererDocumentToken,
) -> Option<(
RendererPagePromiseRejectionOwner,
RendererPagePromiseRejectionTaskKind,
)> {
let (target, kind) = self
._context_host
.borrow()
.current_pending_promise_rejection_target(task_id)?;
Some((
RendererPagePromiseRejectionOwner::new(root_document, target),
kind,
))
}
pub(crate) fn apply_current_promise_rejection_body(
&mut self,
authorization: AuthorizedCurrentPagePromiseRejection,
) -> Result<PagePromiseRejectionTargetEffect> {
let task = authorization.into_task();
let payload = self
._context_host
.borrow_mut()
.take_pending_promise_rejection_task(task.task_id(), task.owner().target(), task.kind())
.ok_or_else(|| anyhow!("authorized promise rejection lost its exact payload"))?;
self.with_default_context_scope(|scope, _| {
Ok(
if dispatch_promise_rejection_task(
scope,
task.owner().target(),
task.kind(),
payload,
) {
PagePromiseRejectionTargetEffect::DispatchedToCurrentOwner
} else {
PagePromiseRejectionTargetEffect::CurrentOwnerHadNoEventTarget
},
)
})
}
pub(crate) fn discard_stale_promise_rejection_task(
&mut self,
task_id: RendererPagePromiseRejectionTaskId,
) -> bool {
self._context_host
.borrow_mut()
.discard_pending_promise_rejection_task(task_id)
}
}
@@ -1,7 +1,8 @@
use crate::{
context_bootstrap::dispatch_window_promise_rejection_event,
exception_reporting::log_unhandled_promise_rejection,
native_bridge::{JsContextHost, WindowExecutionContextBinding},
native_bridge::{JsContextHost, WindowDocumentTaskTarget, WindowExecutionContextBinding},
page_task_queue::RendererPagePromiseRejectionTaskKind,
util::context_host_ptr_from_global_bridge,
};
use std::{
@@ -23,6 +24,12 @@ struct PendingPromiseRejection {
realm: WindowExecutionContextBinding,
}
/// V8 handles stay on the creating Host. The Page queue carries only the
/// exact Document owner and an id for this detached notification batch.
pub(crate) struct PromiseRejectionTaskPayload {
rejections: Vec<PendingPromiseRejection>,
}
#[derive(Clone)]
pub(crate) struct PromiseRejectDispatchSlot {
pub(super) host_weak: Weak<RefCell<JsContextHost>>,
@@ -100,19 +107,83 @@ fn remember_reported_promise_rejection(
reported.push(rejection);
}
pub(super) fn flush_pending_promise_rejections(scope: &mut v8::PinScope<'_, '_>) -> usize {
fn queue_promise_rejection_notification(
state: &PromiseRejectDispatchState,
kind: RendererPagePromiseRejectionTaskKind,
rejections: Vec<PendingPromiseRejection>,
) {
let Some(first) = rejections.first() else {
return;
};
let host_ptr: *mut JsContextHost = (*state.host).as_ptr();
let host = unsafe { &mut *host_ptr };
if !first.realm.is_current(host) {
return;
}
let Some(target) =
host.current_window_document_task_target_for_dispatch_scope(first.realm.dispatch_scope())
else {
return;
};
if !host.queue_promise_rejection_task(target, kind, PromiseRejectionTaskPayload { rejections })
{
tracing::debug!("promise rejection task route closed");
}
}
fn queue_pending_promise_rejections(scope: &mut v8::PinScope<'_, '_>) -> usize {
let Some(state) = promise_reject_dispatch_state(scope) else {
return 0;
};
let pending = std::mem::take(&mut *state.pending.borrow_mut());
let pending_len = pending.len();
// HTML snapshots and empties each global's about-to-be-notified list at
// the checkpoint. Later rejections belong to a later task, even if this
// batch has not yet been selected by the event loop.
let mut batches = indexmap::IndexMap::<_, Vec<_>>::new();
for rejection in pending {
batches
.entry((
rejection.realm.owner(),
rejection.realm.dispatch_scope(),
rejection.realm.realm_token(),
))
.or_default()
.push(rejection);
}
for (_, rejections) in batches {
queue_promise_rejection_notification(
&state,
RendererPagePromiseRejectionTaskKind::Unhandled,
rejections,
);
}
pending_len
}
pub(super) fn dispatch_promise_rejection_task(
scope: &mut v8::PinScope<'_, '_>,
target: WindowDocumentTaskTarget,
kind: RendererPagePromiseRejectionTaskKind,
payload: PromiseRejectionTaskPayload,
) -> bool {
let Some(state) = promise_reject_dispatch_state(scope) else {
return false;
};
let host_ptr: *mut JsContextHost = (*state.host).as_ptr();
state
.reported
.borrow_mut()
.retain(|rejection| rejection.realm.is_current(unsafe { &*host_ptr }));
for rejection in pending {
let mut dispatched = false;
for rejection in payload.rejections {
if unsafe { &*host_ptr }
.current_window_document_task_target_for_dispatch_scope(target.dispatch_scope())
!= Some(target)
{
break;
}
let _ = rejection
.realm
.with_current_scope(scope, host_ptr, |scope, dispatch_scope| {
@@ -120,7 +191,8 @@ pub(super) fn flush_pending_promise_rejections(scope: &mut v8::PinScope<'_, '_>)
// HTML rechecks PromiseIsHandled immediately before notifying.
// The host, or an earlier notification in this detached batch,
// may have handled it since it was first queued.
if promise.has_handler() {
if kind == RendererPagePromiseRejectionTaskKind::Unhandled && promise.has_handler()
{
return;
}
let reason = rejection
@@ -131,7 +203,10 @@ pub(super) fn flush_pending_promise_rejections(scope: &mut v8::PinScope<'_, '_>)
scope,
host_ptr,
dispatch_scope,
"unhandledrejection",
match kind {
RendererPagePromiseRejectionTaskKind::Unhandled => "unhandledrejection",
RendererPagePromiseRejectionTaskKind::Handled => "rejectionhandled",
},
promise,
reason,
);
@@ -139,15 +214,19 @@ pub(super) fn flush_pending_promise_rejections(scope: &mut v8::PinScope<'_, '_>)
// synchronously turn this notification into `rejectionhandled`.
// Only promises that remain unhandled after dispatch belong in
// the outstanding reported-rejection set.
if !promise.has_handler() {
dispatched |= outcome.is_ok();
if kind == RendererPagePromiseRejectionTaskKind::Unhandled && !promise.has_handler()
{
remember_reported_promise_rejection(&state.reported, rejection.clone());
}
if matches!(outcome, Ok(true)) {
if kind == RendererPagePromiseRejectionTaskKind::Unhandled
&& matches!(outcome, Ok(true))
{
log_unhandled_promise_rejection(scope, reason);
}
});
}
pending_len
dispatched
}
pub(crate) fn perform_microtask_checkpoint_and_report_pending_promise_rejections(
@@ -223,13 +302,13 @@ pub(crate) fn perform_microtask_checkpoint_and_report_pending_promise_rejections
elapsed_us = %started.elapsed().as_micros(),
);
}
let flush_started = trace_enabled.then(Instant::now);
let pending_rejections = flush_pending_promise_rejections(scope);
let queue_started = trace_enabled.then(Instant::now);
let pending_rejections = queue_pending_promise_rejections(scope);
crate::context_bootstrap::run_end_of_microtask_checkpoint_tasks(scope);
if let Some(started) = flush_started {
if let Some(started) = queue_started {
tracing::info!(
target: "moli_cdp_runtime",
stage = "microtask_checkpoint_rejection_flush_done",
stage = "microtask_checkpoint_rejection_queue_done",
pending_rejections,
elapsed_us = %started.elapsed().as_micros(),
);
@@ -310,7 +389,7 @@ pub(super) unsafe extern "C" fn promise_reject_callback(message: v8::PromiseReje
.iter()
.position(|rejection| pending_promise_rejection_matches(scope, rejection, promise))
{
pending.swap_remove(index);
pending.remove(index);
return;
}
drop(pending);
@@ -327,23 +406,11 @@ pub(super) unsafe extern "C" fn promise_reject_callback(message: v8::PromiseReje
let Some(rejection) = reported else {
return;
};
let _ = rejection
.realm
.with_current_scope(scope, host_ptr, |scope, dispatch_scope| {
let promise = v8::Local::new(scope, &rejection.promise);
let reason = rejection
.reason
.as_ref()
.map(|reason| v8::Local::new(scope, reason));
let _ = dispatch_window_promise_rejection_event(
scope,
host_ptr,
dispatch_scope,
"rejectionhandled",
promise,
reason,
);
});
queue_promise_rejection_notification(
&state,
RendererPagePromiseRejectionTaskKind::Handled,
vec![rejection],
);
}
_ => {}
}
@@ -1901,9 +1901,10 @@ fn font_face_declared_slots_ignore_prototype_spoofing() {
assert_eq!(vm.eval("fakeLoadedResult").unwrap(), "rejected:TypeError");
}
#[test]
fn invalid_font_face_defers_loaded_rejection_until_promise_is_observed() {
let mut vm = new_storage_test_vm("https://font-face-lazy-loaded-promise.test/");
#[tokio::test(flavor = "current_thread")]
async fn invalid_font_face_defers_loaded_rejection_until_promise_is_observed() {
let mut vm =
new_storage_page_task_executor_test_vm("https://font-face-lazy-loaded-promise.test/");
let initial = vm
.eval(
@@ -1938,6 +1939,12 @@ fn invalid_font_face_defers_loaded_rejection_until_promise_is_observed() {
"[]",
);
assert!(
!vm.has_ready_dom_manipulation_family_for_test(
PageDomManipulationTestFamily::PromiseRejection,
),
"no rejection notification may remain queued"
);
let observed = vm
.eval(
r#"
@@ -1,8 +1,26 @@
use super::*;
#[test]
fn rejection_notification_rechecks_handlers_added_by_an_earlier_notification() {
let mut vm = new_storage_test_vm("https://promise-notification-batch.test/");
async fn run_rejection_task(
vm: &mut crate::runtime::PageVmTaskExecutorTestHarness,
loader: &ResourceRequestClient,
) {
assert!(
vm.run_one_dom_manipulation_task_executor_turn(
PageDomManipulationTestFamily::PromiseRejection,
loader,
)
.await
.expect("promise rejection task")
);
}
#[tokio::test(flavor = "current_thread")]
async fn rejection_notification_rechecks_handlers_added_by_an_earlier_notification() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://promise-notification-batch.test/",
&loader,
);
vm.eval(
r#"
globalThis.__notifications = [];
@@ -18,15 +36,18 @@ fn rejection_notification_rechecks_handlers_added_by_an_earlier_notification() {
)
.unwrap();
vm.eval("0").unwrap();
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval("JSON.stringify(__notifications)").unwrap(),
r#"["first"]"#
);
}
#[test]
fn main_window_unhandled_rejection_dispatches_to_main_window() {
let mut vm = new_storage_test_vm("https://main-promise-rejection.test/");
#[tokio::test(flavor = "current_thread")]
async fn main_window_unhandled_rejection_dispatches_to_main_window() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm =
new_page_task_executor_test_vm_with_loader("https://main-promise-rejection.test/", &loader);
vm.eval(
r#"
@@ -41,6 +62,12 @@ Promise.reject("main-owned");
.expect("main Window rejection setup should evaluate");
vm.eval("0")
.expect("main Window rejection checkpoint should evaluate");
assert_eq!(
vm.eval("JSON.stringify(__mainPromiseRejections)").unwrap(),
"[]",
"microtask checkpoints must leave the notification in the DOM task queue"
);
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval("JSON.stringify(__mainPromiseRejections)")
@@ -49,9 +76,13 @@ Promise.reject("main-owned");
);
}
#[test]
fn handler_added_during_unhandled_rejection_does_not_dispatch_rejectionhandled() {
let mut vm = new_storage_test_vm("https://handled-during-notification.test/");
#[tokio::test(flavor = "current_thread")]
async fn handler_added_during_unhandled_rejection_does_not_dispatch_rejectionhandled() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://handled-during-notification.test/",
&loader,
);
vm.eval(
r#"
@@ -73,6 +104,7 @@ onrejectionhandled = event => {
.expect("rejection handled during notification setup should evaluate");
vm.eval("0")
.expect("unhandled rejection notification checkpoint should evaluate");
run_rejection_task(&mut vm, &loader).await;
vm.eval("0")
.expect("rejection handler reaction checkpoint should evaluate");
@@ -83,9 +115,13 @@ onrejectionhandled = event => {
);
}
#[test]
fn universal_isolated_world_rejection_uses_its_registry_backed_realm() {
let mut vm = new_storage_test_vm("https://isolated-promise-rejection.test/");
#[tokio::test(flavor = "current_thread")]
async fn universal_isolated_world_rejection_uses_its_registry_backed_realm() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://isolated-promise-rejection.test/",
&loader,
);
let context_id = vm
.create_isolated_world("promise-rejection-universal", true)
.expect("universal isolated world should be created");
@@ -105,6 +141,7 @@ Promise.reject("isolated-owned");
.expect("isolated rejection setup should evaluate");
vm.eval_in_isolated_context(context_id, "0")
.expect("isolated rejection checkpoint should evaluate");
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval_in_isolated_context(context_id, "JSON.stringify(__isolatedPromiseRejections)",)
@@ -114,9 +151,13 @@ Promise.reject("isolated-owned");
);
}
#[test]
fn live_child_unhandled_rejection_dispatches_only_to_child_window() {
let mut vm = new_storage_test_vm("https://child-promise-rejection.test/");
#[tokio::test(flavor = "current_thread")]
async fn live_child_unhandled_rejection_dispatches_only_to_child_window() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://child-promise-rejection.test/",
&loader,
);
vm.eval(
r#"
@@ -145,6 +186,7 @@ __promiseRejectionChild.eval(`
.expect("live child rejection setup should evaluate");
vm.eval("0")
.expect("live child rejection checkpoint should evaluate");
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval(
@@ -158,9 +200,13 @@ __promiseRejectionChild.eval(`
);
}
#[test]
fn live_child_rejectionhandled_dispatches_only_to_child_window() {
let mut vm = new_storage_test_vm("https://child-rejection-handled.test/");
#[tokio::test(flavor = "current_thread")]
async fn live_child_rejectionhandled_dispatches_only_to_child_window() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://child-rejection-handled.test/",
&loader,
);
vm.eval(
r#"
@@ -193,8 +239,16 @@ __promiseHandledChild.eval(`
.expect("live child late-handler setup should evaluate");
vm.eval("0")
.expect("live child unhandled rejection checkpoint should evaluate");
run_rejection_task(&mut vm, &loader).await;
vm.eval(r#"__promiseHandledChild.__lateHandledPromise.catch(() => {})"#)
.expect("parent realm should be able to attach the live child rejection handler");
assert_eq!(
vm.eval("JSON.stringify(__promiseHandledChild.__childPromiseEvents)")
.unwrap(),
r#"["unhandledrejection"]"#,
"attaching the handler must only queue rejectionhandled"
);
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval(
@@ -208,9 +262,13 @@ __promiseHandledChild.eval(`
);
}
#[test]
fn detached_child_late_handler_does_not_dispatch_rejectionhandled_to_parent_window() {
let mut vm = new_storage_test_vm("https://detached-rejection-handled.test/");
#[tokio::test(flavor = "current_thread")]
async fn detached_child_late_handler_does_not_dispatch_rejectionhandled_to_parent_window() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://detached-rejection-handled.test/",
&loader,
);
vm.eval(
r#"
@@ -243,6 +301,7 @@ __lateHandlerChild.eval(`
.expect("detached child late-handler setup should evaluate");
vm.eval("0")
.expect("child unhandled rejection checkpoint should evaluate");
run_rejection_task(&mut vm, &loader).await;
vm.eval("__lateHandlerFrame.remove()")
.expect("child frame removal should evaluate");
vm.eval("__lateHandlerChild.__lateHandledPromise.catch(() => {})")
@@ -260,9 +319,9 @@ __lateHandlerChild.eval(`
);
}
#[test]
fn detached_child_dynamic_import_rejection_is_not_reported_to_parent_window() {
let mut vm = new_storage_test_vm("https://inactive-import-rejection.test/");
#[tokio::test(flavor = "current_thread")]
async fn detached_child_dynamic_import_rejection_is_not_reported_to_parent_window() {
let mut vm = new_storage_page_task_executor_test_vm("https://inactive-import-rejection.test/");
let promise_shape = vm
.eval(
@@ -306,6 +365,12 @@ fn detached_child_dynamic_import_rejection_is_not_reported_to_parent_window() {
.expect("detached child rejection checkpoint should evaluate");
}
assert!(
!vm.has_ready_dom_manipulation_family_for_test(
PageDomManipulationTestFamily::PromiseRejection,
),
"no rejection notification may remain queued"
);
assert_eq!(
vm.eval(
r#"JSON.stringify({
@@ -317,3 +382,83 @@ fn detached_child_dynamic_import_rejection_is_not_reported_to_parent_window() {
r#"{"parent":[],"child":[]}"#
);
}
#[tokio::test(flavor = "current_thread")]
async fn promise_rejection_batches_preserve_dom_fifo_across_checkpoints() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let mut vm = new_page_task_executor_test_vm_with_loader("https://promise-fifo.test/", &loader);
vm.eval(
r#"
globalThis.events = [];
globalThis.receiver = new BroadcastChannel('promise-fifo');
globalThis.sender = new BroadcastChannel('promise-fifo');
receiver.onmessage = event => events.push('message:' + event.data);
onunhandledrejection = event => {
events.push('rejection:' + event.reason);
event.preventDefault();
queueMicrotask(() => events.push('microtask:' + event.reason));
};
sender.postMessage('first');
Promise.reject('first');
"#,
)
.unwrap();
vm.eval("sender.postMessage('second'); Promise.reject('second');")
.unwrap();
assert_eq!(vm.eval("JSON.stringify(events)").unwrap(), "[]");
for family in [
PageDomManipulationTestFamily::BroadcastChannel,
PageDomManipulationTestFamily::PromiseRejection,
PageDomManipulationTestFamily::BroadcastChannel,
PageDomManipulationTestFamily::PromiseRejection,
] {
assert!(
vm.run_one_dom_manipulation_task_executor_turn(family, &loader)
.await
.unwrap()
);
}
assert_eq!(
vm.eval("JSON.stringify(events)").unwrap(),
r#"["message:first","rejection:first","microtask:first","message:second","rejection:second","microtask:second"]"#
);
}
#[tokio::test(flavor = "current_thread")]
async fn handler_added_by_an_earlier_dom_task_suppresses_rejection_notification() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let mut vm =
new_page_task_executor_test_vm_with_loader("https://promise-late-task.test/", &loader);
vm.eval(
r#"
globalThis.events = [];
globalThis.receiver = new BroadcastChannel('promise-handler-task');
globalThis.sender = new BroadcastChannel('promise-handler-task');
receiver.onmessage = () => {
events.push('task');
rejected.catch(() => events.push('handler'));
};
onunhandledrejection = event => { events.push(event.type); event.preventDefault(); };
onrejectionhandled = event => events.push(event.type);
globalThis.rejected = Promise.reject('handled before notification');
sender.postMessage('attach');
"#,
)
.unwrap();
assert!(
vm.run_one_dom_manipulation_task_executor_turn(
PageDomManipulationTestFamily::BroadcastChannel,
&loader,
)
.await
.unwrap()
);
run_rejection_task(&mut vm, &loader).await;
assert_eq!(
vm.eval("JSON.stringify(events)").unwrap(),
r#"["task","handler"]"#
);
assert!(!vm.has_ready_dom_manipulation_family_for_test(
PageDomManipulationTestFamily::PromiseRejection
));
}
@@ -4224,9 +4224,9 @@ fn readable_stream_pipe_to_aborts_on_signal_and_honors_prevent_flags() {
);
}
#[test]
fn readable_stream_pipe_abort_actions_own_all_rejections() {
let mut vm = stream_test_vm();
#[tokio::test(flavor = "current_thread")]
async fn readable_stream_pipe_abort_actions_own_all_rejections() {
let mut vm = new_storage_page_task_executor_test_vm("https://stream-runtime.test/");
vm.eval(
r#"
@@ -4288,6 +4288,12 @@ fn readable_stream_pipe_abort_actions_own_all_rejections() {
.expect("pipe abort rejection ownership checkpoint should evaluate");
}
assert!(
!vm.has_ready_dom_manipulation_family_for_test(
PageDomManipulationTestFamily::PromiseRejection,
),
"no rejection notification may remain queued"
);
let events = vm
.eval("JSON.stringify(globalThis.__pipeAbortRejectionEvents.sort())")
.expect("pipe abort rejection ownership events should evaluate");
@@ -2875,9 +2875,9 @@ fn readable_stream_default_reader_release_lock_rejects_closed_and_reads() {
);
}
#[test]
fn readable_stream_default_reader_release_lock_suppresses_internal_closed_rejection() {
let mut vm = new_storage_test_vm("https://example.com/");
#[tokio::test(flavor = "current_thread")]
async fn readable_stream_default_reader_release_lock_suppresses_internal_closed_rejection() {
let mut vm = new_storage_page_task_executor_test_vm("https://example.com/");
let initial = vm
.eval(
@@ -2912,6 +2912,12 @@ fn readable_stream_default_reader_release_lock_suppresses_internal_closed_reject
.expect("ReadableStreamDefaultReader.releaseLock suppress promises should drain");
}
assert!(
!vm.has_ready_dom_manipulation_family_for_test(
PageDomManipulationTestFamily::PromiseRejection,
),
"no rejection notification may remain queued"
);
let unhandled = vm
.eval("JSON.stringify(globalThis.__readerReleaseUnhandled)")
.expect("ReadableStreamDefaultReader.releaseLock suppress events should evaluate");
@@ -362,9 +362,13 @@ fn chromium_discarded_window_fetch_rejects_in_the_detached_function_realm() {
);
}
#[test]
fn borrowed_fetch_uses_receiver_realm_and_keeps_reaction_realm_independent() {
let mut vm = new_storage_test_vm("https://borrowed-fetch-context.test/top/");
#[tokio::test(flavor = "current_thread")]
async fn borrowed_fetch_uses_receiver_realm_and_keeps_reaction_realm_independent() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_page_task_executor_test_vm_with_loader(
"https://borrowed-fetch-context.test/top/",
&loader,
);
vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Fetch));
vm.eval(
r#"
@@ -376,8 +380,21 @@ fn borrowed_fetch_uses_receiver_realm_and_keeps_reaction_realm_independent() {
"#,
)
.expect("borrowed Fetch child should be exposed");
let child_context_id =
materialize_single_child_default_realm_for_test(&mut vm, "borrowed Fetch target");
assert!(
vm.run_one_child_frame_task_executor_turn(
crate::frame_owner_model::ChildFrameSemanticTurnKind::RealmMaterialization,
&loader,
)
.await
.expect("borrowed Fetch child realm task")
);
let realms = vm.live_child_default_runtime_realm_inventory();
assert_eq!(
realms.len(),
1,
"one materialized borrowed Fetch child realm"
);
let child_context_id = realms[0].context_id;
let child_handle = vm
.child_frame_realm_store
.get(&child_context_id)
@@ -535,6 +552,22 @@ fn borrowed_fetch_uses_receiver_realm_and_keeps_reaction_realm_independent() {
"child-response:true",
"Response construction and Promise settlement must use the receiver relevant realm"
);
assert_eq!(
vm.eval("JSON.stringify(__borrowedFetchRejections)")
.unwrap(),
"[]",
"Promise reactions queue notifications without dispatching them at the checkpoint"
);
for _ in 0..2 {
assert!(
vm.run_one_dom_manipulation_task_executor_turn(
PageDomManipulationTestFamily::PromiseRejection,
&loader,
)
.await
.expect("realm-owned rejection notification")
);
}
assert_eq!(
vm.eval("JSON.stringify(__borrowedFetchRejections)")
.expect("borrowed Fetch rejection routing should evaluate"),