diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 98b552fb38..9060f86275 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -789,7 +789,8 @@ struct WorkerAbortSignalTemplateDeclaration { interface = web_api_interfaces::AbortController, constructor_callback = crate::worker::abort::worker_abort_controller_constructor_callback, constructor_length = 0, - enumerable + enumerable, + receiver )] struct WorkerAbortControllerTemplateDeclaration { #[webapi( @@ -800,7 +801,7 @@ struct WorkerAbortControllerTemplateDeclaration { #[webapi( method = "abort", - length = 1, + length = 0, callback = crate::worker::abort::worker_abort_controller_abort_callback )] abort: (), diff --git a/moli-renderer-v8/src/context_bootstrap/shared_installers/abort_statics.rs b/moli-renderer-v8/src/context_bootstrap/shared_installers/abort_statics.rs index 843a3b3628..0585f58648 100644 --- a/moli-renderer-v8/src/context_bootstrap/shared_installers/abort_statics.rs +++ b/moli-renderer-v8/src/context_bootstrap/shared_installers/abort_statics.rs @@ -60,7 +60,7 @@ struct AbortSignalPrototypeDeclaration { } #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::AbortController, enumerable)] +#[webapi(interface = web_api_interfaces::AbortController, enumerable, receiver)] struct AbortControllerPrototypeDeclaration { #[webapi(method, length = 0, callback = abort::abort_controller_abort_callback)] abort: (), diff --git a/moli-renderer-v8/src/native_bridge/abort/controller.rs b/moli-renderer-v8/src/native_bridge/abort/controller.rs index 2bd7c1e271..a7d5dbe868 100644 --- a/moli-renderer-v8/src/native_bridge/abort/controller.rs +++ b/moli-renderer-v8/src/native_bridge/abort/controller.rs @@ -36,7 +36,9 @@ pub(crate) fn abort_controller_signal_getter_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - match get_private_value(scope, args.this(), ABORT_CONTROLLER_SIGNAL_SLOT) { + let controller = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("AbortController receiver is checked by the binding"); + match get_private_value(scope, controller, ABORT_CONTROLLER_SIGNAL_SLOT) { Some(value) => rv.set(value), None => rv.set_null(), } @@ -51,7 +53,8 @@ pub(crate) fn abort_controller_abort_callback<'s>( rv.set_undefined(); return; }; - let controller = args.this(); + let controller = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("AbortController receiver is checked by the binding"); let Some(_) = AbortStore::controller_id_from_object(scope, controller) else { rv.set_undefined(); return; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/abort_controller_receivers.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/abort_controller_receivers.rs new file mode 100644 index 0000000000..85935c05e3 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/abort_controller_receivers.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn abort_controller_receivers_use_native_brands_and_callee_realms() { + let mut vm = new_parsed_test_vm( + "https://abort-controller-receivers.test/", + "", + ); + let result = vm + .eval(include_str!( + "../../../../tests/fixtures/abort-controller-receivers.js" + )) + .expect("AbortController receiver matrix should complete"); + assert_eq!( + result, + "true", + "{}", + vm.eval("JSON.stringify(__uiEventResults.rows.filter(row => !row.passed))") + .unwrap(), + ); +} + +#[test] +fn abort_controller_receivers_accept_registered_native_proxies_and_preserve_reasons() { + let mut vm = new_storage_html_test_vm("https://abort-controller-native-proxy.test/"); + vm.eval( + r#" + globalThis.nativeControllerTarget = new AbortController(); + globalThis.nativeControllerTraps = 0; + globalThis.nativeController = new Proxy(nativeControllerTarget, { + get() {nativeControllerTraps++; throw new Error('get trap');}, + getPrototypeOf() {nativeControllerTraps++; throw new Error('prototype trap');} + }); + globalThis.nativeAbortReason = document.implementation.createHTMLDocument('').createElement('select'); + "#, + ) + .unwrap(); + vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| { + let global = scope.get_current_context().global(scope); + let key = v8::String::new(scope, "nativeController").unwrap(); + let proxy = global.get(scope, key.into()).unwrap(); + let proxy = v8::Local::::try_from(proxy).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + assert!(crate::web_api_interfaces::AbortController::is_instance( + scope, + proxy.into() + )); + let key = v8::String::new(scope, "nativeAbortReason").unwrap(); + let reason = global.get(scope, key.into()).unwrap(); + assert!(reason.is_proxy(), "reason must exercise a native Proxy"); + Ok(()) + }) + .unwrap(); + assert_eq!( + vm.eval( + r#"(() => { + const get = Object.getOwnPropertyDescriptor(AbortController.prototype, 'signal').get; + const signal = get.call(nativeController); + if (signal !== nativeControllerTarget.signal) return false; + AbortController.prototype.abort.call(nativeController, nativeAbortReason); + if (!signal.aborted || signal.reason !== nativeAbortReason) return false; + if (get.call(nativeController) !== signal || nativeControllerTraps !== 0) return false; + const author = new Proxy(nativeController, {}); + for (const call of [() => get.call(author), () => AbortController.prototype.abort.call(author)]) { + try { call(); return false; } catch (error) { if (!(error instanceof TypeError)) return false; } + } + return nativeControllerTraps === 0; + })()"#, + ) + .unwrap(), + "true", + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index f0fff3d74f..5c354693b9 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod abort_controller_receivers; mod abort_signal_events; mod abort_signal_statics; mod bar_prop; diff --git a/moli-renderer-v8/src/worker/abort.rs b/moli-renderer-v8/src/worker/abort.rs index 6ad22ecae9..a8bcd972fd 100644 --- a/moli-renderer-v8/src/worker/abort.rs +++ b/moli-renderer-v8/src/worker/abort.rs @@ -454,7 +454,9 @@ pub(crate) fn worker_abort_controller_signal_getter<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - match get_private_value(scope, args.this(), WORKER_ABORT_CONTROLLER_SIGNAL_SLOT) { + let controller = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("AbortController receiver is checked by the binding"); + match get_private_value(scope, controller, WORKER_ABORT_CONTROLLER_SIGNAL_SLOT) { Some(value) => rv.set(value), None => { scope.throw_exception(v8::Exception::type_error( @@ -474,7 +476,8 @@ pub(crate) fn worker_abort_controller_abort_callback<'s>( rv.set_undefined(); return; }; - let controller = args.this(); + let controller = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("AbortController receiver is checked by the binding"); let Some(controller_id) = WorkerAbortStore::controller_id_from_object(scope, controller) else { rv.set_undefined(); return; diff --git a/moli-renderer-v8/src/worker/thread/tests/abort_controller_receivers.rs b/moli-renderer-v8/src/worker/thread/tests/abort_controller_receivers.rs new file mode 100644 index 0000000000..35bba6bbd1 --- /dev/null +++ b/moli-renderer-v8/src/worker/thread/tests/abort_controller_receivers.rs @@ -0,0 +1,16 @@ +use super::*; + +#[tokio::test] +async fn worker_abort_controller_receivers_use_native_brands() { + ensure_v8(); + let probe = include_str!("../../../../tests/fixtures/abort-controller-receivers.js"); + let mut handle = spawn_worker( + format!("{probe}\npostMessage(__uiEventResults); close();"), + "https://abort-controller-receivers.test/worker.js".into(), + ); + let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap(); + let result: serde_json::Value = serde_json::from_str(&expect_post_json(message)).unwrap(); + assert_eq!(result["complete"], true, "{result}"); + assert_eq!(result["total"], 62, "{result}"); + assert_eq!(result["passed"], result["total"], "{result}"); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/mod.rs b/moli-renderer-v8/src/worker/thread/tests/mod.rs index fb8f53fcc7..8de684e765 100644 --- a/moli-renderer-v8/src/worker/thread/tests/mod.rs +++ b/moli-renderer-v8/src/worker/thread/tests/mod.rs @@ -1550,3 +1550,5 @@ mod payment_events; mod performance_handler_order; mod performance_user_timing; + +mod abort_controller_receivers; diff --git a/moli-renderer-v8/tests/fixtures/abort-controller-receivers.js b/moli-renderer-v8/tests/fixtures/abort-controller-receivers.js new file mode 100644 index 0000000000..555ebe0898 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/abort-controller-receivers.js @@ -0,0 +1,179 @@ +(() => { + const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + const typeError = (body, realm) => { + try { body(); return false; } catch (error) { return error instanceof realm.TypeError; } + }; + const raises = (body, expected) => { + try { body(); return false; } catch (error) { return Object.is(error, expected); } + }; + globalThis.abortControllerReceiverProbe = (realm = globalThis, peers = [['self', realm]]) => { + const rows = []; + const check = (name, body) => { + try { rows.push({name, passed: body() === true}); } + catch (error) { rows.push({name, passed: false, error: String(error)}); } + }; + const C = realm.AbortController; + const signalDescriptor = Object.getOwnPropertyDescriptor(C.prototype, 'signal'); + const getter = signalDescriptor.get; + const abortDescriptor = Object.getOwnPropertyDescriptor(C.prototype, 'abort'); + const abort = abortDescriptor.value; + check('signal prototype descriptor', () => signalDescriptor.enumerable && + signalDescriptor.configurable && signalDescriptor.set === undefined && + getter.name === 'get signal' && getter.length === 0 && + Object.getPrototypeOf(getter) === realm.Function.prototype); + check('abort prototype descriptor', () => abortDescriptor.enumerable && + abortDescriptor.configurable && abortDescriptor.writable && abort.name === 'abort' && + abort.length === 0 && Object.getPrototypeOf(abort) === realm.Function.prototype); + check('constructor metadata and call requirements', () => C.length === 0 && + C.name === 'AbortController' && typeError(() => C(), realm)); + check('instances inherit methods and signal', () => { + const controller = new C(); + return !own(controller, 'signal') && !own(controller, 'abort') && + Object.getPrototypeOf(controller) === C.prototype && controller.abort === abort; + }); + check('signal is SameObject across abort', () => { + const controller = new C(), signal = getter.call(controller); + const before = getter.call(controller) === signal && !signal.aborted && signal.reason === undefined; + abort.call(controller, 'reason'); + return before && getter.call(controller) === signal && controller.signal === signal && + signal.aborted && signal.reason === 'reason'; + }); + check('signal is readonly without converting assigned values', () => { + const controller = new C(), signal = controller.signal; + let calls = 0; + const value = {[Symbol.toPrimitive]() {calls++; throw new Error('coercion');}}; + const set = realm.Function('controller', 'value', '"use strict"; controller.signal = value;'); + return Reflect.set(controller, 'signal', value) === false && + typeError(() => set(controller, value), realm) && controller.signal === signal && calls === 0; + }); + for (const [peerName, peer] of peers) { + const controller = new peer.AbortController(); + let traps = 0; + const author = new Proxy(controller, { + get() {traps++;}, getPrototypeOf() {traps++;}, has() {traps++;}, + ownKeys() {traps++;}, getOwnPropertyDescriptor() {traps++;} + }); + const revoked = Proxy.revocable(controller, {}); revoked.revoke(); + const invalid = [['plain', {}], ['interface prototype', peer.AbortController.prototype], + ['forged prototype', Object.create(peer.AbortController.prototype)], + ['inherited instance', Object.create(controller)], ['author Proxy', author], + ['revoked Proxy', revoked.proxy], ['signal', controller.signal], + ['global', peer], ['null', null], ['undefined', undefined], ['number', 1], + ['boolean', true], ['string', 'controller'], ['symbol', Symbol()], ['bigint', 1n], + ['fake public identity', {__lmAbortControllerId: 1, __lmAbortControllerSignal: controller.signal}]]; + for (const [label, receiver] of invalid) { + check(`${peerName} getter rejects ${label} in callee realm`, () => + typeError(() => getter.call(receiver), realm)); + check(`${peerName} abort rejects ${label} in callee realm`, () => + typeError(() => abort.call(receiver, controller), realm)); + } + check(`${peerName} invalid receivers do not run author traps`, () => traps === 0); + check(`${peerName} borrowed getter preserves original signal and realm`, () => { + const controller = new peer.AbortController(); + return getter.call(controller) === controller.signal && controller.signal instanceof peer.AbortSignal; + }); + check(`${peerName} borrowed abort retains reason and synchronously dispatches`, () => { + const controller = new peer.AbortController(), signal = controller.signal, reason = {}; + let count = 0; + signal.addEventListener('abort', event => { + if (event.target === signal && signal.aborted && signal.reason === reason) count++; + }); + const result = abort.call(controller, reason); + return result === undefined && signal.aborted && signal.reason === reason && count === 1; + }); + check(`${peerName} borrowed abort creates default error in callee realm`, () => { + const controller = new peer.AbortController(); + abort.call(controller); + return controller.signal.aborted && controller.signal.reason instanceof realm.DOMException && + controller.signal.reason.name === 'AbortError'; + }); + check(`${peerName} prototype removal preserves native controller state`, () => { + const controller = new peer.AbortController(), signal = getter.call(controller), reason = {}; + Object.setPrototypeOf(controller, null); + abort.call(controller, reason); + return getter.call(controller) === signal && signal.aborted && signal.reason === reason; + }); + } + for (const [label, reason] of [['null', null], ['false', false], ['zero', 0], + ['negative zero', -0], ['NaN', NaN], ['string', '\ud800'], ['symbol', Symbol('reason')], + ['bigint', 9n], ['object', {}], ['array', []], ['function', () => {}]]) { + check(`raw reason identity ${label}`, () => { + const controller = new C(), signal = controller.signal; + const result = abort.call(controller, reason); + abort.call(controller, 'replacement'); + return result === undefined && signal.aborted && Object.is(signal.reason, reason) && + raises(() => signal.throwIfAborted(), reason); + }); + } + check('Proxy and revoked Proxy are accepted as raw reasons', () => { + let traps = 0; + const reason = new Proxy({}, {get() {traps++; throw 1;}, getPrototypeOf() {traps++; throw 2;}}); + const revoked = Proxy.revocable({}, {}); revoked.revoke(); + const a = new C(), b = new C(); + abort.call(a, reason); abort.call(b, revoked.proxy); + return a.signal.reason === reason && b.signal.reason === revoked.proxy && traps === 0; + }); + check('undefined reason uses a callee-realm AbortError', () => { + const controller = new C(); abort.call(controller, undefined); + return controller.signal.reason instanceof realm.DOMException && + controller.signal.reason.name === 'AbortError'; + }); + check('frozen controller and signal retain private state', () => { + const controller = new C(), signal = controller.signal, reason = {}; + Object.freeze(controller); Object.freeze(signal); + abort.call(controller, reason); + return getter.call(controller) === signal && signal.aborted && signal.reason === reason; + }); + check('own signal and abort shadows do not affect native operations', () => { + const controller = new C(), signal = controller.signal, reason = {}; + let calls = 0; + for (const key of ['signal', 'abort']) Object.defineProperty(controller, key, {get() {calls++; throw 1;}}); + abort.call(controller, reason); + return getter.call(controller) === signal && signal.aborted && signal.reason === reason && calls === 0; + }); + check('subclasses retain native state', () => { + class Derived extends C {} + const controller = new Derived(), signal = getter.call(controller), reason = {}; + abort.call(controller, reason); + return controller instanceof Derived && signal.aborted && signal.reason === reason; + }); + check('abort dispatch is once-only even for reentrant abort', () => { + const controller = new C(), signal = controller.signal, first = {}, second = {}; + let count = 0; + signal.addEventListener('abort', () => {count++; abort.call(controller, second);}); + abort.call(controller, first); abort.call(controller, second); + return count === 1 && signal.reason === first; + }); + check('author constructor replacement does not affect signal factory', () => { + const original = realm.AbortSignal; + let calls = 0; + try { + realm.AbortSignal = function() {calls++; throw 1;}; + const controller = new C(); + return controller.signal instanceof original && calls === 0; + } finally { realm.AbortSignal = original; } + }); + check('prototype replacement does not affect private initialization or abort', () => { + let calls = 0; + try { + Object.defineProperty(C.prototype, 'signal', {configurable: true, get() {calls++; throw 1;}}); + Object.defineProperty(C.prototype, 'abort', {configurable: true, value() {calls++; throw 1;}}); + const controller = new C(), reason = {}; + abort.call(controller, reason); + const signal = getter.call(controller); + return signal.aborted && signal.reason === reason && !own(controller, 'signal') && calls === 0; + } finally { + Object.defineProperty(C.prototype, 'signal', signalDescriptor); + Object.defineProperty(C.prototype, 'abort', abortDescriptor); + } + }); + return {complete: true, total: rows.length, passed: rows.filter(row => row.passed).length, rows}; + }; + const realms = typeof document === 'undefined' ? [['worker', globalThis]] : + [['top', globalThis], ['child', document.querySelector('#child').contentWindow]]; + const rows = realms.flatMap(([name, realm]) => abortControllerReceiverProbe(realm, realms).rows + .map(row => ({...row, name: `${name}: ${row.name}`}))); + globalThis.__uiEventResults = {complete: true, total: rows.length, + passed: rows.filter(row => row.passed).length, rows}; + return rows.every(row => row.passed); +})()