fix(indexeddb): expose the worker factory as a readonly prototype accessor

Install WorkerGlobalScope.indexedDB through the native WebIDL receiver
binding and keep SameObject identity in the existing lazy realm cache.
Author properties no longer replace or discard the native factory.

Cover Dedicated, Shared, Service, and opaque-origin worker bootstraps,
illegal receivers, readonly assignment, author shadows, and lazy storage.
Record the newly passing dedicated-worker IndexedDB IDL WPT.
This commit is contained in:
ldm0
2026-09-23 00:14:08 +08:00
parent faa325c70c
commit dca6d4f7a3
8 changed files with 228 additions and 23 deletions
@@ -307,6 +307,7 @@ IndexedDB/idbtransaction_objectStoreNames.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbtransaction_objectStoreNames.any.js?moli-wpt-any=window
IndexedDB/idbversionchangeevent.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idbversionchangeevent.any.js?moli-wpt-any=window
IndexedDB/idlharness.any.js?moli-wpt-any=dedicatedworker
IndexedDB/idlharness.any.js?moli-wpt-any=window
IndexedDB/index_sort_order.any.js?moli-wpt-any=dedicatedworker
IndexedDB/index_sort_order.any.js?moli-wpt-any=window
@@ -35,7 +35,7 @@ use super::{
idb_object_store_transaction_getter, idb_transaction_abort_callback,
idb_transaction_commit_callback, idb_transaction_object_store_callback,
idb_transaction_object_store_names_getter, indexed_db_runtime_factory,
install_dom_string_list_template_bindings, v8str,
install_dom_string_list_template_bindings,
};
use anyhow::{Result, anyhow};
@@ -1,5 +1,18 @@
use super::*;
use crate::{util::context_host_ptr_from_window_object, webidl};
use crate::{util::context_host_ptr_from_window_object, web_api_interfaces, webidl};
use moli_webapi_declare::WebApiObject;
#[derive(Default, WebApiObject)]
#[webapi(
fragment,
prototype = "WorkerGlobalScope",
enumerable,
receiver = web_api_interfaces::WorkerGlobalScope::is_instance
)]
struct WorkerIndexedDbPrototypeDeclaration {
#[webapi(accessor_property = "indexedDB", getter = worker_indexed_db_getter)]
indexed_db: (),
}
pub(in crate::context_bootstrap) fn ensure_indexed_db_runtime_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
@@ -9,27 +22,25 @@ pub(in crate::context_bootstrap) fn ensure_indexed_db_runtime_state<'s>(
pub(crate) fn install_worker_indexed_db_runtime_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
global: v8::Local<'s, v8::Object>,
) -> Result<()> {
global
.set_lazy_data_property_with_configuration(
scope,
v8str(scope, "indexedDB").into(),
v8::LazyDataPropertyConfiguration::new(worker_indexed_db_lazy_getter)
.property_attribute(v8::PropertyAttribute::DONT_ENUM)
.getter_side_effect_type(v8::SideEffectType::HasNoSideEffect),
)
.unwrap_or(false)
.then_some(())
.ok_or_else(|| anyhow!("failed to install lazy worker IndexedDB factory"))
let prototype = crate::util::global_constructor_prototype(scope, "WorkerGlobalScope")
.ok_or_else(|| anyhow!("WorkerGlobalScope prototype missing during IndexedDB bootstrap"))?;
WorkerIndexedDbPrototypeDeclaration::default()
.initialize(scope, prototype)
.map_err(|error| anyhow!("failed to install WorkerGlobalScope indexedDB getter: {error}"))
}
fn worker_indexed_db_lazy_getter<'s>(
fn worker_indexed_db_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
_name: v8::Local<'s, v8::Name>,
_args: v8::PropertyCallbackArguments<'s>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(context) = args.this().get_creation_context(scope) else {
return;
};
let scope = &mut v8::ContextScope::new(scope, context);
// Laziness and SameObject identity belong to the realm's private cache,
// independently of author properties shadowing the prototype getter.
match ensure_indexed_db_runtime_state(scope) {
Some(factory) => rv.set(factory.into()),
None => rv.set(v8::undefined(scope).into()),
@@ -3194,7 +3194,7 @@ pub(super) fn install_worker_global_scope<'s>(
secure_context,
&identity,
)?;
crate::context_bootstrap::install_worker_indexed_db_runtime_state(scope, global)?;
crate::context_bootstrap::install_worker_indexed_db_runtime_state(scope)?;
crate::context_bootstrap::install_worker_base64_runtime_state(scope, global)?;
install_simple_event_target_methods(scope, global, WORKER_GLOBAL_LISTENERS_SLOT, false);
install_simple_event_target_ordered_handlers(scope, global);
@@ -0,0 +1,62 @@
use super::super::WorkerGlobalKind;
use super::*;
#[tokio::test]
async fn indexed_db_global_attribute_preserves_receivers_and_identity_across_worker_kinds() {
ensure_v8();
let storage_key = moli_storage_key::MoliStorageKey::new(
"https://indexeddb-attribute.test".to_owned(),
"https://indexeddb-attribute.test".to_owned(),
None,
moli_storage_key::StoragePartitionRelation::FirstParty,
);
let cases = [
(
WorkerGlobalKind::Dedicated {
name: String::new(),
},
"https://indexeddb-attribute.test/worker.js",
),
(
WorkerGlobalKind::Dedicated {
name: String::new(),
},
"data:text/javascript,",
),
(
WorkerGlobalKind::Shared {
name: "indexedDB".to_owned(),
storage_key,
},
"https://indexeddb-attribute.test/shared.js",
),
(
WorkerGlobalKind::Service {
registration_id: ServiceWorkerRegistrationId::from_u64_for_test(1),
version_id: ServiceWorkerVersionId::from_u64_for_test(1),
scope_url: url::Url::parse("https://indexeddb-attribute.test/").unwrap(),
},
"https://indexeddb-attribute.test/sw.js",
),
];
let fixture = include_str!("../../../../tests/fixtures/worker-indexeddb-attribute.js");
for (kind, script_url) in cases {
let (bootstrap_tx, mut bootstrap_rx) = tokio::sync::mpsc::unbounded_channel();
let source = format!(
"{fixture}\nconst result = workerIndexedDBAttributeProbe(); if (result.state !== 'pass' || result.checks.length !== 53) throw new Error(JSON.stringify(result));"
);
let handle = spawn_test_worker_with_options(
WorkerSpawnOptions::new(source, script_url.to_owned())
.with_global_kind(kind)
.with_bootstrap_completion_sender(bootstrap_tx),
);
let bootstrap = timeout(TIMEOUT, bootstrap_rx.recv())
.await
.expect("IndexedDB attribute probe should finish")
.expect("IndexedDB attribute probe should report completion");
handle.terminate_and_join();
bootstrap.result.unwrap_or_else(|error| {
panic!("IndexedDB attribute probe failed for {script_url}: {error:?}")
});
}
}
@@ -176,17 +176,31 @@ async fn worker_storage_surfaces_materialize_in_independent_stages() {
return;
}
if (event.data === "indexedDBDescriptor") {
const descriptor = Object.getOwnPropertyDescriptor(WorkerGlobalScope.prototype, "indexedDB");
let illegalReceiver;
try { descriptor.get.call({}); } catch (error) { illegalReceiver = error.name; }
postMessage({
phase: "indexedDBDescriptor",
own: Object.hasOwn(self, "indexedDB"),
accessor: typeof descriptor.get === "function" && descriptor.set === undefined,
illegalReceiver
});
return;
}
if (event.data === "indexedDB") {
const factory = indexedDB;
const descriptor =
Object.getOwnPropertyDescriptor(self, "indexedDB");
Object.getOwnPropertyDescriptor(WorkerGlobalScope.prototype, "indexedDB");
postMessage({
phase: "indexedDB",
same: factory === indexedDB,
instance: factory instanceof IDBFactory,
dataDescriptor:
descriptor.value === factory &&
typeof descriptor.get === "undefined"
prototypeAccessor:
!Object.hasOwn(self, "indexedDB") &&
descriptor.get.call(self) === factory &&
descriptor.set === undefined
});
return;
}
@@ -379,10 +393,22 @@ async fn worker_storage_surfaces_materialize_in_independent_stages() {
assert_eq!(diagnostics.storage_constructor_materializations, 0);
assert!(!diagnostics.opfs_owner_state_materialized);
handle.post_message(serialize_test_string("indexedDBDescriptor"));
assert_eq!(
recv_post_json(&mut handle).await,
r#"{"phase":"indexedDBDescriptor","own":false,"accessor":true,"illegalReceiver":"TypeError"}"#
);
let before_factory = lazy_diagnostics(&handle).await;
assert_eq!(
before_factory.materialized_interfaces, diagnostics.materialized_interfaces,
"inspecting the accessor and rejecting a receiver must not materialize IDBFactory"
);
assert_eq!(materialization_count(&before_factory, "IDBFactory"), 0);
handle.post_message(serialize_test_string("indexedDB"));
assert_eq!(
recv_post_json(&mut handle).await,
r#"{"phase":"indexedDB","same":true,"instance":true,"dataDescriptor":true}"#
r#"{"phase":"indexedDB","same":true,"instance":true,"prototypeAccessor":true}"#
);
let diagnostics = lazy_diagnostics(&handle).await;
assert_eq!(materialization_count(&diagnostics, "IDBFactory"), 1);
@@ -1528,6 +1528,7 @@ mod abort_signal_events;
mod abort_signal_statics;
mod cors_redirects;
mod imported_scripts;
mod indexed_db;
mod lazy_storage;
mod lifecycle;
mod modules;
@@ -0,0 +1,104 @@
function workerIndexedDBAttributeProbe() {
const checks = [];
const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)});
const typeError = callback => {
try { callback(); } catch (error) { return Object.getPrototypeOf(error) === TypeError.prototype; }
return false;
};
const prototype = WorkerGlobalScope.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'indexedDB');
const getter = descriptor?.get;
const native = receiver => typeof getter === 'function' ? getter.call(receiver) : undefined;
check('readonly prototype accessor', typeof getter === 'function' && descriptor.set === undefined);
check('enumerable configurable prototype accessor', descriptor?.enumerable === true && descriptor.configurable === true);
check('getter metadata', getter?.name === 'get indexedDB' && getter.length === 0);
check('getter cannot construct', typeof getter === 'function' && typeError(() => new getter()));
check('no initial own attribute', !Object.hasOwn(self, 'indexedDB'));
check('invalid receiver before first factory access', typeError(() => native({})));
const factory = self.indexedDB;
const originalOwn = Object.getOwnPropertyDescriptor(self, 'indexedDB');
const restoreOwn = () => {
delete self.indexedDB;
if (originalOwn) Object.defineProperty(self, 'indexedDB', originalOwn);
};
check('SameObject', self.indexedDB === factory);
check('factory brand', factory instanceof IDBFactory);
check('factory operation', factory.cmp(1, 2) === -1);
check('read does not materialize own attribute', !Object.hasOwn(self, 'indexedDB'));
for (const [label, receiver] of [['self', self], ['undefined', undefined], ['null', null]]) {
check('getter receiver ' + label, native(receiver) === factory);
}
const revoked = Proxy.revocable(self, {}); revoked.revoke();
let traps = 0;
const proxy = new Proxy(self, {
get() { ++traps; throw new Error('get trap'); },
getPrototypeOf() { ++traps; throw new Error('prototype trap'); }
});
const invalid = [{}, prototype, Object.create(prototype), Object.create(self), 0, 'text',
true, Symbol('receiver'), 1n, factory, IDBFactory.prototype, new Proxy(self, {}), revoked.proxy, proxy];
for (let i = 0; i < invalid.length; ++i) {
check('reject invalid receiver ' + i, typeError(() => native(invalid[i])));
}
check('receiver check does not invoke Proxy traps', traps === 0, traps);
check('inherited attribute validates receiver', typeError(() => Object.create(self).indexedDB));
check('author proxy property read validates receiver', typeError(() => new Proxy(self, {}).indexedDB));
let conversions = 0;
const replacement = {[Symbol.toPrimitive]() { ++conversions; throw new Error('conversion'); }};
check('getter ignores extra arguments', typeof getter === 'function' && getter.call(self, replacement) === factory);
check('Reflect.set rejects assignment', Reflect.set(self, 'indexedDB', replacement) === false);
check('assignment preserves factory', self.indexedDB === factory);
restoreOwn();
check('strict assignment throws TypeError', typeError(() => { 'use strict'; self.indexedDB = replacement; }));
check('strict assignment preserves factory', self.indexedDB === factory);
restoreOwn();
check('assignment does not convert value', conversions === 0, conversions);
check('deleting absent own attribute succeeds', delete self.indexedDB);
check('deleting absent own attribute preserves factory', self.indexedDB === factory);
restoreOwn();
Object.defineProperty(self, 'indexedDB', {value: 42, writable: true, enumerable: true, configurable: true});
check('author data shadow is visible', self.indexedDB === 42);
check('native getter ignores data shadow', native(self) === factory);
check('author data shadow can be deleted', delete self.indexedDB);
check('deleting data shadow restores same factory', self.indexedDB === factory);
restoreOwn();
let reads = 0;
const marker = new Error('author getter');
Object.defineProperty(self, 'indexedDB', {configurable: true, get() { ++reads; throw marker; }});
check('native getter ignores accessor shadow', native(self) === factory && reads === 0, reads);
let error;
try { self.indexedDB; } catch (caught) { error = caught; }
check('author accessor remains observable', error === marker && reads === 1, reads);
delete self.indexedDB;
check('deleting accessor shadow restores same factory', self.indexedDB === factory);
restoreOwn();
// Changing the exposed prototype property must not recreate the factory.
Object.defineProperty(prototype, 'indexedDB', {value: 'prototype shadow', configurable: true});
check('prototype property is configurable', self.indexedDB === 'prototype shadow');
check('saved getter survives prototype replacement', native(self) === factory);
delete prototype.indexedDB;
check('saved getter survives prototype deletion', native(self) === factory);
if (descriptor) Object.defineProperty(prototype, 'indexedDB', descriptor);
check('restoring prototype restores same factory', self.indexedDB === factory);
let constructorReads = 0;
const constructors = ['WorkerGlobalScope', 'IDBFactory'];
const originalConstructors = constructors.map(name => Object.getOwnPropertyDescriptor(self, name));
try {
for (const name of constructors) {
Object.defineProperty(self, name, {configurable: true, get() { ++constructorReads; throw marker; }});
}
check('getter ignores replaced constructors', native(self) === factory && constructorReads === 0, constructorReads);
} finally {
constructors.forEach((name, index) => Object.defineProperty(self, name, originalConstructors[index]));
}
check('factory remains usable', factory.cmp('a', 'b') === -1);
Object.defineProperty(self, 'indexedDB', {value: 'locked', writable: false, configurable: false});
check('locked author shadow is visible', self.indexedDB === 'locked');
check('native getter ignores locked shadow', native(self) === factory);
return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks};
}