refactor(webidl): derive dictionary snapshots for RTC configuration

This commit is contained in:
ldm0
2026-10-19 05:26:21 +08:00
committed by Donough Liu
parent b6a1e93e89
commit fa960b2fc1
10 changed files with 529 additions and 96 deletions
@@ -6,10 +6,11 @@ use super::{
RTC_PEER_CONNECTION_SIGNALING_STATE_SLOT,
};
use crate::{
util::{get_private_value, new_null_prototype_object, set_private_value, v8str},
util::{get_private_value, set_private_value, v8str},
web_api_interfaces, webidl,
};
use moli_webapi_declare::{WebApiObject, WebApiValue};
use webidl::{SnapshotKind, SnapshotPrototype, WebIdlDictionarySnapshot, WebIdlSnapshotValue};
#[derive(Clone, Copy, webidl::WebIdlEnum)]
#[webidl(name = "RTCBundlePolicy")]
@@ -67,6 +68,10 @@ impl<'s> WebApiValue<'s> for RtcpMuxPolicy {
struct PoolSize(u8);
impl WebIdlSnapshotValue for PoolSize {
const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Identity;
}
impl<'s> webidl::WebIdlConverter<'s> for PoolSize {
type Options = ();
@@ -91,6 +96,10 @@ impl<'s> WebApiValue<'s> for PoolSize {
struct Text(webidl::DomString16);
impl WebIdlSnapshotValue for Text {
const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Identity;
}
impl<'s> webidl::WebIdlConverter<'s> for Text {
type Options = ();
@@ -112,6 +121,11 @@ impl<'s> WebApiValue<'s> for Text {
struct IceUrls(Vec<String>);
impl WebIdlSnapshotValue for IceUrls {
// The union converter always binds its canonical value as a URL sequence.
const SNAPSHOT_KIND: SnapshotKind = SnapshotKind::Sequence(&SnapshotKind::Identity);
}
impl<'s> webidl::WebIdlConverter<'s> for IceUrls {
type Options = ();
@@ -142,41 +156,32 @@ impl<'s> WebApiValue<'s> for IceUrls {
}
#[derive(webidl::WebIdlDictionary, WebApiObject)]
#[webidl(prefix = "RTCIceServer")]
#[webapi(plain, enumerable)]
#[webidl(prefix = "RTCIceServer", snapshot)]
#[webapi(plain, data_properties, enumerable)]
struct IceServer {
#[webidl(converter = "raw")]
#[webapi(data_property)]
credential: Option<Text>,
#[webidl(required, converter = "raw")]
#[webapi(data_property)]
urls: IceUrls,
#[webidl(converter = "raw")]
#[webapi(data_property)]
username: Option<Text>,
}
#[derive(webidl::WebIdlDictionary, WebApiObject)]
#[webidl(prefix = "RTCConfiguration")]
#[webapi(plain, enumerable)]
#[webidl(prefix = "RTCConfiguration", snapshot)]
#[webapi(plain, data_properties, enumerable)]
struct Configuration<'s> {
#[webidl(converter = "enum", default = BundlePolicy::Balanced)]
#[webapi(data_property)]
bundle_policy: BundlePolicy,
#[webidl(sequence, interface = web_api_interfaces::RTCCertificate, default = Vec::new())]
#[webapi(data_property)]
certificates: Vec<v8::Local<'s, v8::Object>>,
#[webidl(converter = "raw", default = PoolSize(0))]
#[webapi(data_property)]
ice_candidate_pool_size: PoolSize,
#[webidl(sequence, converter = "dictionary", default = Vec::new())]
#[webapi(data_property)]
ice_servers: Vec<IceServer>,
#[webidl(converter = "enum", default = IceTransportPolicy::All)]
#[webapi(data_property)]
ice_transport_policy: IceTransportPolicy,
#[webidl(converter = "enum", default = RtcpMuxPolicy::Require)]
#[webapi(data_property)]
rtcp_mux_policy: RtcpMuxPolicy,
}
@@ -197,7 +202,10 @@ pub(super) fn constructor_configuration<'s>(
.configuration
.bind(scope)
.expect("converted RTCConfiguration");
Some(copy_configuration(scope, object, true))
Some(
Configuration::to_js_snapshot(scope, object, SnapshotPrototype::Null)
.expect("converted RTCConfiguration snapshot"),
)
}
fn target<'s>(
@@ -224,7 +232,9 @@ pub(super) fn get_configuration<'s>(
) {
let target = target(scope, args.this());
let stored = configuration(scope, target);
rv.set(copy_configuration(scope, stored, false).into());
let snapshot = Configuration::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary)
.expect("native RTCConfiguration snapshot");
rv.set(snapshot.into());
}
pub(super) fn set_configuration<'s>(
@@ -271,7 +281,8 @@ pub(super) fn set_configuration<'s>(
if validate_servers(scope, &parsed.configuration).is_none() {
return;
}
let snapshot = copy_configuration(scope, next, true);
let snapshot = Configuration::to_js_snapshot(scope, next, SnapshotPrototype::Null)
.expect("converted RTCConfiguration snapshot");
set_private_value(
scope,
target,
@@ -381,78 +392,3 @@ fn same_certificates<'s>(
.strict_equals(next.get_index(scope, index).expect("certificate"))
})
}
fn copy_configuration<'s>(
scope: &mut v8::PinScope<'s, '_>,
source: v8::Local<'s, v8::Object>,
internal: bool,
) -> v8::Local<'s, v8::Object> {
let output = if internal {
new_null_prototype_object(scope)
} else {
v8::Object::new(scope)
};
for name in [
"bundlePolicy",
"certificates",
"iceCandidatePoolSize",
"iceServers",
"iceTransportPolicy",
"rtcpMuxPolicy",
] {
let mut value = property(scope, source, name);
if matches!(name, "certificates" | "iceServers") {
let array = v8::Local::<v8::Array>::try_from(value).expect("private sequence");
let mut items = Vec::new();
for index in 0..array.length() {
let mut item = array
.get_index(scope, index)
.expect("private sequence item");
if name == "iceServers" {
let server =
v8::Local::<v8::Object>::try_from(item).expect("private ICE server");
item = copy_server(scope, server, internal).into();
}
items.push(item);
}
value = v8::Array::new_with_elements(scope, &items).into();
}
assert_eq!(
output.create_data_property(scope, v8str(scope, name).into(), value),
Some(true)
);
}
output
}
fn copy_server<'s>(
scope: &mut v8::PinScope<'s, '_>,
source: v8::Local<'s, v8::Object>,
internal: bool,
) -> v8::Local<'s, v8::Object> {
let output = if internal {
new_null_prototype_object(scope)
} else {
v8::Object::new(scope)
};
for name in ["credential", "urls", "username"] {
let key = v8str(scope, name);
// Optional members are absent, not inherited from a polluted prototype.
if source.has_own_property(scope, key.into()) != Some(true) {
continue;
}
let mut value = property(scope, source, name);
if name == "urls" {
let array = v8::Local::<v8::Array>::try_from(value).expect("private URL sequence");
let items: Vec<_> = (0..array.length())
.map(|index| array.get_index(scope, index).expect("private URL"))
.collect();
value = v8::Array::new_with_elements(scope, &items).into();
}
assert_eq!(
output.create_data_property(scope, key.into(), value),
Some(true)
);
}
output
}
@@ -63,6 +63,51 @@ fn rtc_configuration_uses_registered_native_proxy_identity() {
})()"#).unwrap(), "true");
}
#[test]
fn rtc_configuration_snapshots_preserve_certificate_identity_and_immutability() {
let mut vm = new_storage_page_task_executor_test_vm("https://rtc-configuration.test/");
let context_ptr = &vm.page_default_runtime.context as *const _;
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
let certificate = v8::Object::new(scope);
crate::web_api_interfaces::RTCCertificate::DESCRIPTOR
.initialize(scope, certificate)
.unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, certificate, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
let global = scope.get_current_context().global(scope);
for (name, value) in [
("certificate", v8::Local::<v8::Value>::from(certificate)),
("nativeCertificate", proxy.into()),
] {
assert_eq!(
global.create_data_property(scope, crate::util::v8str(scope, name).into(), value),
Some(true)
);
}
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"(() => {
const input = [certificate, nativeCertificate];
const pc = new RTCPeerConnection({certificates:input});
input.length = 0;
const first = pc.getConfiguration();
if (first.certificates.length !== 2 || first.certificates[0] !== certificate || first.certificates[1] !== nativeCertificate) return false;
first.certificates.reverse();
pc.setConfiguration({certificates:[certificate, nativeCertificate], iceTransportPolicy:'relay'});
const next = pc.getConfiguration();
if (first.certificates === next.certificates || next.certificates[0] !== certificate || next.certificates[1] !== nativeCertificate || next.iceTransportPolicy !== 'relay') return false;
for (const certificates of [[], [certificate], [nativeCertificate, certificate]]) {
try {pc.setConfiguration({certificates}); return false;}
catch (error) {if (error.name !== 'InvalidModificationError') return false;}
}
const unchanged = pc.getConfiguration();
pc.close();
return unchanged.certificates[0] === certificate && unchanged.certificates[1] === nativeCertificate && unchanged.iceTransportPolicy === 'relay';
})()"#).unwrap(), "true");
}
#[test]
fn rtc_configuration_applies_reentrant_updates_before_validating_the_outer_call() {
let mut vm = new_storage_page_task_executor_test_vm("https://rtc-configuration.test/");
+8
View File
@@ -7,12 +7,20 @@ pub(crate) struct ContainerAttrs {
pub(crate) prefix: Option<LitStr>,
pub(crate) scope_lifetime: Option<syn::Lifetime>,
pub(crate) rename_all: Option<RenameRule>,
pub(crate) snapshot: bool,
}
pub(crate) fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result<ContainerAttrs, Error> {
let mut parsed = ContainerAttrs::default();
for attr in attrs.iter().filter(|attr| attr.path().is_ident("webidl")) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("snapshot") {
if parsed.snapshot {
return Err(meta.error("duplicate snapshot attribute"));
}
parsed.snapshot = true;
return Ok(());
}
if meta.path.is_ident("prefix") {
parsed.prefix = Some(meta.value()?.parse()?);
return Ok(());
+1 -1
View File
@@ -247,7 +247,7 @@ impl ConverterKind {
/// A field conversion, optionally collecting an iterable of converted items.
#[derive(Clone, Copy)]
pub(crate) struct FieldConverter {
kind: ConverterKind,
pub(crate) kind: ConverterKind,
sequence: bool,
}
+30 -1
View File
@@ -119,9 +119,17 @@ pub(crate) fn expand_webidl_dictionary(
}
}
}
let bindings = inherited
let ordered_fields = inherited
.into_iter()
.chain(members.into_values())
.collect::<Vec<_>>();
let snapshot = if attrs.snapshot {
crate::snapshot::expand_snapshot(&struct_name, &generics, &ordered_fields, rename_all)?
} else {
quote! {}
};
let bindings = ordered_fields
.into_iter()
.map(|field| expand_dictionary_field(field, &prefix, rename_all, &scope_lifetime))
.collect::<Result<Vec<_>, _>>()?;
let idents = fields
@@ -130,6 +138,8 @@ pub(crate) fn expand_webidl_dictionary(
.collect::<Result<Vec<_>, _>>()?;
Ok(quote! {
#snapshot
impl #impl_generics ::moli_webidl::WebIdlDictionary<#scope_lifetime> for #struct_name #ty_generics #where_clause {
fn parse_dictionary(
scope: &mut v8::PinScope<#scope_lifetime, '_>,
@@ -668,6 +678,25 @@ mod tests {
use super::{expand_webidl_args, expand_webidl_dictionary, impl_parts_for_scope};
use syn::parse_quote;
#[test]
fn snapshots_reject_unsupported_ownership_and_member_policies() {
for input in [
parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(inherit)] base: Base } },
parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(nullable)] value: Option<String> } },
parse_quote! { #[webidl(snapshot)] struct Options { #[webidl(with = parse)] value: Custom } },
parse_quote! { #[webidl(snapshot)] struct Options { value: BufferSource } },
parse_quote! { #[webidl(snapshot)] struct Options { value: WebIdlCallbackFunction } },
parse_quote! { #[webidl(snapshot)] struct Options { value: WebIdlCallbackInterface } },
] {
assert!(
expand_webidl_dictionary(input)
.unwrap_err()
.to_string()
.starts_with("dictionary snapshots")
);
}
}
#[test]
fn dictionary_member_names_must_be_unique_after_renaming() {
for input in [
+5
View File
@@ -1,6 +1,7 @@
mod attrs;
mod converter;
mod expand;
mod snapshot;
use proc_macro::TokenStream;
use syn::{DeriveInput, parse_macro_input};
@@ -48,6 +49,10 @@ pub fn derive_webidl_args(input: TokenStream) -> TokenStream {
/// Nested members support `dictionary`: null parses an empty dictionary while
/// `Option<T>` skips missing/undefined and `nullable` also skips null.
/// Iterable members support `sequence` with `Vec<T>` or `Option<Vec<T>>`.
/// `#[webidl(snapshot)]` additionally derives `WebIdlDictionarySnapshot` for
/// copying already-converted, native-owned dictionary objects. Raw converters
/// must supply a `WebIdlSnapshotValue` policy. This opt-in pilot does not support
/// inheritance, nullable members, custom member parsers, buffers, or callbacks.
#[proc_macro_derive(WebIdlDictionary, attributes(webidl))]
pub fn derive_webidl_dictionary(input: TokenStream) -> TokenStream {
match expand::expand_webidl_dictionary(parse_macro_input!(input as DeriveInput)) {
+87
View File
@@ -0,0 +1,87 @@
use quote::quote;
use syn::spanned::Spanned;
use syn::{Error, Field, Generics, Ident};
use crate::attrs::{RenameRule, field_member_name, parse_field_attrs};
use crate::converter::{
ConverterKind, converter_kind, inner_type_for_field, is_option_type, vec_inner_type,
};
pub(crate) fn expand_snapshot(
name: &Ident,
generics: &Generics,
fields: &[&Field],
rename_all: RenameRule,
) -> Result<proc_macro2::TokenStream, Error> {
let mut members = Vec::with_capacity(fields.len());
for field in fields {
let attrs = parse_field_attrs(field)?;
if attrs.inherit || attrs.nullable || attrs.with.is_some() {
return Err(Error::new(
field.span(),
"dictionary snapshots do not yet support inherit, nullable, or with",
));
}
let converter = converter_kind(field, &attrs)?;
let mut ty = inner_type_for_field(field);
if attrs.sequence {
ty = vec_inner_type(ty).expect("validated sequence field");
}
let kind = match converter.kind {
ConverterKind::Dictionary => quote! {
::moli_webidl::SnapshotKind::Dictionary(<#ty as ::moli_webidl::WebIdlDictionarySnapshot>::SNAPSHOT_MEMBERS)
},
ConverterKind::Raw => quote! {
<#ty as ::moli_webidl::WebIdlSnapshotValue>::SNAPSHOT_KIND
},
ConverterKind::BufferSource
| ConverterKind::CallbackFunction
| ConverterKind::CallbackInterface => {
return Err(Error::new(
field.span(),
"dictionary snapshots require an explicit raw policy for buffers and callbacks",
));
}
ConverterKind::DomString
| ConverterKind::DomString16
| ConverterKind::UsvString
| ConverterKind::ByteString
| ConverterKind::Octet
| ConverterKind::Long
| ConverterKind::LongLong
| ConverterKind::EnforceRangeLong
| ConverterKind::Short
| ConverterKind::UnsignedShort
| ConverterKind::EnforceRangeUnsignedShort
| ConverterKind::ClampedUnsignedShort
| ConverterKind::UnsignedLong
| ConverterKind::EnforceRangeUnsignedLong
| ConverterKind::EnforceRangeLongLong
| ConverterKind::UnsignedLongLong
| ConverterKind::EnforceRangeUnsignedLongLong
| ConverterKind::Float
| ConverterKind::UnrestrictedFloat
| ConverterKind::Double
| ConverterKind::UnrestrictedDouble
| ConverterKind::Boolean
| ConverterKind::Enum
| ConverterKind::Interface => quote!(::moli_webidl::SnapshotKind::Identity),
};
let kind = if attrs.sequence {
quote!(::moli_webidl::SnapshotKind::Sequence(&#kind))
} else {
kind
};
let member_name = field_member_name(field, &attrs, rename_all)?;
let optional = is_option_type(&field.ty);
members.push(quote! {
::moli_webidl::SnapshotMember { name: #member_name, kind: #kind, optional: #optional }
});
}
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
Ok(quote! {
impl #impl_generics ::moli_webidl::WebIdlDictionarySnapshot for #name #ty_generics #where_clause {
const SNAPSHOT_MEMBERS: &'static [::moli_webidl::SnapshotMember] = &[#(#members),*];
}
})
}
+8 -2
View File
@@ -27,8 +27,10 @@
//! `#[derive(WebIdlArgs)]` generates positional argument parsing for a V8
//! native binding's `FunctionCallbackArguments`. `#[derive(WebIdlDictionary)]`
//! generates named property parsing for dictionary objects. Both derives use
//! the converter wrappers in `types` and only produce Rust values; object shape
//! declaration and Web API wrapper allocation remain outside this crate.
//! the converter wrappers in `types` and produce Rust values. The opt-in
//! `#[webidl(snapshot)]` also generates copy metadata for already-bound native
//! dictionary snapshots; object shape declaration and Web API wrapper allocation
//! remain outside this crate.
//! Dictionary members are converted in lexicographical order of their final
//! JavaScript names. `#[webidl(inherit)]` on one dictionary field converts the
//! ancestor's members from the same object first; it does not read a nested
@@ -66,6 +68,7 @@ mod convert;
pub use buffer_source::{AllowSharedBufferSource, NonSharedBufferSource};
mod error;
mod helpers;
mod snapshot;
mod traits;
mod types;
@@ -91,6 +94,9 @@ pub use moli_webidl_callback::{
WebIdlCallbackInterface,
};
pub use moli_webidl_derive::{WebIdlArgs, WebIdlDictionary, WebIdlEnum};
pub use snapshot::{
SnapshotKind, SnapshotMember, SnapshotPrototype, WebIdlDictionarySnapshot, WebIdlSnapshotValue,
};
pub use traits::{ParseOutcome, WebIdlArguments, WebIdlConverter, WebIdlDictionary, WebIdlEnum};
pub use types::{
Boolean, BufferSource, ByteString, ClampedUnsignedShort, Dictionary, DomString, DomString16,
+129
View File
@@ -0,0 +1,129 @@
//! Snapshot copies of already-converted, native-owned dictionaries.
//!
//! This pilot deliberately keeps ownership in V8 private slots. Dictionary and
//! sequence containers are copied, while interface leaves keep their identity
//! and remain traced by V8. It does not turn scoped handles into Rust-owned
//! state: doing that needs an explicit rooting/teardown policy and cannot be
//! inferred from a raw converter or an interface's WebIDL name.
//!
//! RTCConfiguration therefore stays attached to its receiver, including when a
//! method from another Realm is borrowed. A CommonRuntimeState map is useful
//! for native payloads such as codecs, but a map holding Global<Object> leaves
//! would root certificate -> connection cycles. Moving these dictionaries there
//! would also require receiver-owner lookup, weak/finalizer cleanup, and isolate
//! teardown ordering. Native certificate handles need a real native payload and
//! wrapper identity policy first; this pilot adds neither a store nor globals.
//!
//! `to_js_snapshot` copies canonical binding output, not arbitrary JS input or
//! Rust values. Binding remains the declaration layer's job. An `into_native`
//! derive is deferred until field ownership policies (including custom raw
//! converters) can be expressed without guessing or generating a parallel Rust
//! type/lifetime graph. Only RTCConfiguration and its nested ICE server opt in.
use moli_v8_util::{new_null_prototype_object, v8str};
/// Prototype policy for every dictionary in a snapshot, including nested ones.
#[derive(Clone, Copy)]
pub enum SnapshotPrototype {
/// Internal records must not consult author-modifiable Object.prototype.
Null,
/// Public snapshots use the current (callee) Realm's Object.prototype.
Ordinary,
}
/// Copy policy for the canonical JS representation of a converted member.
#[derive(Clone, Copy)]
pub enum SnapshotKind {
/// Immutable scalar values or interface objects whose identity is retained.
Identity,
Sequence(&'static SnapshotKind),
Dictionary(&'static [SnapshotMember]),
}
/// A member generated from the same metadata as dictionary argument conversion.
pub struct SnapshotMember {
pub name: &'static str,
pub kind: SnapshotKind,
pub optional: bool,
}
/// Explicit snapshot policy for custom raw converters.
///
/// Raw conversion can produce a scalar, an interface, or a canonical container;
/// its representation cannot safely be inferred by the dictionary derive.
pub trait WebIdlSnapshotValue {
const SNAPSHOT_KIND: SnapshotKind;
}
/// Opt-in dictionary copy metadata, generated by `#[webidl(snapshot)]`.
///
/// Sources must be fresh native binding output or private snapshots, with own
/// data properties and dense arrays. This is not WebIDL input conversion and
/// must never be applied directly to an author-supplied dictionary. It ignores
/// absent optional members and unknown properties, without consulting iterators
/// or prototypes. Missing non-optional members fail instead of silently losing
/// a field when a binding declaration is incomplete.
pub trait WebIdlDictionarySnapshot {
const SNAPSHOT_MEMBERS: &'static [SnapshotMember];
fn to_js_snapshot<'s>(
scope: &mut v8::PinScope<'s, '_>,
source: v8::Local<'s, v8::Object>,
prototype: SnapshotPrototype,
) -> Option<v8::Local<'s, v8::Object>> {
copy_dictionary(scope, source, Self::SNAPSHOT_MEMBERS, prototype)
}
}
fn copy_dictionary<'s>(
scope: &mut v8::PinScope<'s, '_>,
source: v8::Local<'s, v8::Object>,
members: &'static [SnapshotMember],
prototype: SnapshotPrototype,
) -> Option<v8::Local<'s, v8::Object>> {
let output = match prototype {
SnapshotPrototype::Null => new_null_prototype_object(scope),
SnapshotPrototype::Ordinary => v8::Object::new(scope),
};
for member in members {
let key = v8str(scope, member.name);
if !source.has_own_property(scope, key.into())? {
if member.optional {
continue;
}
return None;
}
let value = source.get(scope, key.into())?;
let value = member.kind.copy(scope, value, prototype)?;
if !output.create_data_property(scope, key.into(), value)? {
return None;
}
}
Some(output)
}
impl SnapshotKind {
fn copy<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
source: v8::Local<'s, v8::Value>,
prototype: SnapshotPrototype,
) -> Option<v8::Local<'s, v8::Value>> {
match self {
Self::Identity => Some(source),
Self::Sequence(item_kind) => {
let source = v8::Local::<v8::Array>::try_from(source).ok()?;
let mut items = Vec::with_capacity(source.length() as usize);
for index in 0..source.length() {
let item = source.get_index(scope, index)?;
items.push(item_kind.copy(scope, item, prototype)?);
}
Some(v8::Array::new_with_elements(scope, &items).into())
}
Self::Dictionary(members) => {
let source = v8::Local::<v8::Object>::try_from(source).ok()?;
copy_dictionary(scope, source, members, prototype).map(Into::into)
}
}
}
}
+188
View File
@@ -0,0 +1,188 @@
use moli_webapi_declare::WebApiObject;
use moli_webidl::{SnapshotPrototype, WebIdlDictionary, WebIdlDictionarySnapshot};
mod interfaces {
moli_webapi_declare::declare_web_api_interfaces! {
pub Certificate;
}
}
#[derive(WebIdlDictionary, WebApiObject)]
#[webidl(snapshot)]
#[webapi(plain, data_properties, enumerable)]
struct Server {
#[webidl(sequence, required)]
urls: Vec<String>,
username: Option<String>,
}
#[derive(WebIdlDictionary, WebApiObject)]
#[webidl(snapshot)]
#[webapi(plain, data_properties, enumerable)]
struct Options<'s> {
#[webidl(default = 7, name = "renamedNumber")]
#[webapi(data_property = "renamedNumber")]
number: u32,
#[webidl(sequence, converter = "dictionary", default = Vec::new())]
servers: Vec<Server>,
#[webidl(sequence, interface = interfaces::Certificate, default = Vec::new())]
certificates: Vec<v8::Local<'s, v8::Object>>,
// Adding a field needs no separate snapshot list or copy implementation.
#[webidl(default = "new member")]
added_later: String,
}
fn snapshot<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
let stored = v8::Local::<v8::Object>::try_from(args.data()).unwrap();
let snapshot = Options::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary).unwrap();
rv.set(snapshot.into());
}
fn eval<'s>(scope: &mut v8::PinScope<'s, '_>, source: &str) -> v8::Local<'s, v8::Value> {
let source = v8::String::new(scope, source).unwrap();
v8::Script::compile(scope, source, None)
.unwrap()
.run(scope)
.unwrap()
}
#[test]
fn snapshots_follow_dictionary_metadata_and_copy_only_containers() {
moli_v8_test_util::ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let global = context.global(scope);
let certificate = v8::Object::new(scope);
interfaces::Certificate::DESCRIPTOR
.initialize(scope, certificate)
.unwrap();
let key = v8::String::new(scope, "certificate").unwrap();
assert_eq!(
global.create_data_property(scope, key.into(), certificate.into()),
Some(true)
);
let source = eval(
scope,
"({servers:[{urls:['stun:host.example']}], certificates:[certificate], unknown:123})",
);
let parsed =
moli_webidl::parse_dictionary_object::<Options>(scope, source.try_into().unwrap()).unwrap();
let bound = parsed.bind(scope).unwrap();
let stored = Options::to_js_snapshot(scope, bound, SnapshotPrototype::Null).unwrap();
let key = v8::String::new(scope, "stored").unwrap();
assert_eq!(
global.create_data_property(scope, key.into(), stored.into()),
Some(true)
);
let snapshot = v8::Function::builder(snapshot)
.data(stored.into())
.build(scope)
.unwrap();
let key = v8::String::new(scope, "snapshot").unwrap();
assert_eq!(
global.create_data_property(scope, key.into(), snapshot.into()),
Some(true)
);
assert!(eval(scope, r#"(() => {
const assert = (ok, message) => { if (!ok) throw Error(message); };
assert(Object.getPrototypeOf(stored) === null, 'private root');
assert(Object.getPrototypeOf(stored.servers[0]) === null, 'private nested dictionary');
const first = snapshot();
assert(Object.keys(first).join() === 'addedLater,certificates,renamedNumber,servers', 'renamed and newly added members');
assert(first.addedLater === 'new member' && first.renamedNumber === 7, 'member defaults');
assert(Object.getPrototypeOf(first) === Object.prototype, 'public root');
assert(Object.getPrototypeOf(first.servers[0]) === Object.prototype, 'public nested dictionary');
assert(first.servers instanceof Array && first.servers[0].urls instanceof Array, 'public arrays');
assert(first.certificates !== stored.certificates && first.certificates[0] === certificate, 'interface identity');
first.servers[0].urls[0] = 'stun:changed.example';
first.servers[0].username = 'changed';
first.certificates.length = 0;
first.addedLater = 'changed';
Object.defineProperty(Object.prototype, 'username', {configurable:true, get(){throw Error('inherited member');}});
Object.defineProperty(Object.prototype, 'servers', {configurable:true, set(){throw Error('inherited setter');}});
const iterator = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator);
const index = Object.getOwnPropertyDescriptor(Array.prototype, '0');
Object.defineProperty(Array.prototype, Symbol.iterator, {configurable:true, value(){throw Error('iterator');}});
Object.defineProperty(Array.prototype, '0', {configurable:true, set(){throw Error('indexed setter');}});
try {
const next = snapshot();
assert(next.servers[0].urls[0] === 'stun:host.example', 'deep copy');
assert(!Object.hasOwn(next.servers[0], 'username'), 'optional member stays absent');
assert(next.certificates.length === 1 && next.certificates[0] === certificate, 'interface array retained');
assert(next.addedLater === 'new member', 'scalar copy');
const descriptor = Object.getOwnPropertyDescriptor(next, 'servers');
assert(descriptor.writable && descriptor.enumerable && descriptor.configurable, 'data property descriptors');
} finally {
delete Object.prototype.username;
delete Object.prototype.servers;
Object.defineProperty(Array.prototype, Symbol.iterator, iterator);
if (index) Object.defineProperty(Array.prototype, '0', index); else delete Array.prototype[0];
}
return true;
})()"#).is_true());
// An incomplete binding is an invariant violation, not a partial snapshot.
let key = v8::String::new(scope, "addedLater").unwrap();
assert_eq!(stored.delete(scope, key.into()), Some(true));
assert!(Options::to_js_snapshot(scope, stored, SnapshotPrototype::Ordinary).is_none());
}
#[test]
fn private_snapshots_trace_interfaces_without_rooting_unreachable_cycles() {
moli_v8_test_util::ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let (owner, weak_owner, weak_certificate) = {
let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let certificate = v8::Object::new(scope);
interfaces::Certificate::DESCRIPTOR
.initialize(scope, certificate)
.unwrap();
let bound = Options {
number: 7,
servers: Vec::new(),
certificates: vec![certificate],
added_later: "new member".into(),
}
.bind(scope)
.unwrap();
let stored = Options::to_js_snapshot(scope, bound, SnapshotPrototype::Null).unwrap();
let owner = v8::Object::new(scope);
let slot = v8::Private::new(scope, None);
assert_eq!(owner.set_private(scope, slot, stored.into()), Some(true));
let key = v8::String::new(scope, "owner").unwrap();
assert_eq!(
certificate.create_data_property(scope, key.into(), owner.into()),
Some(true)
);
(
v8::Global::new(scope, owner),
v8::Weak::new(scope, owner),
v8::Weak::new(scope, certificate),
)
};
isolate.low_memory_notification();
assert!(
!weak_certificate.is_empty(),
"the live owner traces its certificate"
);
drop(owner);
isolate.low_memory_notification();
assert!(
weak_owner.is_empty(),
"the owner/certificate cycle is collectible"
);
assert!(
weak_certificate.is_empty(),
"no native global roots the certificate"
);
}