fix: construct trusted abort events and reset AbortSignal dispatch state

Worker abort events now use the signal realm's intrinsic Event constructor
and carry the native trust flag, including timeout and stream cancellation.

Share AbortSignal dispatch setup and cleanup between Window and Worker.
Script dispatch validates Event state and clears trust, active redispatch
throws, and completion clears currentTarget, eventPhase, the event path,
and propagation flags. Pre-stopped events skip listeners and remain reusable.

Cover controller, composite, stream and timeout events with overwritten or
missing public constructors, plus synthetic dispatch and native event reuse,
in Window, child frames and dedicated workers.
This commit is contained in:
ldm0
2026-09-23 00:11:56 +08:00
parent 1b1e2e781c
commit a35f2006d2
10 changed files with 308 additions and 75 deletions
@@ -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
+154
View File
@@ -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};
}
+58
View File
@@ -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
}
@@ -1,3 +1,4 @@
pub(crate) mod abort_signal_events;
mod animation_runtime;
mod assets;
pub(crate) mod bridge_descriptor;
@@ -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::<v8::Object>::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);
}
}
+1 -14
View File
@@ -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<'_, '_>,
@@ -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);
}
@@ -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::<v8::Object>::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
-13
View File
@@ -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>,
@@ -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::<v8::Object>::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::<v8::Function>::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>(