diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 3db01e2c29..3be702bac4 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -3760,6 +3760,7 @@ dom/abort/abort-signal-any.any.js?moli-wpt-any=window dom/abort/abort-signal-timeout.html dom/abort/crashtests/any-on-abort.html dom/abort/crashtests/timeout-close.html +dom/abort/event.any.js?moli-wpt-any=dedicatedworker dom/abort/event.any.js?moli-wpt-any=window dom/abort/reason-constructor.html dom/attributes-are-nodes.html diff --git a/moli-core/tests/fixtures/runtime/abort_signal_events.js b/moli-core/tests/fixtures/runtime/abort_signal_events.js new file mode 100644 index 0000000000..9c24c5da0f --- /dev/null +++ b/moli-core/tests/fixtures/runtime/abort_signal_events.js @@ -0,0 +1,154 @@ +async function runAbortSignalEventProbe(scenario) { + if (typeof document !== 'undefined' && document.readyState !== 'complete') { + await new Promise(resolve => addEventListener('load', resolve, {once: true})); + } + // Let document lifecycle events finish before replacing the public constructor. + await new Promise(resolve => setTimeout(resolve, 0)); + const errors = []; + const events = []; + let reads = 0; + const check = (condition, label) => { if (!condition) errors.push(label); }; + const during = (event, signal, trusted, label) => { + check(event.target === signal && event.srcElement === signal, label + ': target'); + check(event.currentTarget === signal && event.eventPhase === 2, label + ': dispatch fields'); + check(event.isTrusted === trusted, label + ': trust'); + const path = event.composedPath(); + check(path.length === 1 && path[0] === signal, label + ': path'); + }; + const after = (event, signal, label) => { + check(event.target === signal, label + ': target retained'); + check(event.currentTarget === null && event.eventPhase === 0, label + ': dispatch fields cleared'); + check(event.composedPath().length === 0 && !event.cancelBubble, label + ': propagation cleared'); + }; + const throws = (callback, name, label) => { + let caught = false; + try { callback(); } + catch (error) { caught = true; check(error.name === name, label + ': exception type'); } + check(caught, label + ': throws'); + }; + + try { + if (scenario.startsWith('native-')) { + const original = Object.getOwnPropertyDescriptor(globalThis, 'Event'); + const observed = []; + const onerror = event => { errors.push('uncaught: ' + event.message); event.preventDefault(); }; + addEventListener('error', onerror); + let deadline; + try { + if (scenario === 'native-getter') { + Object.defineProperty(globalThis, 'Event', {configurable: true, get() { + reads++; throw new Error('public Event getter'); + }}); + } else if (scenario === 'native-replacement') { + Object.defineProperty(globalThis, 'Event', {configurable: true, value: function () { + reads++; throw new Error('public Event constructor'); + }}); + } else if (scenario === 'native-missing') { + delete globalThis.Event; + } + const record = (signal, label) => signal.addEventListener('abort', function (event) { + events.push(label); + observed.push([event, signal, label]); + during(event, signal, true, label); + check(this === signal && event.type === 'abort', label + ': callback'); + check(!event.bubbles && !event.cancelable && !event.composed, label + ': defaults'); + event.preventDefault(); + check(!event.defaultPrevented, label + ': uncancelable'); + }); + const controller = new AbortController(); + const composite = AbortSignal.any([controller.signal]); + record(controller.signal, 'source'); + record(composite, 'composite'); + const reason = {cancel: true}; + controller.abort(reason); + check(events.join(',') === 'source,composite', 'controller events are synchronous'); + check(composite.reason === reason, 'composite reason identity'); + const writable = new WritableStream({start(controller) { record(controller.signal, 'stream'); }}); + await writable.abort(reason); + const timeout = AbortSignal.timeout(0); + record(timeout, 'timeout'); + await new Promise(resolve => { + deadline = setTimeout(() => { errors.push('timeout abort event missing'); resolve(); }, 500); + timeout.onabort = () => { clearTimeout(deadline); resolve(); }; + }); + check(timeout.reason.name === 'TimeoutError', 'timeout reason'); + // Promise reactions can run at callback cleanup before native dispatch ends. + await new Promise(resolve => setTimeout(resolve, 0)); + } finally { + clearTimeout(deadline); + Object.defineProperty(globalThis, 'Event', original); + removeEventListener('error', onerror); + } + check(reads === 0, 'native event construction does not access public Event'); + check(new Set(observed.map(([event]) => event)).size === 4, 'each signal gets a distinct event'); + for (const [event, signal, label] of observed) { + check(event instanceof Event, label + ': intrinsic prototype'); + check(event.isTrusted, label + ': stays trusted after native dispatch'); + after(event, signal, label); + } + } else if (scenario === 'script') { + const source = new AbortController(); + const recipient = new AbortController(); + let nativeEvent; + source.signal.addEventListener('abort', event => { + nativeEvent = event; + events.push('native'); + during(event, source.signal, true, 'native'); + throws(() => recipient.signal.dispatchEvent(event), 'InvalidStateError', 'active redispatch'); + during(event, source.signal, true, 'rejected redispatch preserves active event'); + event.initEvent('changed', true, true); + check(event.type === 'abort' && !event.bubbles && !event.cancelable, 'active initEvent is ignored'); + event.stopImmediatePropagation(); + }); + source.signal.addEventListener('abort', () => errors.push('stopped listener fired')); + source.abort('reason'); + after(nativeEvent, source.signal, 'native'); + recipient.signal.onabort = event => { + events.push('replayed'); + during(event, recipient.signal, false, 'replayed'); + check(!recipient.signal.aborted && recipient.signal.reason === undefined, 'script event leaves signal pending'); + event.stopPropagation(); + }; + check(recipient.signal.dispatchEvent(nativeEvent), 'replayed abort is uncancelable'); + check(!nativeEvent.isTrusted, 'redispatch clears trust'); + after(nativeEvent, recipient.signal, 'replayed'); + const cancelable = new Event('probe', {cancelable: true}); + recipient.signal.addEventListener('probe', event => { + events.push('passive'); + during(event, recipient.signal, false, 'synthetic'); + const before = event.defaultPrevented; + event.preventDefault(); + check(event.defaultPrevented === before, 'passive cancellation is ignored'); + }, {passive: true}); + recipient.signal.addEventListener('probe', event => { + events.push('active'); + event.preventDefault(); + event.stopImmediatePropagation(); + }, {once: true}); + recipient.signal.addEventListener('probe', () => events.push('tail')); + check(!recipient.signal.dispatchEvent(cancelable), 'canceling a synthetic event returns false'); + after(cancelable, recipient.signal, 'synthetic'); + check(!recipient.signal.dispatchEvent(cancelable), 'redispatch preserves cancellation'); + after(cancelable, recipient.signal, 'synthetic again'); + const stopped = new Event('pre-stopped'); + recipient.signal.addEventListener('pre-stopped', () => events.push('pre-stopped')); + stopped.stopPropagation(); + recipient.signal.dispatchEvent(stopped); + check(!events.includes('pre-stopped'), 'pre-stopped event skips listeners'); + after(stopped, recipient.signal, 'pre-stopped'); + recipient.signal.dispatchEvent(stopped); + check(events.includes('pre-stopped'), 'pre-stopped event can be dispatched again'); + for (const value of [null, undefined, {}, {type: 'abort'}, Object.create(Event.prototype)]) { + throws(() => recipient.signal.dispatchEvent(value), 'TypeError', 'non-Event argument'); + } + if (typeof document !== 'undefined') { + throws(() => recipient.signal.dispatchEvent(document.createEvent('Event')), 'InvalidStateError', 'uninitialized Event'); + } + } else { + errors.push('unknown scenario ' + scenario); + } + } catch (error) { + errors.push(String(error)); + } + return {errors, events, reads}; +} diff --git a/moli-core/tests/web_apis/abort_signal.rs b/moli-core/tests/web_apis/abort_signal.rs index b392735081..c2a892bb6a 100644 --- a/moli-core/tests/web_apis/abort_signal.rs +++ b/moli-core/tests/web_apis/abort_signal.rs @@ -49,3 +49,61 @@ async fn abort_signal_any_runs_listener_removal_before_dispatch_and_snapshots_ea ) .await } + +async fn assert_abort_signal_events(scenarios: &[&str], events: &[&str]) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + for scenario in scenarios { + let source = format!( + "{}\nrunAbortSignalEventProbe({}).then(finish, error => finish({{error: String(error)}}));", + include_str!("../fixtures/runtime/abort_signal_events.js"), + serde_json::to_string(scenario)?, + ); + for target in ["window", "child", "worker"] { + let observed = tokio::time::timeout( + Duration::from_secs(10), + super::event_dispatch::run_probe(&browser, &server, target, &source), + ) + .await??; + assert_eq!( + observed, + serde_json::json!({"errors": [], "events": events, "reads": 0}), + "{scenario}/{target}" + ); + } + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn abort_signal_native_events_use_intrinsic_constructors_and_preserve_trust() -> Result<()> { + assert_abort_signal_events( + &[ + "native-original", + "native-getter", + "native-replacement", + "native-missing", + ], + &["source", "composite", "stream", "timeout"], + ) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn abort_signal_script_dispatch_validates_events_resets_trust_and_clears_dispatch_state() +-> Result<()> { + assert_abort_signal_events( + &["script"], + &[ + "native", + "replayed", + "passive", + "active", + "passive", + "tail", + "pre-stopped", + ], + ) + .await +} diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 14f52017a9..b7b7b4cda5 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -1,3 +1,4 @@ +pub(crate) mod abort_signal_events; mod animation_runtime; mod assets; pub(crate) mod bridge_descriptor; diff --git a/moli-renderer-v8/src/context_bootstrap/abort_signal_events.rs b/moli-renderer-v8/src/context_bootstrap/abort_signal_events.rs new file mode 100644 index 0000000000..e45bf84e31 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/abort_signal_events.rs @@ -0,0 +1,62 @@ +use super::events::{clear_event_dispatch_fields, set_event_dispatch_fields}; +use super::{ + EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, EVENT_STOP_PROPAGATION_SLOT, + clear_event_composed_path, event_initialized, event_internal_bool_flag, event_is_dispatching, + new_dom_exception_value, set_event_composed_path, set_event_internal_flag, set_event_trusted, +}; +use crate::util::{throw_type_error, v8str}; + +pub(crate) fn prepare_script_dispatch<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option<(v8::Local<'s, v8::Object>, String)> { + let event = v8::Local::::try_from(value).ok(); + let Some((event, initialized)) = + event.and_then(|event| event_initialized(scope, event).map(|flag| (event, flag))) + else { + throw_type_error(scope, "AbortSignal.dispatchEvent requires an Event."); + return None; + }; + if !initialized || event_is_dispatching(scope, event) { + let error = new_dom_exception_value( + scope, + "The event is uninitialized or already being dispatched.", + "InvalidStateError", + ); + scope.throw_exception(error); + return None; + } + set_event_trusted(scope, event, false); + let event_type = event + .get(scope, v8str(scope, "type").into())? + .to_string(scope)? + .to_rust_string_lossy(scope); + Some((event, event_type)) +} + +// Even a pre-stopped event needs finish_dispatch, but its listeners must not run. +pub(crate) fn begin_dispatch<'s>( + scope: &mut v8::PinScope<'s, '_>, + signal: v8::Local<'s, v8::Object>, + event: v8::Local<'s, v8::Object>, +) -> bool { + set_event_dispatch_fields(scope, signal, event); + let path = v8::Array::new_with_elements(scope, &[signal.into()]); + set_event_composed_path(scope, event, path); + !event_internal_bool_flag(scope, event, EVENT_STOP_PROPAGATION_SLOT) +} + +pub(crate) fn finish_dispatch<'s>( + scope: &mut v8::PinScope<'s, '_>, + event: v8::Local<'s, v8::Object>, +) { + clear_event_dispatch_fields(scope, event); + clear_event_composed_path(scope, event); + for flag in [ + EVENT_STOP_PROPAGATION_SLOT, + EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, + EVENT_PASSIVE_SLOT, + ] { + set_event_internal_flag(scope, event, flag, false); + } +} diff --git a/moli-renderer-v8/src/native_bridge/abort.rs b/moli-renderer-v8/src/native_bridge/abort.rs index 2d268f2b39..788ff96f3a 100644 --- a/moli-renderer-v8/src/native_bridge/abort.rs +++ b/moli-renderer-v8/src/native_bridge/abort.rs @@ -2,7 +2,7 @@ use crate::web_api_interfaces; use std::collections::{HashMap, HashSet}; use super::super::document_runtime::EventTargetHandle; -use super::super::util::{get_private_value, set_private_value, v8_string, v8str}; +use super::super::util::{get_private_value, set_private_value, v8str}; use crate::context_bootstrap::{MessagePortEventListenerId, new_dom_exception_value}; use crate::types::MessagePortId; use moli_webapi_declare::WebApiObject; @@ -151,19 +151,6 @@ impl AbortStore { .map(|value| value as u32) } - fn define_hidden_value( - scope: &mut v8::PinScope<'_, '_>, - object: v8::Local<'_, v8::Object>, - key: &str, - value: v8::Local<'_, v8::Value>, - ) { - let Some(key) = v8_string(scope, key) else { - return; - }; - let _ = - object.define_own_property(scope, key.into(), value, v8::PropertyAttribute::DONT_ENUM); - } - fn init_signal( &mut self, scope: &mut v8::PinScope<'_, '_>, diff --git a/moli-renderer-v8/src/native_bridge/abort/event.rs b/moli-renderer-v8/src/native_bridge/abort/event.rs index 2672363767..a0dcedc5a0 100644 --- a/moli-renderer-v8/src/native_bridge/abort/event.rs +++ b/moli-renderer-v8/src/native_bridge/abort/event.rs @@ -1,4 +1,5 @@ -use super::{AbortDispatchSnapshot, AbortStore}; +use super::AbortDispatchSnapshot; +use crate::context_bootstrap::abort_signal_events; use crate::context_bootstrap::{ EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, construct_original_event, event_internal_bool_flag, set_event_internal_flag, @@ -47,9 +48,6 @@ pub(super) fn dispatch_abort<'s>( let Some(event) = construct_original_event(scope, "abort") else { return; }; - AbortStore::define_hidden_value(scope, event, "target", signal.into()); - AbortStore::define_hidden_value(scope, event, "currentTarget", signal.into()); - invoke_abort_event_callbacks( scope, host_ptr, @@ -78,6 +76,10 @@ pub(super) fn invoke_abort_event_callbacks<'s>( event_type: &str, event: v8::Local<'s, v8::Object>, ) { + if !abort_signal_events::begin_dispatch(scope, signal, event) { + abort_signal_events::finish_dispatch(scope, event); + return; + } for listener in dispatch_snapshot.listeners { let Some(listener) = (unsafe { &mut *host_ptr }) .claim_abort_signal_event_listener_for_dispatch( @@ -105,10 +107,8 @@ pub(super) fn invoke_abort_event_callbacks<'s>( break; } } - if event_internal_bool_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT) { - return; - } - if event_type == "abort" + if !event_internal_bool_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT) + && event_type == "abort" && let Some(onabort) = dispatch_snapshot.onabort { let onabort = v8::Local::new(scope, &onabort); @@ -120,4 +120,5 @@ pub(super) fn invoke_abort_event_callbacks<'s>( &[event.into()], ); } + abort_signal_events::finish_dispatch(scope, event); } diff --git a/moli-renderer-v8/src/native_bridge/abort/signal.rs b/moli-renderer-v8/src/native_bridge/abort/signal.rs index a9597afe2f..7f32c1fe00 100644 --- a/moli-renderer-v8/src/native_bridge/abort/signal.rs +++ b/moli-renderer-v8/src/native_bridge/abort/signal.rs @@ -1,5 +1,6 @@ use super::AbortStore; use super::event::{invoke_abort_event_callbacks, local_object_in_scope}; +use crate::context_bootstrap::abort_signal_events; use crate::util::{context_host_ptr_from_global_bridge, v8str}; use crate::webidl; @@ -109,18 +110,12 @@ pub(crate) fn abort_signal_dispatch_event_callback<'s>( rv.set_bool(false); return; }; - let Ok(event) = v8::Local::::try_from(parsed.event) else { - rv.set_bool(false); - return; - }; let Some(signal_id) = AbortStore::signal_id_from_object(scope, signal) else { rv.set_bool(false); return; }; - let Some(event_type) = event - .get(scope, v8str(scope, "type").into()) - .and_then(|value| value.to_string(scope)) - .map(|s| s.to_rust_string_lossy(scope)) + let Some((event, event_type)) = + abort_signal_events::prepare_script_dispatch(scope, parsed.event) else { rv.set_bool(false); return; @@ -132,8 +127,6 @@ pub(crate) fn abort_signal_dispatch_event_callback<'s>( // callback signature that `FunctionTemplate::builder(...)` expects. let signal = local_object_in_scope(scope, signal); let event = local_object_in_scope(scope, event); - AbortStore::define_hidden_value(scope, event, "target", signal.into()); - AbortStore::define_hidden_value(scope, event, "currentTarget", signal.into()); let Some(dispatch_snapshot) = unsafe { &mut *host_ptr } .native_bridge_mut() .abort diff --git a/moli-renderer-v8/src/worker/abort.rs b/moli-renderer-v8/src/worker/abort.rs index 1c42f6a76a..2350ef59c8 100644 --- a/moli-renderer-v8/src/worker/abort.rs +++ b/moli-renderer-v8/src/worker/abort.rs @@ -78,19 +78,6 @@ impl WorkerAbortStore { self.next_controller_id } - fn define_hidden_value( - scope: &mut v8::PinScope<'_, '_>, - object: v8::Local<'_, v8::Object>, - key: &str, - value: v8::Local<'_, v8::Value>, - ) { - let Some(key) = v8_string(scope, key) else { - return; - }; - let _ = - object.define_own_property(scope, key.into(), value, v8::PropertyAttribute::DONT_ENUM); - } - pub(super) fn signal_id_from_object<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/worker/abort/event_listener.rs b/moli-renderer-v8/src/worker/abort/event_listener.rs index 0322b1fee6..495e1c0291 100644 --- a/moli-renderer-v8/src/worker/abort/event_listener.rs +++ b/moli-renderer-v8/src/worker/abort/event_listener.rs @@ -4,12 +4,13 @@ use moli_webidl_callback::{PreparedWebIdlCallbackInterface, WebIdlCallbackInterf use super::{WorkerAbortSignalState, WorkerAbortStore, local_object_in_scope, worker_abort_store}; use crate::callback_invocation::{CallbackInvocation, CallbackInvocationOutcome, CallbackInvoker}; +use crate::context_bootstrap::abort_signal_events; use crate::context_bootstrap::{ - EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, event_internal_bool_flag, - set_event_internal_flag, + EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, construct_original_event, + event_internal_bool_flag, set_event_internal_flag, }; use crate::exception_reporting::{CallbackExceptionLogLevel, invoke_callback}; -use crate::util::{v8_string, v8str}; +use crate::util::v8str; use crate::webidl; /// Identity of one EventListener registration inside a worker run. @@ -281,18 +282,12 @@ pub(crate) fn worker_abort_signal_dispatch_event_callback<'s>( rv.set_bool(false); return; }; - let Ok(event) = v8::Local::::try_from(parsed.event) else { - rv.set_bool(false); - return; - }; let Some(signal_id) = WorkerAbortStore::signal_id_from_object(scope, signal) else { rv.set_bool(false); return; }; - let Some(event_type) = event - .get(scope, v8str(scope, "type").into()) - .and_then(|value| value.to_string(scope)) - .map(|value| value.to_rust_string_lossy(scope)) + let Some((event, event_type)) = + abort_signal_events::prepare_script_dispatch(scope, parsed.event) else { rv.set_bool(false); return; @@ -301,8 +296,6 @@ pub(crate) fn worker_abort_signal_dispatch_event_callback<'s>( let default_prevented_key = v8str(scope, "defaultPrevented"); let signal = local_object_in_scope(scope, signal); let event = local_object_in_scope(scope, event); - WorkerAbortStore::define_hidden_value(scope, event, "target", signal.into()); - WorkerAbortStore::define_hidden_value(scope, event, "currentTarget", signal.into()); let dispatch_snapshot = store .borrow() .signal_state(signal_id) @@ -331,21 +324,13 @@ pub(super) fn dispatch_abort<'s>( signal_id: u32, dispatch_snapshot: WorkerAbortDispatchSnapshot, ) { - let global = scope.get_current_context().global(scope); - let Some(event_ctor) = global - .get(scope, v8str(scope, "Event").into()) - .and_then(|value| v8::Local::::try_from(value).ok()) - else { + let context = signal + .get_creation_context(scope) + .unwrap_or_else(|| scope.get_current_context()); + let scope = &mut v8::ContextScope::new(scope, context); + let Some(event) = construct_original_event(scope, "abort") else { return; }; - let Some(event_type) = v8_string(scope, "abort") else { - return; - }; - let Some(event) = event_ctor.new_instance(scope, &[event_type.into()]) else { - return; - }; - WorkerAbortStore::define_hidden_value(scope, event, "target", signal.into()); - WorkerAbortStore::define_hidden_value(scope, event, "currentTarget", signal.into()); dispatch_event_callbacks( store, scope, @@ -367,6 +352,10 @@ fn dispatch_event_callbacks<'s>( dispatch_snapshot: WorkerAbortDispatchSnapshot, event: v8::Local<'s, v8::Object>, ) { + if !abort_signal_events::begin_dispatch(scope, signal, event) { + abort_signal_events::finish_dispatch(scope, event); + return; + } for listener_id in dispatch_snapshot.listener_ids { let listener = store @@ -389,10 +378,9 @@ fn dispatch_event_callbacks<'s>( } } - if event_internal_bool_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT) { - return; - } - if let Some(onabort) = dispatch_snapshot.onabort { + if !event_internal_bool_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT) + && let Some(onabort) = dispatch_snapshot.onabort + { let onabort = v8::Local::new(scope, &onabort); let _ = invoke_callback( scope, @@ -402,6 +390,7 @@ fn dispatch_event_callbacks<'s>( &[event.into()], ); } + abort_signal_events::finish_dispatch(scope, event); } fn invoke_worker_abort_event_listener<'s>(