feat(worklet): expose native interfaces and independent module promises

Expose Worklet and AudioWorklet with shared branded prototypes and the readonly BaseAudioContext.audioWorklet attribute. Native factories bypass replaceable public constructors; each addModule call gets its own promise and rejection state.

Tests cover WebIDL conversion order, forged/proxy receivers, intrinsic factories, cross-realm results and independent module completion promises, including the existing Worker-backed module loader.

AudioWorklet execution still uses the existing per-module Worker backend. A shared AudioWorkletGlobalScope, real DSP/message ports, Blob dependency loading and complete parser/AbortError handling remain follow-up.

Tests cover top-level Window and same-origin iframe realms, plus applicable Worker globals. Popup exposure remains with the separate popup-realm migration on the integration branch; main still uses a legacy Window shell without a complete realm.

Rebased onto main, with separate exposure-list and Worker regression insertion points from WebGL, Cache and ServiceWorker.

An initial full test run hit the existing synchronous-XHR watchdog wall-clock assertion at 4.208s (limit 4s). The unchanged-tree focused recheck and subsequent full run passed, with no automatic retries; the initial log is preserved.
This commit is contained in:
ldm0
2026-09-30 22:54:44 +08:00
committed by Donough Liu
parent a2a71161b4
commit 4e2f6caa46
13 changed files with 504 additions and 178 deletions
@@ -8,6 +8,7 @@ use super::materialize::{
enum RealmDependentFinalizer {
NodeMixinUnscopables,
CryptoSecureContextSurface,
BaseAudioContextSecureContextSurface,
XmlHttpRequestEventTargetState,
NotificationPermission,
PointerEventSecureContextSurface,
@@ -33,6 +34,10 @@ const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] =
),
// These entries install private realm state, context-conditional surface,
// or constructor data whose JavaScript identity must be realm-local.
(
"BaseAudioContext",
RealmDependentFinalizer::BaseAudioContextSecureContextSurface,
),
(
"Crypto",
RealmDependentFinalizer::CryptoSecureContextSurface,
@@ -80,6 +85,9 @@ pub(super) fn finalize_materialized_interface(
RealmDependentFinalizer::NodeMixinUnscopables => {
finalize_node_mixin_unscopables(scope, prototype);
}
RealmDependentFinalizer::BaseAudioContextSecureContextSurface => {
crate::context_bootstrap::web_audio_runtime::finalize_base_audio_context_realm_bindings(scope, prototype)?;
}
RealmDependentFinalizer::CryptoSecureContextSurface => {
crate::context_bootstrap::crypto::finalize_crypto_realm_bindings(scope, prototype)?;
}
@@ -140,6 +148,7 @@ mod tests {
"Element",
"DocumentType",
"CharacterData",
"BaseAudioContext",
"Crypto",
"XMLHttpRequestEventTarget",
"Notification",
@@ -102,6 +102,8 @@ const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[
"SubtleCrypto",
"CryptoKey",
"IdleDetector",
"Worklet",
"AudioWorklet",
"Clipboard",
"ClipboardItem",
"Cache",
@@ -2063,6 +2063,14 @@ pub(crate) fn set_interface_prototype_constructor<'s>(
);
}
pub(crate) fn window_realm_secure_context_available<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
) -> bool {
get_private_value(scope, window, WINDOW_SECURE_CONTEXT_AVAILABLE_SLOT)
.is_some_and(|value| value.is_true())
}
fn install_window_runtime_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
global: v8::Local<'s, v8::Object>,
@@ -1183,6 +1183,14 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[
interface: web_api_interfaces::AudioContext::DESCRIPTOR,
kind: ConstructorKind::AudioContext,
},
ConstructorSpec {
interface: web_api_interfaces::Worklet::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
ConstructorSpec {
interface: web_api_interfaces::AudioWorklet::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
ConstructorSpec {
interface: web_api_interfaces::AudioWorkletNode::DESCRIPTOR,
kind: ConstructorKind::AudioWorkletNode,
@@ -6,8 +6,7 @@ use super::media_queries::{
use super::*;
use crate::native_bridge::throw_dom_exception;
use crate::util::{
array_push_value, call_object_method, get_private_value, object_string_property,
set_private_value, set_symbol_to_string_tag,
call_object_method, get_private_value, object_string_property, set_private_value,
};
use crate::web_api_interfaces;
use crate::webidl;
@@ -23,11 +22,12 @@ const AUDIO_CONTEXT_LISTENERS_SLOT: &str = "__moliAudioContextListeners";
const AUDIO_CONTEXT_MODULES_SLOT: &str = "__moliAudioContextModules";
const AUDIO_CONTEXT_MODULE_LIST_SLOT: &str = "__moliAudioContextModuleList";
const AUDIO_CONTEXT_PROCESSORS_SLOT: &str = "__moliAudioContextProcessors";
const AUDIO_CONTEXT_WORKLET_SLOT: &str = "__moliAudioContextWorklet";
const AUDIO_WORKLET_CONTEXT_SLOT: &str = "__moliAudioWorkletContext";
const AUDIO_WORKLET_MODULE_CONTEXT_SLOT: &str = "__moliAudioWorkletModuleContext";
const AUDIO_WORKLET_MODULE_WORKER_SLOT: &str = "__moliAudioWorkletModuleWorker";
const AUDIO_WORKLET_MODULE_PROMISE_SLOT: &str = "__moliAudioWorkletModulePromise";
const AUDIO_WORKLET_MODULE_RESOLVER_SLOT: &str = "__moliAudioWorkletModuleResolver";
const AUDIO_WORKLET_MODULE_RESOLVERS_SLOT: &str = "__moliAudioWorkletModuleResolvers";
const AUDIO_WORKLET_MODULE_ERROR_SLOT: &str = "__moliAudioWorkletModuleError";
const AUDIO_WORKLET_MODULE_LOADED_SLOT: &str = "__moliAudioWorkletModuleLoaded";
const AUDIO_WORKLET_MODULE_SETTLED_SLOT: &str = "__moliAudioWorkletModuleSettled";
const AUDIO_WORKLET_CALLBACK_MODULE_SLOT: &str = "__moliAudioWorkletCallbackModule";
@@ -51,8 +51,6 @@ struct AudioContextObjectDeclaration<'scope> {
state: &'static str,
#[webapi(data_property)]
destination: v8::Local<'scope, v8::Object>,
#[webapi(data_property = "audioWorklet")]
audio_worklet: v8::Local<'scope, v8::Object>,
#[webapi(slot = SIMPLE_EVENT_TARGET_SLOT, value = AUDIO_CONTEXT_LISTENERS_SLOT)]
event_target_slot: (),
#[webapi(slot = AUDIO_CONTEXT_MODULES_SLOT)]
@@ -66,10 +64,10 @@ struct AudioContextObjectDeclaration<'scope> {
#[derive(WebApiObject)]
#[webapi(interface = web_api_interfaces::AudioWorklet)]
struct AudioWorkletObjectDeclaration<'scope> {
#[webapi(prototype)]
prototype: v8::Local<'scope, v8::Object>,
#[webapi(slot = AUDIO_WORKLET_CONTEXT_SLOT)]
context: v8::Local<'scope, v8::Object>,
#[webapi(method = "addModule", length = 1, callback = audio_worklet_add_module_callback)]
add_module: (),
}
#[derive(WebApiObject)]
@@ -88,10 +86,8 @@ struct AudioWorkletModuleStateDeclaration<'scope> {
context: v8::Local<'scope, v8::Object>,
#[webapi(slot = AUDIO_WORKLET_MODULE_WORKER_SLOT)]
worker: v8::Local<'scope, v8::Object>,
#[webapi(slot = AUDIO_WORKLET_MODULE_PROMISE_SLOT)]
promise: v8::Local<'scope, v8::Promise>,
#[webapi(slot = AUDIO_WORKLET_MODULE_RESOLVER_SLOT)]
resolver: v8::Local<'scope, v8::PromiseResolver>,
#[webapi(slot = AUDIO_WORKLET_MODULE_RESOLVERS_SLOT)]
resolvers: v8::Local<'scope, v8::Array>,
#[webapi(slot = AUDIO_WORKLET_MODULE_LOADED_SLOT)]
loaded: bool,
#[webapi(slot = AUDIO_WORKLET_MODULE_SETTLED_SLOT)]
@@ -480,6 +476,10 @@ pub(in crate::context_bootstrap) fn install_web_audio_template_bindings<'s>(
graph::install(scope, template);
}
match interface_name {
"Worklet" => WorkletPrototypeDeclaration::initialize_prototype_template(
scope,
template.prototype_template(scope),
),
"AudioParam" => audio_param::install(scope, template),
"BiquadFilterNode" => biquad::install(scope, template),
"BaseAudioContext" => {
@@ -528,17 +528,12 @@ fn audio_context_constructor_callback<'s>(
let modules = new_web_audio_map_object(scope);
let module_list = v8::Array::new(scope, 0);
let processors = new_web_audio_map_object(scope);
let audio_worklet = AudioWorkletObjectDeclaration::new(context)
.bind(scope)
.expect("AudioWorklet declaration should bind");
set_symbol_to_string_tag(scope, audio_worklet, "AudioWorklet");
AudioContextObjectDeclaration::new(
0.0,
44_100.0,
"running",
destination,
audio_worklet,
modules,
module_list,
processors,
@@ -579,20 +574,12 @@ fn audio_context_close_callback<'s>(
module_state,
AUDIO_WORKLET_MODULE_SETTLED_SLOT,
) {
set_audio_worklet_module_bool_slot(
let error = new_dom_exception_value(
scope,
module_state,
AUDIO_WORKLET_MODULE_SETTLED_SLOT,
true,
"AudioWorklet module loading was aborted.",
"AbortError",
);
if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) {
let error = new_dom_exception_value(
scope,
"AudioWorklet module loading was aborted.",
"AbortError",
);
let _ = resolver.reject(scope, error);
}
settle_audio_worklet_module(scope, module_state, Err(error));
}
if let Some(worker) =
web_audio_object_slot(scope, module_state, AUDIO_WORKLET_MODULE_WORKER_SLOT)
@@ -627,76 +614,184 @@ fn audio_context_close_callback<'s>(
}
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::Worklet, enumerable, receiver)]
struct WorkletPrototypeDeclaration {
#[webapi(method = "addModule", length = 1, returns_promise, callback = audio_worklet_add_module_callback)]
add_module: (),
}
#[derive(Default, WebApiObject)]
#[webapi(fragment, prototype = "BaseAudioContext", enumerable, receiver = web_api_interfaces::BaseAudioContext::is_instance)]
struct BaseAudioContextSecureAttributes {
#[webapi(accessor_property = "audioWorklet", getter = audio_worklet_getter)]
audio_worklet: (),
}
pub(super) fn finalize_base_audio_context_realm_bindings<'s>(
scope: &mut v8::PinScope<'s, '_>,
prototype: v8::Local<'s, v8::Object>,
) -> Result<()> {
let global = scope.get_current_context().global(scope);
if super::runtime_state::window_realm_secure_context_available(scope, global) {
BaseAudioContextSecureAttributes::default()
.initialize(scope, prototype)
.map_err(|error| anyhow!(error))?;
}
Ok(())
}
fn audio_worklet_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let context = args.this();
if let Some(worklet) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_WORKLET_SLOT) {
rv.set(worklet.into());
return;
}
let Some(realm) = context.get_creation_context(scope) else {
return;
};
let scope = &mut v8::ContextScope::new(scope, realm);
let Ok(prototype) = ensure_intrinsic_interface_prototype(scope, "AudioWorklet") else {
return;
};
let worklet = AudioWorkletObjectDeclaration::new(prototype, context)
.bind(scope)
.expect("AudioWorklet declaration should bind");
set_private_value(scope, context, AUDIO_CONTEXT_WORKLET_SLOT, worklet.into());
rv.set(worklet.into());
}
#[derive(Clone, Copy, Default, webidl::WebIdlEnum)]
#[webidl(name = "RequestCredentials", rename_all = "kebab-case")]
enum WorkletCredentials {
Omit,
#[default]
SameOrigin,
Include,
}
impl WorkletCredentials {
fn as_str(self) -> &'static str {
match self {
Self::Omit => "omit",
Self::SameOrigin => "same-origin",
Self::Include => "include",
}
}
}
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "WorkletOptions")]
struct WorkletOptions {
#[webidl(converter = "enum", default = WorkletCredentials::SameOrigin)]
credentials: WorkletCredentials,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "Worklet.addModule")]
struct WorkletAddModuleArgs {
#[webidl(required, converter = "usv_string")]
module_url: String,
#[webidl(with = worklet_options_arg)]
options: WorkletOptions,
}
fn worklet_options_arg<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: &v8::FunctionCallbackArguments<'s>,
index: i32,
) -> Result<WorkletOptions, webidl::WebIdlError> {
webidl::parse_dictionary(
scope,
args.get(index),
webidl::Context::argument("Worklet.addModule", 2),
)
.map(Option::unwrap_or_default)
}
fn audio_worklet_add_module_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let Some(parsed) = webidl::parse_args::<WorkletAddModuleArgs>(scope, &args) else {
return;
};
let worklet = args.this();
let Some(context) = web_audio_object_slot(scope, worklet, AUDIO_WORKLET_CONTEXT_SLOT) else {
let error = type_error_value(
scope,
"AudioWorklet.addModule called on incompatible receiver.",
)
.unwrap_or_else(|| v8::undefined(scope).into());
set_rejected_promise_return(scope, &mut rv, error);
throw_type_error(scope, "AudioWorklet context state is unavailable.");
return;
};
let Some(module_url) = resolve_audio_worklet_module_url(scope, args.get(0)) else {
let error = type_error_value(scope, "AudioWorklet.addModule module URL is invalid.")
.unwrap_or_else(|| v8::undefined(scope).into());
set_rejected_promise_return(scope, &mut rv, error);
// Argument conversion failures use the method's realm. The operation's
// Promise, URL resolution and workers use the Worklet's relevant realm.
let Some(realm) = worklet.get_creation_context(scope) else {
return;
};
let credentials = match audio_worklet_credentials(scope, &args) {
Ok(credentials) => credentials,
Err(message) => {
let error =
type_error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into());
set_rejected_promise_return(scope, &mut rv, error);
return;
}
};
let Some(modules) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_MODULES_SLOT) else {
let error = type_error_value(scope, "AudioWorklet context state is unavailable.")
.unwrap_or_else(|| v8::undefined(scope).into());
set_rejected_promise_return(scope, &mut rv, error);
return;
};
if let Some(existing_module) = map_get_object(scope, modules, &module_url)
&& let Some(promise) =
get_private_value(scope, existing_module, AUDIO_WORKLET_MODULE_PROMISE_SLOT)
.and_then(|value| v8::Local::<v8::Promise>::try_from(value).ok())
{
rv.set(promise.into());
return;
}
let scope = &mut v8::ContextScope::new(scope, realm);
let Some(resolver) = v8::PromiseResolver::new(scope) else {
rv.set_undefined();
return;
};
let promise = resolver.get_promise(scope);
let Some(worker) = create_audio_worklet_module_worker(scope, &module_url, credentials) else {
rv.set(promise.into());
let Some(module_url) = resolve_audio_worklet_module_url(scope, &parsed.module_url) else {
let error = new_dom_exception_value(
scope,
"AudioWorklet.addModule module URL is invalid.",
"SyntaxError",
);
let _ = resolver.reject(scope, error);
return;
};
let Some(modules) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_MODULES_SLOT) else {
let error = v8::Exception::type_error(
scope,
v8str(scope, "AudioWorklet context state is unavailable."),
);
let _ = resolver.reject(scope, error);
return;
};
if let Some(module_state) = map_get_object(scope, modules, &module_url) {
if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_SETTLED_SLOT) {
if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_LOADED_SLOT)
{
let _ = resolver.resolve(scope, v8::undefined(scope).into());
} else if let Some(error) =
get_private_value(scope, module_state, AUDIO_WORKLET_MODULE_ERROR_SLOT)
{
let _ = resolver.reject(scope, error);
}
} else if let Some(resolvers) =
web_audio_array_slot(scope, module_state, AUDIO_WORKLET_MODULE_RESOLVERS_SLOT)
{
let key = v8_string(scope, &resolvers.length().to_string()).expect("array index");
let _ = resolvers.create_data_property(scope, key.into(), resolver.into());
}
return;
}
let Some(worker) =
create_audio_worklet_module_worker(scope, &module_url, parsed.options.credentials.as_str())
else {
let error = type_error_value(scope, "AudioWorklet module failed.")
.unwrap_or_else(|| v8::undefined(scope).into());
let _ = resolver.reject(scope, error);
rv.set(promise.into());
return;
};
let resolvers = v8::Array::new_with_elements(scope, &[resolver.into()]);
let module_state =
AudioWorkletModuleStateDeclaration::new(context, worker, promise, resolver, false, false)
AudioWorkletModuleStateDeclaration::new(context, worker, resolvers, false, false)
.bind(scope)
.expect("AudioWorklet module state declaration should bind");
install_audio_worklet_worker_callbacks(scope, worker, module_state);
let _ = map_set_object(scope, modules, &module_url, module_state);
if let Some(module_list) = web_audio_array_slot(scope, context, AUDIO_CONTEXT_MODULE_LIST_SLOT)
{
array_push_value(scope, module_list, module_state.into());
let key = v8_string(scope, &module_list.length().to_string()).expect("array index");
let _ = module_list.create_data_property(scope, key.into(), module_state.into());
}
rv.set(promise.into());
}
fn install_audio_worklet_worker_callbacks<'s>(
@@ -756,35 +851,12 @@ fn audio_worklet_worker_message_callback<'s>(
};
let _ = map_set_object(scope, processors, &name, module_state);
}
"module-loaded" => {
if audio_worklet_module_bool_slot(
scope,
module_state,
AUDIO_WORKLET_MODULE_SETTLED_SLOT,
) {
return;
}
set_audio_worklet_module_bool_slot(
scope,
module_state,
AUDIO_WORKLET_MODULE_LOADED_SLOT,
true,
);
set_audio_worklet_module_bool_slot(
scope,
module_state,
AUDIO_WORKLET_MODULE_SETTLED_SLOT,
true,
);
if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) {
let _ = resolver.resolve(scope, v8::undefined(scope).into());
}
}
"module-loaded" => settle_audio_worklet_module(scope, module_state, Ok(())),
"processor-error" => {
let message = object_string_property(scope, message, "message")
.unwrap_or_else(|| "AudioWorklet processor failed.".to_owned());
let error = error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into());
fail_audio_worklet_module(scope, module_state, error);
settle_audio_worklet_module(scope, module_state, Err(error));
}
_ => {}
}
@@ -793,7 +865,7 @@ fn audio_worklet_worker_message_callback<'s>(
fn audio_worklet_worker_error_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'s, v8::Value>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let Some(module_state) = audio_worklet_callback_module_state(scope, &args) else {
return;
@@ -807,7 +879,10 @@ fn audio_worklet_worker_error_callback<'s>(
})
.unwrap_or_else(|| "AudioWorklet module failed.".to_owned());
let error = error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into());
fail_audio_worklet_module(scope, module_state, error);
settle_audio_worklet_module(scope, module_state, Err(error));
// This internal worker failure is reported through addModule's Promise.
// Cancel the handled error instead of reporting it again on Window.
rv.set_bool(false);
}
fn audio_worklet_node_constructor_callback<'s>(
@@ -884,28 +959,6 @@ fn audio_worklet_node_constructor_callback<'s>(
rv.set(node.into());
}
fn set_rejected_promise_return<'s>(
scope: &mut v8::PinScope<'s, '_>,
rv: &mut v8::ReturnValue<'s, v8::Value>,
error: v8::Local<'s, v8::Value>,
) {
if let Some(promise) = rejected_promise(scope, error) {
rv.set(promise.into());
} else {
rv.set(v8::undefined(scope).into());
}
}
fn rejected_promise<'s>(
scope: &mut v8::PinScope<'s, '_>,
error: v8::Local<'s, v8::Value>,
) -> Option<v8::Local<'s, v8::Promise>> {
let resolver = v8::PromiseResolver::new(scope)?;
let promise = resolver.get_promise(scope);
let _ = resolver.reject(scope, error);
Some(promise)
}
fn resolved_undefined_promise<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Promise>> {
@@ -958,50 +1011,23 @@ fn map_set_object<'s>(
fn resolve_audio_worklet_module_url(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
input: &str,
) -> Option<String> {
let input = value.to_string(scope)?.to_rust_string_lossy(scope);
let base_url = if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
let host = unsafe { &*host_ptr };
super::worker_host::worker_constructor_base_url(host)
} else {
let global = scope.get_current_context().global(scope);
let location = object_property_as_object(scope, global, "location")?;
let href = object_string_property(scope, location, "href")?;
url::Url::parse(&href).ok()?
};
base_url.join(&input).ok().map(|url| url.to_string())
}
use crate::native_bridge::OwnerDispatchScope;
fn audio_worklet_credentials(
scope: &mut v8::PinScope<'_, '_>,
args: &v8::FunctionCallbackArguments<'_>,
) -> std::result::Result<&'static str, String> {
let value = args.get(1);
if value.is_undefined() || value.is_null() {
return Ok("same-origin");
}
let Ok(options) = v8::Local::<v8::Object>::try_from(value) else {
return Ok("same-origin");
let host_ptr = context_host_ptr_from_global_bridge(scope)?;
// SAFETY: the bridge owns the host for the lifetime of the callback.
let host = unsafe { &*host_ptr };
let global = scope.get_current_context().global(scope);
let document = match super::navigation_window::runtime_window_dispatch_scope(scope, global)? {
OwnerDispatchScope::Top => host.document_handle(),
OwnerDispatchScope::Child(handle) => host.child_browsing_context_document_handle(handle)?,
OwnerDispatchScope::LightweightPopup(id) => host.lightweight_popup_document_handle(id)?,
};
let Some(credentials) = options.get(scope, v8str(scope, "credentials").into()) else {
return Ok("same-origin");
};
if credentials.is_undefined() {
return Ok("same-origin");
}
let Some(credentials) = credentials.to_string(scope) else {
return Err("AudioWorklet.addModule options.credentials is invalid.".to_owned());
};
let credentials = credentials.to_rust_string_lossy(scope);
match credentials.as_str() {
"omit" => Ok("omit"),
"same-origin" => Ok("same-origin"),
"include" => Ok("include"),
_ => Err(format!(
"The provided value '{credentials}' is not a valid enum value of type RequestCredentials."
)),
}
host.document_base_url_for_handle(document)
.join(input)
.ok()
.map(|url| url.to_string())
}
fn create_audio_worklet_module_worker<'s>(
@@ -1176,10 +1202,10 @@ fn audio_worklet_callback_module_state<'s>(
web_audio_object_slot(scope, data, AUDIO_WORKLET_CALLBACK_MODULE_SLOT)
}
fn fail_audio_worklet_module<'s>(
fn settle_audio_worklet_module<'s>(
scope: &mut v8::PinScope<'s, '_>,
module_state: v8::Local<'s, v8::Object>,
error: v8::Local<'s, v8::Value>,
result: Result<(), v8::Local<'s, v8::Value>>,
) {
if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_SETTLED_SLOT) {
return;
@@ -1190,8 +1216,45 @@ fn fail_audio_worklet_module<'s>(
AUDIO_WORKLET_MODULE_SETTLED_SLOT,
true,
);
if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) {
let _ = resolver.reject(scope, error);
set_audio_worklet_module_bool_slot(
scope,
module_state,
AUDIO_WORKLET_MODULE_LOADED_SLOT,
result.is_ok(),
);
if let Err(error) = result {
set_private_value(scope, module_state, AUDIO_WORKLET_MODULE_ERROR_SLOT, error);
}
let Some(resolvers) =
web_audio_array_slot(scope, module_state, AUDIO_WORKLET_MODULE_RESOLVERS_SLOT)
else {
return;
};
// Drop the state's references before settling, and never invoke author
// Promise.then / constructor properties to join a module response.
set_private_value(
scope,
module_state,
AUDIO_WORKLET_MODULE_RESOLVERS_SLOT,
v8::undefined(scope).into(),
);
for index in 0..resolvers.length() {
let Some(value) = resolvers.get_index(scope, index) else {
continue;
};
let Ok(object) = v8::Local::<v8::Object>::try_from(value) else {
continue;
};
// Only native PromiseResolvers are written to this private array.
let resolver = unsafe { v8::Local::<v8::PromiseResolver>::cast_unchecked(object) };
match result {
Ok(()) => {
let _ = resolver.resolve(scope, v8::undefined(scope).into());
}
Err(error) => {
let _ = resolver.reject(scope, error);
}
}
}
}
@@ -1215,15 +1278,6 @@ fn set_audio_worklet_module_bool_slot<'s>(
set_private_value(scope, object, slot, value.into());
}
fn audio_worklet_module_resolver<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::PromiseResolver>> {
get_private_value(scope, object, AUDIO_WORKLET_MODULE_RESOLVER_SLOT)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
.map(|object| unsafe { v8::Local::<v8::PromiseResolver>::cast_unchecked(object) })
}
fn dynamics_compressor_reduction_getter_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
@@ -1272,6 +1326,22 @@ pub(in crate::context_bootstrap) fn offline_audio_context_constructor_callback<'
let context = args.this();
let destination = audio_destination_node(scope, context);
let compressors = v8::Array::new(scope, 0);
let modules = new_web_audio_map_object(scope);
let module_list = v8::Array::new(scope, 0);
let processors = new_web_audio_map_object(scope);
set_private_value(scope, context, AUDIO_CONTEXT_MODULES_SLOT, modules.into());
set_private_value(
scope,
context,
AUDIO_CONTEXT_MODULE_LIST_SLOT,
module_list.into(),
);
set_private_value(
scope,
context,
AUDIO_CONTEXT_PROCESSORS_SLOT,
processors.into(),
);
OfflineAudioContextObjectDeclaration::new(
0.0,
length,
@@ -227,7 +227,14 @@ async fn audio_worklet_close_rejects_pending_add_module_response() {
globalThis.__audioWorkletCloseDone = false;
const context = new AudioContext();
globalThis.__audioWorkletCloseContext = context;
context.audioWorklet.addModule({module_url_literal}).then(
const first = context.audioWorklet.addModule({module_url_literal});
const second = context.audioWorklet.addModule({module_url_literal});
Promise.allSettled([first, second]).then(results => {{
if (first === second || results.some(result => result.status !== "rejected" || result.reason.name !== "AbortError")) {{
throw new Error("Every pending caller must independently reject on close");
}}
throw results[0].reason;
}}).then(
() => {{
globalThis.__audioWorkletCloseResult = "resolved";
globalThis.__audioWorkletCloseDone = true;
@@ -1447,3 +1454,47 @@ async fn worker_owned_messageport_options_transfer_null_clone_path_responds() {
})
.await;
}
#[tokio::test]
async fn worklet_module_promises_preserve_independent_native_waiters() {
run_page_vm_async_test(async move {
let mut page_vm = test_page_vm_with_document_url(Url::parse("https://worklet-promises.test/").unwrap());
let local_executor = page_vm.local_executor.clone();
let result = local_executor.run(async move {
page_vm.vm_mut().eval(&format!("({}).then(value => globalThis.__workletDone = value, error => globalThis.__workletDone = String(error));", include_str!("../../../../script_vm/tests/worklet_module_promises.js")))?;
drive_websocket_until_done(&mut page_vm, "String(globalThis.__workletDone !== undefined)", "Worklet module waiters should settle").await?;
page_vm.vm_mut().eval("JSON.stringify(__nodeReplacementResults.failures)")
}).await.expect("Worklet module fixture should complete");
assert_eq!(result, "[]");
}).await;
}
#[tokio::test]
async fn borrowed_worklet_add_module_uses_receiver_document_base() {
run_page_vm_async_test(async move {
let (base_url, request_rx, server) = spawn_shared_worker_script_capture_http_server(
"registerProcessor('receiver-base', class extends AudioWorkletProcessor {});",
).await;
let mut page_vm = test_page_vm_with_document_url(Url::parse(&format!("{base_url}/main/page.html")).unwrap());
let base = serde_json::to_string(&format!("{base_url}/worklet/")).unwrap();
let local_executor = page_vm.local_executor.clone();
let result = local_executor.run(async move {
page_vm.vm_mut().eval(&format!(r#"
const frame = document.createElement('iframe'); document.body.append(frame);
const other = frame.contentWindow;
const base = other.document.createElement('base'); base.href = {base}; other.document.head.append(base);
const context = new other.AudioContext();
Worklet.prototype.addModule.call(context.audioWorklet, 'entry.js').then(() => {{
globalThis.__receiverBaseResult = String(new other.AudioWorkletNode(context, 'receiver-base') instanceof other.AudioWorkletNode);
return context.close();
}}).catch(error => globalThis.__receiverBaseResult = String(error));
"#))?;
drive_websocket_until_done(&mut page_vm, "String(globalThis.__receiverBaseResult !== undefined)", "borrowed addModule should settle").await?;
page_vm.vm_mut().eval("__receiverBaseResult")
}).await.expect("receiver base URL test should complete");
assert_eq!(result, "true");
let request = request_rx.await.expect("module request");
assert!(request.starts_with("GET /worklet/entry.js "), "{request}");
server.await.expect("server should finish");
}).await;
}
@@ -2156,6 +2156,7 @@ mod webidl_receivers;
mod webidl_trusted_types;
mod websocket;
mod window_execution_context;
mod worklet_interfaces;
mod string_timers;
@@ -0,0 +1,88 @@
(async () => {
const rows = [], assert = (ok, message) => { if (!ok) throw Error(message); };
const check = async (name, run) => { try { await run(); rows.push({ name, pass: true }); } catch (error) { rows.push({ name, pass: false, message: String(error) }); } };
const realms = [['main', window], ['child', document.getElementById('child').contentWindow]];
for (const [label, w] of realms) {
if (!w.isSecureContext) {
await check(label + '/insecure', () => assert(!('Worklet' in w) && !('AudioWorklet' in w) && !('audioWorklet' in w.BaseAudioContext.prototype) && !('audioWorklet' in new w.OfflineAudioContext(1,1,44100)), 'secure globals and attribute hidden'));
continue;
}
for (const name of ['Worklet', 'AudioWorklet']) {
await check(label + '/' + name, () => {
const C = w[name], parent = name === 'AudioWorklet' ? w.Worklet : w.Object;
const d = Object.getOwnPropertyDescriptor(w, name);
assert(typeof C === 'function' && C.name === name && C.length === 0, 'constructor metadata');
assert(d.writable && d.configurable && !d.enumerable, 'global descriptor');
assert(Object.getPrototypeOf(C.prototype) === parent.prototype && Object.getPrototypeOf(C) === (parent === w.Object ? w.Function.prototype : parent), 'native inheritance');
const tag = Object.getOwnPropertyDescriptor(C.prototype, Symbol.toStringTag);
assert(tag.value === name && tag.configurable && !tag.writable && !tag.enumerable, 'prototype tag');
for (const call of [() => C(), () => new C()]) { let error; try { call(); } catch (e) { error = e; } assert(error instanceof w.TypeError, 'illegal constructor'); }
});
}
await check(label + '/receiver', async () => {
const method = w.Worklet.prototype.addModule, d = Object.getOwnPropertyDescriptor(w.Worklet.prototype, 'addModule');
assert(method.length === 1 && d.enumerable && d.configurable && d.writable, 'shared operation descriptor');
let conversions = 0, traps = 0; const trap = () => { traps++; throw Error('author trap'); };
const url = { toString() { conversions++; return 'data:text/javascript,'; } }, options = { get credentials() { conversions++; return 'same-origin'; } };
const revoked = Proxy.revocable({}, {}); revoked.revoke();
for (const receiver of [null, {}, w.Worklet.prototype, w.AudioWorklet.prototype, Object.create(w.Worklet.prototype), new Proxy({}, { get: trap, getPrototypeOf: trap }), revoked.proxy]) {
const promise = method.call(receiver, url, options); assert(promise instanceof w.Promise, 'callee Promise');
let error; try { await promise; } catch (e) { error = e; } assert(error instanceof w.TypeError, 'callee TypeError rejection');
}
assert(conversions === 0 && traps === 0, 'receiver validation precedes author callbacks');
});
let context;
try {
await check(label + '/native-factory', () => {
const C = w.AudioWorklet, descriptor = Object.getOwnPropertyDescriptor(w, 'AudioWorklet'); let reads = 0;
Object.defineProperty(w, 'AudioWorklet', { configurable: true, get() { reads++; throw Error('author constructor'); } });
try { context = new w.AudioContext(); } finally { if (descriptor) Object.defineProperty(w, 'AudioWorklet', descriptor); else delete w.AudioWorklet; }
const worklet = context.audioWorklet;
const d = Object.getOwnPropertyDescriptor(w.BaseAudioContext.prototype, 'audioWorklet');
assert(d && typeof d.get === 'function' && d.set === undefined && d.enumerable && d.configurable && !Object.hasOwn(context, 'audioWorklet'), 'shared readonly attribute');
const offline = new w.OfflineAudioContext(1, 1, 44100);
assert(Object.getPrototypeOf(offline.audioWorklet) === C.prototype && offline.audioWorklet === offline.audioWorklet, 'offline worklet');
assert(d.get.call(context) === worklet, 'genuine receiver');
for (const value of [{}, Object.create(context), new Proxy(context, {})]) { let error; try {d.get.call(value);} catch(e) {error=e;} assert(error instanceof w.TypeError, 'getter receiver'); }
assert(Object.getPrototypeOf(worklet) === C.prototype && worklet instanceof w.Worklet, 'native derived prototype');
assert(worklet === context.audioWorklet && !Object.hasOwn(worklet, 'addModule') && worklet.addModule === w.Worklet.prototype.addModule && reads === 0, 'shared operation and intrinsic factory');
});
await check(label + '/argument-conversion', async () => {
const worklet = context.audioWorklet;
for (const args of [[], [Symbol()], ['data:text/javascript,', 7], ['data:text/javascript,', {credentials:'invalid'}]]) {
let error; const promise=worklet.addModule(...args); assert(promise instanceof w.Promise, 'conversion rejection is Promise');
try { await promise; } catch (e) { error=e; } assert(error instanceof w.TypeError, 'invalid arguments');
}
const sentinel = {}, order=[]; let error;
const promise=worklet.addModule({toString(){order.push('url');return 'data:text/javascript,';}},{get credentials(){order.push('credentials');return {toString(){order.push('string');throw sentinel;}};}});
try { await promise; } catch (e) {error=e;}
assert(error===sentinel && order.join()==='url,credentials,string', 'dictionary order and original exception');
});
await check(label + '/invalid-url', async () => {
let error;
try { await context.audioWorklet.addModule('https://['); } catch(e) {error=e;}
assert(error instanceof w.DOMException && error.name === 'SyntaxError', 'invalid URL DOMException');
const sentinel = {};
try { await context.audioWorklet.addModule('https://[', {get credentials(){throw sentinel;}}); } catch(e) {error=e;}
assert(error === sentinel, 'dictionary conversion before URL parsing');
});
await check(label + '/cross-realm', async () => {
const other = realms.find(([,r]) => r !== w)[1];
const otherContext = new other.AudioContext();
try {
const getter = Object.getOwnPropertyDescriptor(w.BaseAudioContext.prototype, 'audioWorklet').get;
const receiver = getter.call(otherContext);
assert(Object.getPrototypeOf(receiver) === other.AudioWorklet.prototype, 'getter uses receiver realm');
const p = w.Worklet.prototype.addModule.call(receiver, 'https://[');
let error; try {await p;} catch(e) {error=e;}
assert(p instanceof other.Promise && error instanceof other.DOMException && error.name === 'SyntaxError', 'valid receiver realm');
const invalid = w.Worklet.prototype.addModule.call(receiver, Symbol());
try {await invalid;} catch(e) {error=e;}
assert(invalid instanceof w.Promise && error instanceof w.TypeError, 'conversion rejection callee realm');
} finally {await otherContext.close();}
});
} finally { if (context) await context.close(); }
}
globalThis.__nodeReplacementResults={rows,failures:rows.filter(row=>!row.pass),passed:rows.filter(row=>row.pass).length,total:rows.length};
return rows.every(row=>row.pass);
})()
@@ -0,0 +1,21 @@
use super::*;
#[test]
fn worklet_interfaces_preserve_realms_brands_and_secure_exposure() {
for url in [
"https://worklet-interfaces.test/",
"http://worklet-interfaces.test/",
] {
let mut vm = new_storage_page_task_executor_test_vm(url);
vm.eval("document.body.innerHTML = '<iframe id=child></iframe>'")
.unwrap();
vm.eval(&format!("({}).then(value => globalThis.__workletDone = value, error => globalThis.__workletDone = String(error));", include_str!("worklet_interfaces.js"))).unwrap();
assert_eq!(
vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)")
.unwrap(),
"[]",
"{url}"
);
assert_eq!(vm.eval("__workletDone").unwrap(), "true", "{url}");
}
}
@@ -0,0 +1,49 @@
(async () => {
const rows = [], assert = (ok, message) => { if (!ok) throw Error(message); };
const check = async (name, run) => { try { await run(); rows.push({ name, pass: true }); } catch (error) { rows.push({ name, pass: false, message: String(error) }); } };
const context = new AudioContext(), worklet = context.audioWorklet;
const source = text => 'data:text/javascript,' + encodeURIComponent(text);
const errors = [], onError = event => errors.push(event.message);
addEventListener('error', onError);
try {
await check('pending-and-completed-module-promises', async () => {
const url = source('registerProcessor("fresh-promise", class extends AudioWorkletProcessor {});');
const first = worklet.addModule(url); let reads=0;
const poison = () => {reads++; throw Error('public Promise property');};
Object.defineProperties(first, {then:{get:poison,configurable:true}, constructor:{get:poison,configurable:true}});
let second;
// A second call appends a native waiter; inherited array setters must not run.
Object.defineProperty(Array.prototype, '1', {configurable:true, set:poison});
try {second = worklet.addModule(url);} finally {delete Array.prototype[1];delete first.then;delete first.constructor;}
assert(reads===0 && first!==second, 'independent pending Promise without author hooks');
const values = await Promise.all([first, second]);
assert(values.every(value => value===undefined), 'all callers fulfilled');
Object.defineProperties(first, {then:{get:poison,configurable:true}, constructor:{get:poison,configurable:true}});
let third;
try {third=worklet.addModule(url);} finally {delete first.then;delete first.constructor;}
assert(reads===0 && third!==first && third!==second, 'independent completed Promise');
assert(await third === undefined, 'completed response reused');
assert(new AudioWorkletNode(context, 'fresh-promise') instanceof AudioWorkletNode, 'processor registration retained');
});
await check('failed-module-promises', async () => {
const url=source('export {');
const first=worklet.addModule(url), second=worklet.addModule(url);
assert(first!==second, 'independent failed pending Promise');
const results=await Promise.allSettled([first,second]);
assert(results.every(result=>result.status==='rejected'), 'all failed waiters settle');
const third=worklet.addModule(url);assert(third!==first && third!==second, 'independent rejected cached Promise');
const [result]=await Promise.allSettled([third]);assert(result.status==='rejected', 'cached failure settles');
assert(errors.length === 0, 'module rejection does not report internal Worker error on Window');
});
await check('offline-worklet-module', async () => {
const offline=new OfflineAudioContext(1,1,44100);
const url=source('registerProcessor("offline-module", class extends AudioWorkletProcessor {});');
await offline.audioWorklet.addModule(url);
const node=new AudioWorkletNode(offline, 'offline-module');
assert(node instanceof AudioWorkletNode, 'offline processor registered');
node.connect(offline.destination);
});
} finally {removeEventListener('error', onError);await context.close();}
globalThis.__nodeReplacementResults={rows,failures:rows.filter(row=>!row.pass),passed:rows.filter(row=>row.pass).length,total:rows.length};
return rows.every(row=>row.pass);
})()
+2 -2
View File
@@ -13,8 +13,8 @@ pub use moli_v8_util::{
object_own_static_string_property, object_property_as_array, object_property_as_object,
object_string_property, private_key, register_intrinsic_interface,
registered_intrinsic_constructor, registered_intrinsic_prototype, set_null_prototype,
set_private_value, set_symbol_to_string_tag, throw_range_error, throw_type_error,
v8_json_parse, v8_string, v8str, walk_object_chain,
set_private_value, throw_range_error, throw_type_error, v8_json_parse, v8_string, v8str,
walk_object_chain,
};
use moli_webapi_declare::WebApiValue;
pub(crate) use moli_webapi_declare::define_array_data_property as define_v8_array_data_property;
+2 -1
View File
@@ -35,7 +35,8 @@ interfaces! {
AudioContext: BaseAudioContext;
AudioDestinationNode;
AudioParam;
AudioWorklet;
Worklet;
AudioWorklet: Worklet;
AudioWorkletNode;
BaseAudioContext: EventTarget;
BatteryManager;
@@ -1889,6 +1889,24 @@ async fn worker_self_close() {
// ─── Error propagation ──────────────────────────────────────────────
#[tokio::test]
async fn worker_does_not_expose_window_worklet_interfaces() {
ensure_v8();
let mut handle = spawn_worker(
"postMessage({Worklet: 'Worklet' in self, AudioWorklet: 'AudioWorklet' in self}); close();"
.to_owned(),
"https://worklet-worker.test/worker.js".into(),
);
let message = timeout(TIMEOUT, handle.recv())
.await
.expect("timed out")
.expect("channel closed");
assert_eq!(
expect_post_json(message),
r#"{"Worklet":false,"AudioWorklet":false}"#
);
}
#[tokio::test]
async fn worker_error_propagation() {
ensure_v8();