From 483687c23c13d0d52264f33f5bb74f6b77a17397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 23:33:34 +0200 Subject: [PATCH 1/2] Add durable IndexedDB storage for Code OSS --- docs/architecture/indexeddb-persistence.md | 97 ++++ .../CMakeLists.txt | 17 +- .../native/webscene_indexeddb_compatibility.h | 515 ++++++++++++++++++ .../native/webscene_indexeddb_storage.cpp | 475 ++++++++++++++++ .../native/webscene_indexeddb_storage.h | 97 ++++ .../native/webscene_native_engine.cpp | 28 +- .../native/webscene_native_engine.h | 12 + .../webscene_native_engine_lifecycle.inc | 6 + .../native/webscene_native_engine_worker.inc | 3 +- .../native/webscene_v8_runtime.cpp | 21 +- .../native/webscene_v8_runtime.h | 5 +- .../webscene_v8_runtime_cache_and_frames.inc | 1 + .../native/webscene_v8_runtime_indexeddb.inc | 232 ++++++++ .../native/webscene_v8_runtime_lifecycle.inc | 23 +- .../native/webscene_v8_runtime_state.inc | 10 + .../webscene_v8_runtime_state_types.inc | 11 + .../native/webscene_v8_runtime_tasks.inc | 3 + .../tests/indexeddb_storage_tests.cpp | 298 ++++++++++ .../native_v8_runtime_indexeddb_tests.inc | 294 ++++++++++ .../tests/native_v8_runtime_tests.cpp | 6 + .../NativeWebSceneLoadOptions.cs | 15 + .../NativeSceneInteropTypes.cs | 5 + .../NativeSceneRuntime.cs | 22 +- .../NativeWebSceneView.cs | 9 +- .../UnoNativeSceneSurface.cs | 9 +- .../WebSceneComponentHost.cs | 31 ++ src/WebScene.Sdk.Uno/WebSceneComponentHost.cs | 43 ++ src/WebScene.Sdk/CompatibilityChecker.cs | 3 +- src/WebScene.Sdk/ComponentManifest.cs | 2 + tests/WebPlatformSubset/README.md | 5 + .../contracts/indexeddb-code-oss-storage.html | 137 +++++ .../runner/EngineAdapters.cs | 10 +- .../WebPlatformSubset/runner/ProfileModels.cs | 3 + tests/WebPlatformSubset/runner/Program.cs | 18 + .../webscene-indexeddb-profile.json | 64 +++ .../CompatibilityCheckerTests.cs | 15 +- tooling/webscene/compatibility.mjs | 4 +- tooling/webscene/tests/compatibility.test.mjs | 8 + 38 files changed, 2536 insertions(+), 21 deletions(-) create mode 100644 docs/architecture/indexeddb-persistence.md create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_compatibility.h create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.cpp create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.h create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_indexeddb.inc create mode 100644 experiments/WebScene.NativeEngine.Probe/tests/indexeddb_storage_tests.cpp create mode 100644 experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc create mode 100644 tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html create mode 100644 tests/WebPlatformSubset/webscene-indexeddb-profile.json diff --git a/docs/architecture/indexeddb-persistence.md b/docs/architecture/indexeddb-persistence.md new file mode 100644 index 000000000..9e99aea7b --- /dev/null +++ b/docs/architecture/indexeddb-persistence.md @@ -0,0 +1,97 @@ +# Persistent IndexedDB compatibility + +WebScene's native V8 runtime can expose a durable IndexedDB subset for packaged +applications such as VS Code OSS. The host opts in with two values: + +- `PersistentStorageDirectory`: a private directory owned by the host; +- `PersistentStoragePartitionKey`: a stable application/profile identifier. + +`PersistentStorageQuotaBytes` sets the whole-partition quota. Zero selects the +native default of 256 MiB. IndexedDB remains absent when either required value is +empty, so an application never receives an in-memory API presented as durable. +The component manifest must also declare `storage.indexeddb` for SDK preflight. + +The partition key separates applications and profiles. The runtime adds the +document origin and database name below it. Loopback origins use a stable +`scheme//loopback` identity so a trusted host can restart on a different ephemeral +port without losing `vscode-web-state-db-global`, `vscode-web-state-db-global-shared`, +or `vscode-web-state-db-empty-window`. Non-loopback origins retain their full origin. + +## Commit and recovery model + +Each database is one revisioned snapshot produced by V8 structured clone. Disk I/O +runs on a dedicated storage thread; the V8 owner thread only serializes/deserializes +and settles promises. A commit: + +1. acquires a per-database interprocess directory lock; +2. reloads and validates the current revision; +3. rejects a stale expected revision; +4. checks the partition quota; +5. writes a temporary file with schema, identity, length, and content hash; +6. flushes the file and atomically replaces the prior revision. + +Readers reject truncated, trailing, identity-mismatched, or hash-mismatched files +with `DataError`. The previous snapshot remains intact if a transaction aborts, a +quota check fails, or a stale writer loses a race. Read/write transactions replay +their mutation list against the latest same-version snapshot after a revision +conflict, up to four attempts. Version upgrades do not replay. + +Hosts should remove a partition directory only while its engines are stopped. +Applications can remove an individual database with `indexedDB.deleteDatabase()`. + +## Supported application surface + +The current slice implements the operations used by VS Code OSS browser storage: + +- `indexedDB.open`, `deleteDatabase`, `cmp`, and `databases` for databases seen by + the current realm; +- upgrade, blocked, and versionchange lifecycles; +- readonly, readwrite, and versionchange transactions with commit and abort; +- out-of-line string, finite number, Date, binary, and array keys; +- object store `get`, `put`, `add`, `delete`, `clear`, `count`, `getAll`, + `getAllKeys`, and forward cursors; +- structured objects, arrays, maps, sets, dates, array buffers, and typed arrays. + +Indexes, `IDBKeyRange`, key paths, key generators, cursor update/delete and reverse +cursors are not yet implemented. Object stores requesting `keyPath` or +`autoIncrement`, and all index operations, fail with `NotSupportedError`. Blob/File +prototype restoration is not yet guaranteed across a durable round trip. This is a +bounded compatibility implementation and does not claim full IndexedDB WPT +conformance. + +## Gates + +`webscene_indexeddb_storage_tests` covers revision isolation, profile/origin +partitioning, rollback preservation, quota, corruption detection, asynchronous I/O, +abandoned temporary-write and stale-lock recovery, real cross-process stale-writer +rejection, and 100 durable 4 KiB commits under ten seconds. +`webscene_native_indexeddb_contract` covers the V8 API, Code OSS ItemTable +shape, upgrade/versionchange, rollback, cursors, restart across loopback port changes, +quota errors, and corruption errors. +The same regression deletes the rejected corrupt database and verifies that a +fresh version-one database can be created in its place. + +The manifest names the native lifecycle regressions as evidence for candidate cases +that a single WPT document cannot drive: engine restart, direct file corruption, +process races, and interruption before atomic replacement. The runnable document +itself covers open/upgrade/versionchange, commit/rollback, cursors, request error +cancellation, connection reopen, quota, and competing connections. + +The candidate manifest `tests/WebPlatformSubset/webscene-indexeddb-profile.json` +adds a project-owned WPT-style Code OSS transaction contract and records the focused +upstream areas that still require broader algorithms. Run it with a fresh directory: + +```bash +dotnet run --project tests/WebPlatformSubset/runner -c Release -- \ + --manifest tests/WebPlatformSubset/webscene-indexeddb-profile.json \ + --selection candidate \ + --native-library /absolute/path/to/libwebscene_native_engine.dylib \ + --native-storage-directory /absolute/path/to/wpt-storage \ + --native-storage-partition webscene-indexeddb-wpt \ + --native-storage-quota-bytes 4194304 \ + --output TestResults/WebPlatformSubset/indexeddb +``` + +Storage operations do not mutate DOM, style, layout, or retained-scene state. The +ordinary required visual profile remains the rendering regression gate when this +feature is promoted on each release RID. diff --git a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt index 0cd261b34..7cce66125 100644 --- a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt +++ b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt @@ -5,6 +5,15 @@ include(CTest) include(FetchContent) if(BUILD_TESTING) + add_executable(webscene_indexeddb_storage_tests + tests/indexeddb_storage_tests.cpp + native/webscene_indexeddb_storage.cpp) + target_compile_features(webscene_indexeddb_storage_tests PRIVATE cxx_std_20) + target_include_directories(webscene_indexeddb_storage_tests PRIVATE native) + add_test(NAME webscene_indexeddb_storage_tests + COMMAND webscene_indexeddb_storage_tests) + set_tests_properties(webscene_indexeddb_storage_tests PROPERTIES + LABELS "storage;durability;performance" TIMEOUT 20) add_executable(webscene_graphics_scene_abi_layout_tests tests/graphics_scene_abi_layout_tests.c) target_compile_features(webscene_graphics_scene_abi_layout_tests PRIVATE c_std_11) target_include_directories(webscene_graphics_scene_abi_layout_tests PRIVATE native) @@ -14,6 +23,7 @@ if(BUILD_TESTING) target_include_directories(webscene_graphics_canvas_backing_tests PRIVATE native) add_test(NAME webscene_graphics_canvas_backing_tests COMMAND webscene_graphics_canvas_backing_tests) find_package(Threads REQUIRED) + target_link_libraries(webscene_indexeddb_storage_tests PRIVATE Threads::Threads) add_executable(webscene_graphics_image_lease_tests tests/graphics_image_lease_tests.cpp) target_compile_features(webscene_graphics_image_lease_tests PRIVATE cxx_std_20) target_include_directories(webscene_graphics_image_lease_tests PRIVATE native) @@ -79,7 +89,8 @@ else() endif() add_library(webscene_native_engine ${webscene_runtime_kind} native/webscene_native_engine.cpp - native/webscene_secure_random.cpp) + native/webscene_secure_random.cpp + native/webscene_indexeddb_storage.cpp) if(TARGET webscene_core) target_link_libraries(webscene_native_engine PRIVATE webscene_core) else() @@ -783,6 +794,7 @@ if(WEBSCENE_NATIVE_ENGINE_ENABLE_V8) COMMAND webscene_native_engine_tests) add_test(NAME webscene_native_recursive_selector_cache COMMAND webscene_native_engine_tests) + add_test(NAME webscene_native_indexeddb_contract COMMAND webscene_native_engine_tests) set_tests_properties(webscene_native_idle_v8_platform PROPERTIES ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=idle-v8-platform") set_tests_properties(webscene_native_dom_punctuation_keyboard PROPERTIES @@ -801,6 +813,9 @@ if(WEBSCENE_NATIVE_ENGINE_ENABLE_V8) ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=recursive-selector-cache" TIMEOUT 30 LABELS "native;runtime;performance") + set_tests_properties(webscene_native_indexeddb_contract PROPERTIES + ENVIRONMENT "WEBSCENE_NATIVE_ENGINE_TEST_FILTER=indexeddb" + LABELS "storage;indexeddb;durability" TIMEOUT 60) set_tests_properties(webscene_native_engine_tests PROPERTIES ENVIRONMENT "WEBSCENE_V8_DETAILED_MEMORY_METRICS=1;WEBSCENE_INTEROP_STRESS=1") diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_compatibility.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_compatibility.h new file mode 100644 index 000000000..e7d0ec344 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_compatibility.h @@ -0,0 +1,515 @@ +#pragma once + +#include + +namespace webscene_native { + +inline constexpr std::string_view indexeddb_compatibility_source = R"JS( +(() => { + 'use strict'; + const nativeStorage = globalThis.__webSceneIndexedDBStorage; + if (typeof nativeStorage !== 'function') return; + const openConnections = new Map(); + const loadedDatabases = new Map(); + const clone = value => structuredClone(value); + const failure = (message, name) => new DOMException(message, name); + const event = (type, fields = {}) => Object.assign({ + type, bubbles: false, cancelable: false, defaultPrevented: false, + preventDefault() { if (this.cancelable) this.defaultPrevented = true; } + }, fields); + + const names = values => { + const result = Array.from(values).sort(); + Object.defineProperties(result, { + contains: { value: name => result.includes(String(name)) }, + item: { value: index => result[index] ?? null } + }); + return result; + }; + const validKey = key => { + if (typeof key === 'string') return ['s', key]; + if (typeof key === 'number' && Number.isFinite(key)) { + return ['n', Object.is(key, -0) ? 0 : key]; + } + if (key instanceof Date && Number.isFinite(key.getTime())) { + return ['d', key.getTime()]; + } + if (Array.isArray(key) && key.length) { + return ['a', key.map(value => validKey(value))]; + } + if (key instanceof ArrayBuffer || ArrayBuffer.isView(key)) { + const bytes = key instanceof ArrayBuffer + ? new Uint8Array(key) : new Uint8Array(key.buffer, key.byteOffset, key.byteLength); + return ['b', Array.from(bytes)]; + } + throw failure('The supplied key is not a valid IndexedDB key', 'DataError'); + }; + const keyToken = key => JSON.stringify(validKey(key)); + const compareTokens = (left, right) => left < right ? -1 : left > right ? 1 : 0; + const emptyState = version => ({ version, stores: Object.create(null) }); + const normalizeState = value => { + if (!value || typeof value !== 'object' + || !Number.isSafeInteger(value.version) || value.version < 0 + || !value.stores || typeof value.stores !== 'object') { + throw failure('The IndexedDB data file is invalid', 'DataError'); + } + for (const name of Object.keys(value.stores)) { + const store = value.stores[name]; + if (!store || !Array.isArray(store.entries)) { + throw failure('The IndexedDB object store is invalid', 'DataError'); + } + } + return value; + }; + + class IDBRequest extends EventTarget { + constructor(source = null, transaction = null) { + super(); + this.source = source; + this.transaction = transaction; + this.readyState = 'pending'; + this.result = undefined; + this.error = null; + this.onsuccess = null; + this.onerror = null; + } + _success(value) { + if (this.readyState === 'done') return; + this.result = value; + this.readyState = 'done'; + this.dispatchEvent(event('success')); + } + _failure(error) { + if (this.readyState === 'done') return null; + this.error = error instanceof DOMException + ? error : failure(String(error?.message || error), 'UnknownError'); + this.readyState = 'done'; + const errorEvent = event('error', { cancelable: true }); + this.dispatchEvent(errorEvent); + return errorEvent; + } + } + + class IDBOpenDBRequest extends IDBRequest { + constructor() { + super(); + this.onblocked = null; + this.onupgradeneeded = null; + this.transaction = null; + } + } + + class IDBCursor { + constructor(request, entries, index) { + this._request = request; + this._entries = entries; + this._index = index; + this._continued = false; + this.direction = 'next'; + this.key = clone(entries[index][1]); + this.primaryKey = clone(entries[index][1]); + this.value = clone(entries[index][2]); + this.source = request.source; + } + continue(key = undefined) { + if (this._continued) throw failure('Cursor has already advanced', 'InvalidStateError'); + this._continued = true; + let next = this._index + 1; + if (key !== undefined) { + const token = keyToken(key); + while (next < this._entries.length + && compareTokens(this._entries[next][0], token) < 0) next++; + } + this._request._cursorStep(this._entries, next); + } + } + + class IDBObjectStore { + constructor(transaction, name) { + this.transaction = transaction; + this.name = name; + this.keyPath = null; + this.autoIncrement = false; + this.indexNames = names([]); + } + _store() { + this.transaction._requireActive(); + const store = this.transaction._state.stores[this.name]; + if (!store) throw failure(`Object store '${this.name}' was not found`, 'NotFoundError'); + return store; + } + get(key) { + const token = keyToken(key); + return this.transaction._enqueue(this, () => { + const entry = this._store().entries.find(item => item[0] === token); + return entry ? clone(entry[2]) : undefined; + }); + } + put(value, key) { return this._write(value, key, false); } + add(value, key) { return this._write(value, key, true); } + _write(value, key, addOnly) { + if (this.transaction.mode === 'readonly') { + throw failure('The transaction is read-only', 'ReadOnlyError'); + } + const token = keyToken(key); + const clonedKey = clone(key); + const clonedValue = clone(value); + return this.transaction._enqueue(this, () => { + const entries = this._store().entries; + const index = entries.findIndex(item => item[0] === token); + if (addOnly && index >= 0) throw failure('The key already exists', 'ConstraintError'); + const entry = [token, clone(clonedKey), clone(clonedValue)]; + if (index < 0) entries.push(entry); else entries[index] = entry; + this.transaction._mutations.push({ kind: 'put', store: this.name, entry }); + return clone(clonedKey); + }); + } + delete(key) { + if (this.transaction.mode === 'readonly') { + throw failure('The transaction is read-only', 'ReadOnlyError'); + } + const token = keyToken(key); + return this.transaction._enqueue(this, () => { + const entries = this._store().entries; + const index = entries.findIndex(item => item[0] === token); + if (index >= 0) entries.splice(index, 1); + this.transaction._mutations.push({ kind: 'delete', store: this.name, token }); + return undefined; + }); + } + clear() { + if (this.transaction.mode === 'readonly') { + throw failure('The transaction is read-only', 'ReadOnlyError'); + } + return this.transaction._enqueue(this, () => { + this._store().entries.length = 0; + this.transaction._mutations.push({ kind: 'clear', store: this.name }); + return undefined; + }); + } + count(key = undefined) { + const token = key === undefined ? null : keyToken(key); + return this.transaction._enqueue(this, () => token === null + ? this._store().entries.length + : Number(this._store().entries.some(item => item[0] === token))); + } + getAll() { + return this.transaction._enqueue(this, + () => this._store().entries.map(item => clone(item[2]))); + } + getAllKeys() { + return this.transaction._enqueue(this, + () => this._store().entries.map(item => clone(item[1]))); + } + openCursor() { + const request = new IDBRequest(this, this.transaction); + const entries = this._store().entries.slice() + .sort((left, right) => compareTokens(left[0], right[0])); + request._cursorStep = (values, index) => { + this.transaction._queueCursor(request, values, index); + }; + request._cursorStep(entries, 0); + return request; + } + createIndex() { throw failure('Indexes are not implemented', 'NotSupportedError'); } + index() { throw failure('Indexes are not implemented', 'NotSupportedError'); } + deleteIndex() { throw failure('Indexes are not implemented', 'NotSupportedError'); } + } + + class IDBTransaction extends EventTarget { + constructor(database, storeNames, mode, state, revision, versionchange = false) { + super(); + this.db = database; + this.mode = mode; + this.objectStoreNames = names(storeNames); + this.error = null; + this.onabort = null; + this.oncomplete = null; + this.onerror = null; + this._state = state; + this._revision = revision; + this._versionchange = versionchange; + this._active = true; + this._pending = 0; + this._finishScheduled = false; + this._mutations = []; + this._settled = versionchange ? new Promise((resolve, reject) => { + this._settleResolve = resolve; + this._settleReject = reject; + }) : null; + } + _requireActive() { + if (!this._active) throw failure('The transaction is inactive', 'TransactionInactiveError'); + } + objectStore(name) { + this._requireActive(); + name = String(name); + if (!this.objectStoreNames.includes(name)) { + throw failure(`Object store '${name}' is outside this transaction`, 'NotFoundError'); + } + return new IDBObjectStore(this, name); + } + _enqueue(source, operation) { + this._requireActive(); + const request = new IDBRequest(source, this); + this._pending++; + setTimeout(() => { + if (!this._active) return; + try { request._success(operation()); } + catch (error) { + const errorEvent = request._failure(error); + if (!errorEvent?.defaultPrevented) this._fail(request.error); + } + this._pending--; + this._scheduleFinish(); + }, 0); + return request; + } + _queueCursor(request, entries, index) { + this._requireActive(); + request.readyState = 'pending'; + this._pending++; + setTimeout(() => { + if (!this._active) return; + request.result = index < entries.length ? new IDBCursor(request, entries, index) : null; + request.readyState = 'done'; + request.dispatchEvent(event('success')); + this._pending--; + this._scheduleFinish(); + }, 0); + } + _scheduleFinish() { + if (!this._active || this._pending !== 0 || this._finishScheduled) return; + this._finishScheduled = true; + setTimeout(() => { + this._finishScheduled = false; + if (!this._active || this._pending !== 0) return; + if (this.mode === 'readonly') this._complete(); + else this._commit(); + }, 0); + } + async _commit(attempt = 0) { + try { + const revision = await nativeStorage( + 'store', this.db.name, this._revision, this._state); + this._revision = revision.revision ?? revision; + this.db._state = this._state; + this.db._revision = this._revision; + loadedDatabases.set(this.db.name, { + state: this._state, revision: this._revision + }); + this._complete(); + } catch (error) { + if (error?.name === 'AbortError' && attempt < 4 && !this._versionchange) { + try { + const latest = await nativeStorage('load', this.db.name); + const state = normalizeState(latest.data); + if (state.version !== this._state.version) throw error; + for (const mutation of this._mutations) { + const store = state.stores[mutation.store]; + if (!store) throw error; + if (mutation.kind === 'clear') store.entries.length = 0; + else if (mutation.kind === 'delete') { + const index = store.entries.findIndex(item => item[0] === mutation.token); + if (index >= 0) store.entries.splice(index, 1); + } else { + const index = store.entries.findIndex(item => item[0] === mutation.entry[0]); + const entry = clone(mutation.entry); + if (index < 0) store.entries.push(entry); else store.entries[index] = entry; + } + } + this._state = state; + this._revision = latest.revision; + return this._commit(attempt + 1); + } catch (reloadError) { error = reloadError; } + } + this._fail(error); + } + } + _complete() { + if (!this._active) return; + this._active = false; + this.dispatchEvent(event('complete')); + this._settleResolve?.(); + } + _fail(error) { + if (!this._active) return; + this.error = error instanceof DOMException + ? error : failure(String(error?.message || error), 'UnknownError'); + this._active = false; + this.dispatchEvent(event('error')); + this.dispatchEvent(event('abort')); + this._settleReject?.(this.error); + } + abort() { + this._requireActive(); + this._fail(failure('The transaction was aborted', 'AbortError')); + } + commit() { this._requireActive(); this._scheduleFinish(); } + } + + class IDBDatabase extends EventTarget { + constructor(name, state, revision) { + super(); + this.name = name; + this._state = state; + this._revision = revision; + this._closed = false; + this._upgradeTransaction = null; + this.onabort = null; + this.onerror = null; + this.onversionchange = null; + } + get version() { return this._state.version; } + get objectStoreNames() { return names(Object.keys(this._state.stores)); } + createObjectStore(name, options = {}) { + name = String(name); + if (!this._upgradeTransaction?._active) { + throw failure('createObjectStore requires an upgrade transaction', 'InvalidStateError'); + } + if (options.keyPath != null || options.autoIncrement) { + throw failure('keyPath and autoIncrement stores are not implemented', 'NotSupportedError'); + } + if (this._state.stores[name]) throw failure('Object store already exists', 'ConstraintError'); + this._state.stores[name] = { entries: [] }; + this._upgradeTransaction.objectStoreNames = names(Object.keys(this._state.stores)); + return new IDBObjectStore(this._upgradeTransaction, name); + } + deleteObjectStore(name) { + if (!this._upgradeTransaction?._active) { + throw failure('deleteObjectStore requires an upgrade transaction', 'InvalidStateError'); + } + name = String(name); + if (!this._state.stores[name]) throw failure('Object store was not found', 'NotFoundError'); + delete this._state.stores[name]; + this._upgradeTransaction.objectStoreNames = names(Object.keys(this._state.stores)); + } + transaction(storeNames, mode = 'readonly') { + if (this._closed) throw failure('The database connection is closed', 'InvalidStateError'); + const selected = typeof storeNames === 'string' ? [storeNames] : Array.from(storeNames); + if (!selected.length || selected.some(name => !this._state.stores[String(name)])) { + throw failure('An object store was not found', 'NotFoundError'); + } + if (mode !== 'readonly' && mode !== 'readwrite') { + throw new TypeError('Unsupported transaction mode'); + } + const state = mode === 'readonly' ? this._state : clone(this._state); + const transaction = new IDBTransaction( + this, selected.map(String), mode, state, this._revision); + transaction._scheduleFinish(); + return transaction; + } + close() { + if (this._closed) return; + this._closed = true; + openConnections.get(this.name)?.delete(this); + } + } + + const waitForConnections = (name, request, oldVersion, newVersion) => { + const connections = Array.from(openConnections.get(name) || []) + .filter(connection => !connection._closed); + if (!connections.length) return Promise.resolve(); + for (const connection of connections) { + connection.dispatchEvent(event('versionchange', { oldVersion, newVersion })); + } + const remaining = () => Array.from(openConnections.get(name) || []) + .some(connection => !connection._closed); + if (!remaining()) return Promise.resolve(); + request.dispatchEvent(event('blocked', { oldVersion, newVersion })); + return new Promise(resolve => { + const poll = () => remaining() ? setTimeout(poll, 2) : resolve(); + poll(); + }); + }; + + class IDBFactory { + open(name, version = undefined) { + name = String(name); + if (!name) throw new TypeError('Database name must not be empty'); + if (version !== undefined + && (!Number.isSafeInteger(Number(version)) || Number(version) <= 0)) { + throw new TypeError('Database version must be a positive integer'); + } + const requested = version === undefined ? undefined : Number(version); + const request = new IDBOpenDBRequest(); + setTimeout(async () => { + try { + const loaded = await nativeStorage('load', name); + const existing = loaded.data === null ? emptyState(0) : normalizeState(loaded.data); + let target = requested ?? (existing.version || 1); + if (target < existing.version) throw failure('Requested version is older', 'VersionError'); + if (target > existing.version) { + await waitForConnections(name, request, existing.version, target); + } + const state = target > existing.version ? clone(existing) : existing; + state.version = target; + const database = new IDBDatabase(name, state, loaded.revision); + request.result = database; + if (target > existing.version) { + const transaction = new IDBTransaction( + database, Object.keys(state.stores), 'versionchange', state, + loaded.revision, true); + database._upgradeTransaction = transaction; + request.transaction = transaction; + request.dispatchEvent(event('upgradeneeded', { + oldVersion: existing.version, newVersion: target + })); + database._upgradeTransaction = null; + transaction._scheduleFinish(); + await transaction._settled; + } + loadedDatabases.set(name, { state, revision: database._revision }); + if (!openConnections.has(name)) openConnections.set(name, new Set()); + openConnections.get(name).add(database); + request.transaction = null; + request._success(database); + } catch (error) { request._failure(error); } + }, 0); + return request; + } + deleteDatabase(name) { + name = String(name); + const request = new IDBOpenDBRequest(); + setTimeout(async () => { + try { + let oldVersion = 0; + try { + const prior = await nativeStorage('load', name); + oldVersion = prior.data?.version ?? 0; + } catch (error) { + if (error?.name !== 'DataError') throw error; + } + await waitForConnections(name, request, oldVersion, null); + await nativeStorage('delete', name); + loadedDatabases.delete(name); + request._success(undefined); + } catch (error) { request._failure(error); } + }, 0); + return request; + } + cmp(first, second) { + const left = keyToken(first), right = keyToken(second); + return compareTokens(left, right); + } + databases() { + return Promise.resolve(Array.from(loadedDatabases, ([name, value]) => ({ + name, version: value.state.version + }))); + } + } + + Object.defineProperties(globalThis, { + IDBRequest: { value: IDBRequest, configurable: true }, + IDBOpenDBRequest: { value: IDBOpenDBRequest, configurable: true }, + IDBDatabase: { value: IDBDatabase, configurable: true }, + IDBTransaction: { value: IDBTransaction, configurable: true }, + IDBObjectStore: { value: IDBObjectStore, configurable: true }, + IDBCursor: { value: IDBCursor, configurable: true }, + IDBFactory: { value: IDBFactory, configurable: true }, + indexedDB: { value: new IDBFactory(), configurable: true } + }); +})(); +)JS"; + +} // namespace webscene_native diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.cpp new file mode 100644 index 000000000..e423d6cc9 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.cpp @@ -0,0 +1,475 @@ +#include "webscene_indexeddb_storage.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#include +#endif + +namespace webscene_native { +namespace { + +constexpr std::array storage_magic{'W', 'S', 'I', 'D', 'B', '0', '0', '1'}; +constexpr uint32_t storage_schema = 1U; +constexpr size_t maximum_identity_bytes = 4096U; +constexpr uint64_t minimum_quota_bytes = 1024U * 1024U; +constexpr uint64_t maximum_payload_bytes = 512U * 1024U * 1024U; + +uint64_t hash_bytes(const void* data, size_t length) noexcept +{ + auto value = UINT64_C(14695981039346656037); + const auto* bytes = static_cast(data); + for (size_t index = 0; index < length; ++index) { + value ^= bytes[index]; + value *= UINT64_C(1099511628211); + } + return value; +} + +std::string digest_name(const std::string& value) +{ + constexpr char hex[] = "0123456789abcdef"; + const auto first = hash_bytes(value.data(), value.size()); + auto second = hash_bytes(&first, sizeof(first)); + second ^= static_cast(value.size()) * UINT64_C(0x9e3779b97f4a7c15); + std::string result(32U, '0'); + for (size_t index = 0; index < 16U; ++index) { + const auto shift = static_cast((15U - index) * 4U); + const auto source = index < 8U ? first : second; + result[index] = hex[(source >> shift) & 0xfU]; + result[index + 16U] = hex[(source >> ((index * 4U) & 63U)) & 0xfU]; + } + return result; +} + +template +bool read_scalar(std::istream& stream, Value& value) +{ + stream.read(reinterpret_cast(&value), sizeof(value)); + return static_cast(stream); +} + +template +bool write_scalar(std::ostream& stream, const Value& value) +{ + stream.write(reinterpret_cast(&value), sizeof(value)); + return static_cast(stream); +} + +bool read_string(std::istream& stream, std::string& value) +{ + uint32_t length = 0; + if (!read_scalar(stream, length) || length > maximum_identity_bytes) return false; + value.resize(length); + if (length != 0U) stream.read(value.data(), static_cast(length)); + return static_cast(stream); +} + +bool write_string(std::ostream& stream, const std::string& value) +{ + if (value.size() > maximum_identity_bytes) return false; + const auto length = static_cast(value.size()); + return write_scalar(stream, length) + && (length == 0U + || static_cast(stream.write( + value.data(), static_cast(length)))); +} + +bool flush_file(const std::filesystem::path& path) +{ +#if defined(_WIN32) + const auto handle = CreateFileW( + path.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) return false; + const auto flushed = FlushFileBuffers(handle) != 0; + CloseHandle(handle); + return flushed; +#else + const auto descriptor = ::open(path.c_str(), O_RDONLY); + if (descriptor < 0) return false; + const auto flushed = ::fsync(descriptor) == 0; + ::close(descriptor); + return flushed; +#endif +} + +bool replace_file( + const std::filesystem::path& temporary, + const std::filesystem::path& destination) +{ +#if defined(_WIN32) + return MoveFileExW( + temporary.c_str(), destination.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + std::error_code error; + std::filesystem::rename(temporary, destination, error); + if (error) return false; + const auto descriptor = ::open(destination.parent_path().c_str(), O_RDONLY); + if (descriptor >= 0) { + static_cast(::fsync(descriptor)); + ::close(descriptor); + } + return true; +#endif +} + +class directory_lock final { +public: + explicit directory_lock(std::filesystem::path path) : path_(std::move(path)) + { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(2); + while (std::chrono::steady_clock::now() < deadline) { + std::error_code error; + if (std::filesystem::create_directory(path_, error)) { + locked_ = true; + return; + } + error.clear(); + const auto modified = std::filesystem::last_write_time(path_, error); + if (!error + && std::filesystem::file_time_type::clock::now() - modified + > std::chrono::seconds(30)) { + std::filesystem::remove_all(path_, error); + continue; + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + + ~directory_lock() + { + if (!locked_) return; + std::error_code error; + std::filesystem::remove(path_, error); + } + + explicit operator bool() const noexcept { return locked_; } + +private: + std::filesystem::path path_; + bool locked_{false}; +}; + +uint64_t partition_usage(const std::filesystem::path& root) +{ + std::error_code error; + if (!std::filesystem::is_directory(root, error)) return 0U; + uint64_t total = 0U; + std::filesystem::recursive_directory_iterator iterator(root, error); + const std::filesystem::recursive_directory_iterator end; + while (!error && iterator != end) { + if (iterator->is_regular_file(error) + && iterator->path().extension() == ".wsidb") { + const auto size = iterator->file_size(error); + if (!error && size <= std::numeric_limits::max() - total) { + total += size; + } + } + iterator.increment(error); + } + return total; +} + +} // namespace + +indexeddb_storage::indexeddb_storage( + std::filesystem::path root, + std::string partition, + uint64_t quota_bytes) + : root_(std::move(root)) + , partition_(std::move(partition)) + , quota_bytes_(quota_bytes == 0U ? 256U * 1024U * 1024U : quota_bytes) + , worker_([this](std::stop_token token) { run(token); }) +{ +} + +indexeddb_storage::~indexeddb_storage() +{ + worker_.request_stop(); + wake_.notify_all(); + if (worker_.joinable()) worker_.join(); +} + +bool indexeddb_storage::available() const noexcept +{ + return !root_.empty() && !partition_.empty() + && partition_.size() <= maximum_identity_bytes + && quota_bytes_ >= minimum_quota_bytes; +} + +std::filesystem::path indexeddb_storage::database_path( + const std::string& origin, + const std::string& database) const +{ + if (!available() || origin.empty() || origin == "null" + || origin.size() > maximum_identity_bytes || database.empty() + || database.size() > maximum_identity_bytes) { + return {}; + } + return root_ / digest_name(partition_) / digest_name(origin) + / (digest_name(database) + ".wsidb"); +} + +indexeddb_storage_result indexeddb_storage::load_sync( + const std::string& origin, + const std::string& database) const +{ + const auto path = database_path(origin, database); + if (path.empty()) { + return {indexeddb_storage_status::unavailable, 0U, {}, + "Persistent storage is not configured for this origin"}; + } + std::ifstream stream(path, std::ios::binary); + if (!stream) { + std::error_code error; + if (!std::filesystem::exists(path, error)) { + return {indexeddb_storage_status::not_found, 0U, {}, {}}; + } + return {indexeddb_storage_status::io_error, 0U, {}, + "Unable to open the IndexedDB data file"}; + } + std::array magic{}; + uint32_t schema = 0; + uint64_t revision = 0; + std::string stored_partition, stored_origin, stored_database; + uint64_t payload_length = 0, payload_hash = 0; + stream.read(magic.data(), static_cast(magic.size())); + if (!stream || magic != storage_magic || !read_scalar(stream, schema) + || schema != storage_schema || !read_scalar(stream, revision) + || !read_string(stream, stored_partition) + || !read_string(stream, stored_origin) + || !read_string(stream, stored_database) + || !read_scalar(stream, payload_length) + || !read_scalar(stream, payload_hash) + || stored_partition != partition_ || stored_origin != origin + || stored_database != database || payload_length > maximum_payload_bytes + || payload_length > quota_bytes_) { + return {indexeddb_storage_status::corrupt, 0U, {}, + "IndexedDB data failed schema or identity validation"}; + } + std::vector bytes(static_cast(payload_length)); + if (payload_length != 0U) { + stream.read( + reinterpret_cast(bytes.data()), + static_cast(payload_length)); + } + if (!stream || stream.peek() != std::ifstream::traits_type::eof() + || hash_bytes(bytes.data(), bytes.size()) != payload_hash) { + return {indexeddb_storage_status::corrupt, 0U, {}, + "IndexedDB data failed content validation"}; + } + return {indexeddb_storage_status::ok, revision, std::move(bytes), {}}; +} + +indexeddb_storage_result indexeddb_storage::store_sync( + const std::string& origin, + const std::string& database, + uint64_t expected_revision, + const std::vector& bytes) const +{ + const auto path = database_path(origin, database); + if (path.empty()) { + return {indexeddb_storage_status::unavailable, 0U, {}, + "Persistent storage is not configured for this origin"}; + } + if (bytes.size() > quota_bytes_ || bytes.size() > maximum_payload_bytes) { + return {indexeddb_storage_status::quota_exceeded, 0U, {}, + "IndexedDB payload exceeds the configured quota"}; + } + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) { + return {indexeddb_storage_status::io_error, 0U, {}, + "Unable to create the IndexedDB profile directory"}; + } + directory_lock lock(path.string() + ".lock"); + if (!lock) { + return {indexeddb_storage_status::io_error, 0U, {}, + "Timed out acquiring the IndexedDB database lock"}; + } + const auto current = load_sync(origin, database); + const auto current_revision = current.status == indexeddb_storage_status::not_found + ? 0U : current.revision; + if (current.status != indexeddb_storage_status::ok + && current.status != indexeddb_storage_status::not_found) { + return current; + } + if (current_revision != expected_revision) { + return {indexeddb_storage_status::conflict, current_revision, {}, + "IndexedDB database changed before this transaction committed"}; + } + + const auto old_size = std::filesystem::exists(path, error) + ? std::filesystem::file_size(path, error) : 0U; + if (error) { + return {indexeddb_storage_status::io_error, current_revision, {}, + "Unable to inspect the IndexedDB data file"}; + } + constexpr uint64_t fixed_header = 8U + sizeof(uint32_t) + + sizeof(uint64_t) * 3U + sizeof(uint32_t) * 3U; + const auto new_size = fixed_header + partition_.size() + origin.size() + + database.size() + bytes.size(); + const auto usage = partition_usage(root_ / digest_name(partition_)); + const auto projected = usage >= old_size ? usage - old_size + new_size : new_size; + if (projected > quota_bytes_) { + return {indexeddb_storage_status::quota_exceeded, current_revision, {}, + "IndexedDB profile quota would be exceeded"}; + } + + static std::atomic sequence{0U}; + const auto temporary = path.string() + "." +#if defined(_WIN32) + + std::to_string(GetCurrentProcessId()) +#else + + std::to_string(static_cast(::getpid())) +#endif + + "." + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)) + + ".tmp"; + std::ofstream stream(temporary, std::ios::binary | std::ios::trunc); + const auto revision = current_revision + 1U; + const auto payload_length = static_cast(bytes.size()); + const auto payload_hash = hash_bytes(bytes.data(), bytes.size()); + stream.write(storage_magic.data(), static_cast(storage_magic.size())); + if (!stream || !write_scalar(stream, storage_schema) + || !write_scalar(stream, revision) + || !write_string(stream, partition_) || !write_string(stream, origin) + || !write_string(stream, database) + || !write_scalar(stream, payload_length) + || !write_scalar(stream, payload_hash)) { + stream.close(); + std::filesystem::remove(temporary, error); + return {indexeddb_storage_status::io_error, current_revision, {}, + "Unable to write the IndexedDB transaction header"}; + } + if (!bytes.empty()) { + stream.write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + } + stream.close(); + if (!stream || !flush_file(temporary) || !replace_file(temporary, path)) { + std::filesystem::remove(temporary, error); + return {indexeddb_storage_status::io_error, current_revision, {}, + "Unable to commit the IndexedDB transaction atomically"}; + } + return {indexeddb_storage_status::ok, revision, {}, {}}; +} + +indexeddb_storage_result indexeddb_storage::erase_sync( + const std::string& origin, + const std::string& database) const +{ + const auto path = database_path(origin, database); + if (path.empty()) { + return {indexeddb_storage_status::unavailable, 0U, {}, + "Persistent storage is not configured for this origin"}; + } + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) return {indexeddb_storage_status::io_error, 0U, {}, {}}; + directory_lock lock(path.string() + ".lock"); + if (!lock) return {indexeddb_storage_status::io_error, 0U, {}, {}}; + if (!std::filesystem::remove(path, error) && error) { + return {indexeddb_storage_status::io_error, 0U, {}, + "Unable to remove the IndexedDB data file"}; + } + return {indexeddb_storage_status::ok, 0U, {}, {}}; +} + +void indexeddb_storage::load( + std::string origin, + std::string database, + completion callback) +{ + enqueue({operation_kind::load, std::move(origin), std::move(database), + 0U, {}, std::move(callback)}); +} + +void indexeddb_storage::store( + std::string origin, + std::string database, + uint64_t expected_revision, + std::vector bytes, + completion callback) +{ + enqueue({operation_kind::store, std::move(origin), std::move(database), + expected_revision, std::move(bytes), std::move(callback)}); +} + +void indexeddb_storage::erase( + std::string origin, + std::string database, + completion callback) +{ + enqueue({operation_kind::erase, std::move(origin), std::move(database), + 0U, {}, std::move(callback)}); +} + +void indexeddb_storage::enqueue(operation value) +{ + { + std::lock_guard lock(mutex_); + operations_.push_back(std::move(value)); + } + wake_.notify_one(); +} + +void indexeddb_storage::run(std::stop_token token) +{ + while (!token.stop_requested()) { + operation next{operation_kind::load, {}, {}, 0U, {}, {}}; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, token, [this] { return !operations_.empty(); }); + if (token.stop_requested()) break; + next = std::move(operations_.front()); + operations_.pop_front(); + } + indexeddb_storage_result result; + switch (next.kind) { + case operation_kind::load: + result = load_sync(next.origin, next.database); + break; + case operation_kind::store: + result = store_sync( + next.origin, next.database, next.expected_revision, next.bytes); + break; + case operation_kind::erase: + result = erase_sync(next.origin, next.database); + break; + } + try { + if (next.callback) next.callback(std::move(result)); + } catch (...) { + } + } + std::deque abandoned; + { + std::lock_guard lock(mutex_); + abandoned.swap(operations_); + } + for (auto& value : abandoned) { + try { + if (value.callback) value.callback({ + indexeddb_storage_status::unavailable, 0U, {}, + "IndexedDB storage is shutting down"}); + } catch (...) { + } + } +} + +} // namespace webscene_native diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.h new file mode 100644 index 000000000..be5fecc37 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_indexeddb_storage.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace webscene_native { + +enum class indexeddb_storage_status : uint8_t { + ok, + not_found, + conflict, + quota_exceeded, + unavailable, + corrupt, + io_error +}; + +struct indexeddb_storage_result final { + indexeddb_storage_status status{indexeddb_storage_status::io_error}; + uint64_t revision{0}; + std::vector bytes; + std::string message; +}; + +class indexeddb_storage final { +public: + using completion = std::function; + + indexeddb_storage( + std::filesystem::path root, + std::string partition, + uint64_t quota_bytes); + ~indexeddb_storage(); + + indexeddb_storage(const indexeddb_storage&) = delete; + indexeddb_storage& operator=(const indexeddb_storage&) = delete; + + bool available() const noexcept; + void load(std::string origin, std::string database, completion callback); + void store( + std::string origin, + std::string database, + uint64_t expected_revision, + std::vector bytes, + completion callback); + void erase(std::string origin, std::string database, completion callback); + + // Synchronous entry points are intentionally public for the V8-free + // durability contract. Production JavaScript uses only the worker-backed + // methods above. + indexeddb_storage_result load_sync( + const std::string& origin, + const std::string& database) const; + indexeddb_storage_result store_sync( + const std::string& origin, + const std::string& database, + uint64_t expected_revision, + const std::vector& bytes) const; + indexeddb_storage_result erase_sync( + const std::string& origin, + const std::string& database) const; + +private: + enum class operation_kind : uint8_t { load, store, erase }; + struct operation final { + operation_kind kind; + std::string origin; + std::string database; + uint64_t expected_revision{0}; + std::vector bytes; + completion callback; + }; + + std::filesystem::path database_path( + const std::string& origin, + const std::string& database) const; + void enqueue(operation value); + void run(std::stop_token token); + + std::filesystem::path root_; + std::string partition_; + uint64_t quota_bytes_{0}; + mutable std::mutex mutex_; + std::condition_variable_any wake_; + std::deque operations_; + std::jthread worker_; +}; + +} // namespace webscene_native diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 5fddd6695..ef0319fb6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -407,6 +407,9 @@ struct webscene_engine final { webscene_frame_trace frame_trace_; uint32_t command_count_; std::string compilation_cache_directory_; + std::string storage_directory_; + std::string storage_partition_key_; + uint64_t storage_quota_bytes_{0}; webscene_resource_load_callback resource_load_callback_{nullptr}; void* resource_load_user_data_{nullptr}; webscene_resource_load_callback_v2 resource_load_callback_v2_{nullptr}; @@ -905,6 +908,8 @@ webscene_engine* webscene_engine_create_with_options(const webscene_engine_optio options->compilation_cache_directory, options->compilation_cache_directory_length); } + std::string storage_directory; + std::string storage_partition_key; constexpr auto resource_callback_options_size = offsetof(webscene_engine_options, scene_published_callback); const auto has_resource_callback = @@ -941,10 +946,31 @@ webscene_engine* webscene_engine_create_with_options(const webscene_engine_optio options->struct_size >= offsetof(webscene_engine_options, webgpu_policy_callback); const auto has_webgpu_policy = options->struct_size >= offsetof( webscene_engine_options, resource_load_callback_v4); - const auto has_resource_callback_v4 = options->struct_size >= sizeof(webscene_engine_options); + const auto has_resource_callback_v4 = options->struct_size + >= offsetof(webscene_engine_options, storage_directory); + constexpr auto storage_options_size = + offsetof(webscene_engine_options, storage_quota_bytes) + + sizeof(uint64_t); + const auto has_storage_options = options->struct_size + >= storage_options_size; + if (has_storage_options && options->storage_directory != nullptr + && options->storage_directory_length > 0U) { + storage_directory.assign( + options->storage_directory, + options->storage_directory_length); + } + if (has_storage_options && options->storage_partition_key != nullptr + && options->storage_partition_key_length > 0U) { + storage_partition_key.assign( + options->storage_partition_key, + options->storage_partition_key_length); + } return new webscene_engine( options->simulated_chart_command_count, std::move(cache_directory), + std::move(storage_directory), + std::move(storage_partition_key), + has_storage_options ? options->storage_quota_bytes : 0U, has_resource_callback ? options->resource_load_callback : nullptr, has_resource_callback ? options->resource_load_user_data : nullptr, has_resource_callback_v2 ? options->resource_load_callback_v2 : nullptr, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index d11197656..a2d906a66 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -900,6 +900,18 @@ typedef struct webscene_engine_options { void* webgpu_policy_user_data; webscene_resource_load_callback_v4 resource_load_callback_v4; void* resource_load_v4_user_data; + /* + * Durable browser storage is disabled unless both strings are supplied. + * storage_partition_key is a stable host-owned application/profile id; + * the runtime still partitions its files by the document's effective + * origin below that key. Hosts may therefore keep a random loopback port + * out of the profile identity without merging unrelated applications. + */ + const char* storage_directory; + size_t storage_directory_length; + const char* storage_partition_key; + size_t storage_partition_key_length; + uint64_t storage_quota_bytes; } webscene_engine_options; enum { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc index c80d5e823..411545a0c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -1,6 +1,9 @@ explicit webscene_engine( uint32_t command_count, std::string compilation_cache_directory = {}, + std::string storage_directory = {}, + std::string storage_partition_key = {}, + uint64_t storage_quota_bytes = 0, webscene_resource_load_callback resource_load_callback = nullptr, void* resource_load_user_data = nullptr, webscene_resource_load_callback_v2 resource_load_callback_v2 = nullptr, @@ -32,6 +35,9 @@ ? minimum_command_count : command_count)) , compilation_cache_directory_(std::move(compilation_cache_directory)) + , storage_directory_(std::move(storage_directory)) + , storage_partition_key_(std::move(storage_partition_key)) + , storage_quota_bytes_(storage_quota_bytes) , resource_load_callback_(resource_load_callback) , resource_load_user_data_(resource_load_user_data) , resource_load_callback_v2_(resource_load_callback_v2) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index 7b13560be..744e18cd9 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -300,7 +300,8 @@ }, [this] { signal_worker(); - }, &diagnostics_); + }, &diagnostics_, storage_directory_, storage_partition_key_, + storage_quota_bytes_); runtime_->set_work_metrics_enabled( runtime_work_metrics_enabled_.load(std::memory_order_acquire)); if (stylesheet_consumed_callback_ != nullptr) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 37796d47e..fc93b2e8f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -23,6 +23,8 @@ #include "webscene_embed_fallback.h" #include "webscene_performance_timeline_compatibility.h" #include "webscene_file_reader_compatibility.h" +#include "webscene_indexeddb_storage.h" +#include "webscene_indexeddb_compatibility.h" #include "webscene_stylesheet_cssom_compatibility.h" #include "webscene_secure_random.h" #include "webscene_crypto_provider.h" @@ -4009,6 +4011,7 @@ struct v8_dom_runtime::implementation final { install_crypto_globals(local_context, local_context->Global()); local_context->Global()->Set(local_context, js_string(isolate, "structuredClone"), v8::Function::New(local_context, structured_clone, {}, 1).ToLocalChecked()).Check(); + install_indexeddb(local_context); install_performance_timeline(local_context); install_worker_constructor(local_context); install_clipboard_api(local_context); @@ -4878,6 +4881,7 @@ struct v8_dom_runtime::implementation final { } #include "webscene_v8_runtime_clone.inc" +#include "webscene_v8_runtime_indexeddb.inc" #include "webscene_v8_runtime_modules.inc" #include "webscene_v8_runtime_workers.inc" #include "webscene_v8_runtime_crypto.inc" @@ -4993,16 +4997,22 @@ v8_dom_runtime::v8_dom_runtime( std::function interop_callback_available, interop_callback_sink_v3 interop_callback_sink, std::function runtime_work_available, - runtime_diagnostics* diagnostics) + runtime_diagnostics* diagnostics, + std::string storage_directory, + std::string storage_partition_key, + uint64_t storage_quota_bytes) : impl_(std::make_unique( document, std::move(viewport_provider), std::move(compilation_cache_directory), std::move(load_resource), - std::move(host_request_available), - std::move(interop_callback_available), - std::move(interop_callback_sink), - std::move(runtime_work_available), diagnostics)) + std::move(host_request_available), + std::move(interop_callback_available), + std::move(interop_callback_sink), + std::move(runtime_work_available), diagnostics, + std::move(storage_directory), + std::move(storage_partition_key), + storage_quota_bytes)) { } @@ -5618,6 +5628,7 @@ bool v8_dom_runtime::has_pending_tasks() const noexcept #endif #endif return impl_->has_pending_detached_dom_collection() + || impl_->indexeddb_work_ready.load(std::memory_order_acquire) || impl_->websocket_transport.has_pending_events() || !impl_->pending_window_messages.empty() || impl_->has_worker_messages() diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 6ee6b4cc1..0548422d4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -278,7 +278,10 @@ class v8_dom_runtime final { std::function interop_callback_available = {}, interop_callback_sink_v3 interop_callback_sink = {}, std::function runtime_work_available = {}, - class runtime_diagnostics* diagnostics = nullptr); + class runtime_diagnostics* diagnostics = nullptr, + std::string storage_directory = {}, + std::string storage_partition_key = {}, + uint64_t storage_quota_bytes = 0); ~v8_dom_runtime(); v8_dom_runtime(const v8_dom_runtime&) = delete; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc index e44c0fd89..e22aa84f3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc @@ -1886,6 +1886,7 @@ js_string(isolate, message_channel_bootstrap.c_str())).ToLocalChecked(); message_channel_script->Run(local_context).ToLocalChecked(); install_crypto_globals(local_context, global); + install_indexeddb(local_context); install_clipboard_api(local_context); install_websocket_globals(local_context); install_editor_web_platform_globals(local_context); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_indexeddb.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_indexeddb.inc new file mode 100644 index 000000000..3ce08185d --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_indexeddb.inc @@ -0,0 +1,232 @@ + std::string indexeddb_origin(v8::Local realm) + { + v8::Local location_value; + if (!realm->Global()->Get(realm, js_string(isolate, "location")) + .ToLocal(&location_value) + || !location_value->IsObject()) return "null"; + auto location = location_value.As(); + const auto property = [&](const char* name) { + v8::Local value; + return location->Get(realm, js_string(isolate, name)).ToLocal(&value) + ? to_utf8(isolate, value) : std::string{}; + }; + auto origin = property("origin"); + auto hostname = property("hostname"); + auto protocol = property("protocol"); + if (hostname.empty()) { + const auto href = property("href"); + if (protocol.empty()) { + const auto scheme_end = href.find(':'); + if (scheme_end != std::string::npos) + protocol = href.substr(0U, scheme_end + 1U); + } + const auto authority_start = href.find("://"); + if (authority_start != std::string::npos) { + const auto authority_end = href.find_first_of( + "/?#", authority_start + 3U); + auto authority = href.substr( + authority_start + 3U, + (authority_end == std::string::npos ? href.size() : authority_end) + - authority_start - 3U); + if (const auto at = authority.rfind('@'); at != std::string::npos) + authority.erase(0U, at + 1U); + if (authority.starts_with("[")) { + const auto close = authority.find(']'); + hostname = authority.substr( + 0U, close == std::string::npos ? authority.size() : close + 1U); + } else { + const auto colon = authority.find(':'); + hostname = authority.substr(0U, colon); + } + if (origin.empty()) origin = protocol + "//" + authority; + } + } + std::transform(hostname.begin(), hostname.end(), hostname.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + if (hostname == "localhost" || hostname.ends_with(".localhost") + || hostname.starts_with("127.") + || hostname == "[::1]" || hostname == "::1") { + origin = protocol + "//loopback"; + } + return origin; + } + + v8::Local indexeddb_exception( + v8::Local realm, + const std::string& message, + const char* name) + { + v8::Local constructor_value; + if (realm->Global()->Get(realm, js_string(isolate, "DOMException")) + .ToLocal(&constructor_value) + && constructor_value->IsFunction()) { + v8::Local arguments[] = { + js_string(isolate, message.c_str()), js_string(isolate, name)}; + v8::Local result; + if (constructor_value.As() + ->NewInstance(realm, 2, arguments).ToLocal(&result)) return result; + } + return v8::Exception::Error(js_string(isolate, message.c_str())); + } + + void enqueue_indexeddb_completion(uint64_t id, indexeddb_storage_result result) + { + { + std::lock_guard lock(indexeddb_completion_mutex); + indexeddb_completions.push_back({id, std::move(result)}); + indexeddb_work_ready.store(true, std::memory_order_release); + } + if (runtime_work_available) runtime_work_available(); + } + + static void indexeddb_storage_operation( + const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto realm = info.GetIsolate()->GetCurrentContext(); + if (self == nullptr || self->indexeddb == nullptr + || !self->indexeddb->available()) { + info.GetIsolate()->ThrowException(self == nullptr + ? v8::Exception::Error(js_string( + info.GetIsolate(), "IndexedDB runtime is unavailable")) + : self->indexeddb_exception( + realm, "Persistent storage was not configured by the host", + "NotSupportedError")); + return; + } + if (info.Length() < 2 || !info[0]->IsString() || !info[1]->IsString()) { + info.GetIsolate()->ThrowException(v8::Exception::TypeError( + js_string(info.GetIsolate(), "Invalid IndexedDB storage operation"))); + return; + } + if (self->pending_indexeddb_promises.size() >= 256U) { + info.GetIsolate()->ThrowException(self->indexeddb_exception( + realm, "IndexedDB operation queue is full", "QuotaExceededError")); + return; + } + const auto operation = to_utf8(info.GetIsolate(), info[0]); + const auto database = to_utf8(info.GetIsolate(), info[1]); + const auto origin = self->indexeddb_origin(realm); + if (database.empty() || database.size() > 4096U + || origin.empty() || origin == "null") { + info.GetIsolate()->ThrowException(self->indexeddb_exception( + realm, "IndexedDB is unavailable for this origin", "SecurityError")); + return; + } + auto resolver = v8::Promise::Resolver::New(realm).ToLocalChecked(); + const auto id = self->next_indexeddb_operation_id++; + self->pending_indexeddb_promises.emplace(id, pending_indexeddb_promise{ + v8::Global(info.GetIsolate(), realm), + v8::Global(info.GetIsolate(), resolver), + operation == "load"}); + const auto completion = [self, id](indexeddb_storage_result result) { + self->enqueue_indexeddb_completion(id, std::move(result)); + }; + if (operation == "load") { + self->indexeddb->load(origin, database, completion); + } else if (operation == "delete") { + self->indexeddb->erase(origin, database, completion); + } else if (operation == "store") { + if (info.Length() < 4 || !info[2]->IsBigInt()) { + self->pending_indexeddb_promises.erase(id); + info.GetIsolate()->ThrowException(v8::Exception::TypeError( + js_string(info.GetIsolate(), + "IndexedDB store requires a revision and value"))); + return; + } + bool lossless = false; + const auto revision = info[2].As()->Uint64Value(&lossless); + clone_packet packet; + if (!lossless || !serialize_clone( + info, info[3], v8::Undefined(info.GetIsolate()), packet)) { + self->pending_indexeddb_promises.erase(id); + return; + } + self->indexeddb->store( + origin, database, revision, std::move(packet.bytes), completion); + } else { + self->pending_indexeddb_promises.erase(id); + info.GetIsolate()->ThrowException(v8::Exception::TypeError( + js_string(info.GetIsolate(), "Unknown IndexedDB storage operation"))); + return; + } + info.GetReturnValue().Set(resolver->GetPromise()); + } + + void install_indexeddb(v8::Local realm) + { + if (indexeddb == nullptr) return; + auto global = realm->Global(); + global->DefineOwnProperty( + realm, + js_string(isolate, "__webSceneIndexedDBStorage"), + v8::Function::New(realm, indexeddb_storage_operation).ToLocalChecked(), + v8::PropertyAttribute::DontEnum).Check(); + auto script = v8::Script::Compile( + realm, + js_string(isolate, std::string(indexeddb_compatibility_source).c_str())) + .ToLocalChecked(); + script->Run(realm).ToLocalChecked(); + } + + bool drain_indexeddb_completion() + { + indexeddb_completion completion; + { + std::lock_guard lock(indexeddb_completion_mutex); + if (indexeddb_completions.empty()) { + indexeddb_work_ready.store(false, std::memory_order_release); + return true; + } + completion = std::move(indexeddb_completions.front()); + indexeddb_completions.pop_front(); + indexeddb_work_ready.store( + !indexeddb_completions.empty(), std::memory_order_release); + } + const auto pending = pending_indexeddb_promises.find(completion.id); + if (pending == pending_indexeddb_promises.end()) return true; + auto realm = pending->second.context.Get(isolate); + auto resolver = pending->second.resolver.Get(isolate); + const auto deserialize_data = pending->second.deserialize_data; + pending_indexeddb_promises.erase(pending); + if (realm.IsEmpty() || resolver.IsEmpty()) return true; + v8::Context::Scope context_scope(realm); + auto& result = completion.result; + if (result.status == indexeddb_storage_status::ok + || result.status == indexeddb_storage_status::not_found) { + auto response = v8::Object::New(isolate); + response->Set(realm, js_string(isolate, "revision"), + v8::BigInt::NewFromUnsigned(isolate, result.revision)).Check(); + v8::Local data = v8::Null(isolate); + if (result.status == indexeddb_storage_status::ok && deserialize_data) { + clone_packet packet; + packet.bytes = std::move(result.bytes); + if (!deserialize_clone(isolate, realm, packet).ToLocal(&data)) { + resolver->Reject(realm, indexeddb_exception( + realm, "IndexedDB structured data could not be decoded", + "DataError")).Check(); + perform_microtask_checkpoint(); + return true; + } + } + response->Set(realm, js_string(isolate, "data"), data).Check(); + resolver->Resolve(realm, response).Check(); + } else { + const char* name = "UnknownError"; + if (result.status == indexeddb_storage_status::quota_exceeded) + name = "QuotaExceededError"; + else if (result.status == indexeddb_storage_status::conflict) + name = "AbortError"; + else if (result.status == indexeddb_storage_status::unavailable) + name = "NotSupportedError"; + else if (result.status == indexeddb_storage_status::corrupt) + name = "DataError"; + resolver->Reject(realm, indexeddb_exception( + realm, + result.message.empty() ? "IndexedDB storage operation failed" + : result.message, + name)).Check(); + } + perform_microtask_checkpoint(); + return true; + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index 19d3ea3f5..435e18d16 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -8,10 +8,16 @@ v8_dom_runtime::interop_callback_sink_v3 interop_callback_sink_value, std::function runtime_work_available_value, - runtime_diagnostics* diagnostics_value) + runtime_diagnostics* diagnostics_value, + std::string storage_directory_value, + std::string storage_partition_key_value, + uint64_t storage_quota_bytes_value) : document(document_value) , viewport_provider(std::move(viewport_provider_value)) , load_resource_callback(std::move(resource_loader_value)) + , storage_directory(std::move(storage_directory_value)) + , storage_partition_key(std::move(storage_partition_key_value)) + , storage_quota_bytes(storage_quota_bytes_value) , host_request_available(std::move(host_request_available_value)) , interop_callback_available(std::move(interop_callback_available_value)) , interop_callback_sink(std::move(interop_callback_sink_value)) @@ -40,6 +46,13 @@ , profile_css(std::getenv("WEBSCENE_PROBE_PROFILE_CSS") != nullptr) #endif { + if (!storage_directory.empty() && !storage_partition_key.empty()) { + indexeddb = std::make_unique( + std::filesystem::path(storage_directory), + storage_partition_key, + storage_quota_bytes); + if (!indexeddb->available()) indexeddb.reset(); + } #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) if (profile_startup) { startup_profile_started = std::chrono::steady_clock::now(); @@ -53,6 +66,9 @@ for (auto& cipher : pending_ciphers) cipher.stop.request_stop(); stop_message_ports(); stop_workers(); + // The storage worker never touches V8. Join it before persistent + // resolver handles and the isolate are released. + indexeddb.reset(); #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_MEDIA) clear_media_bindings(); media_initializer_key.Reset(); @@ -165,6 +181,11 @@ pending_programmatic_scroll_events.clear(); pending_interop_promises.clear(); pending_callback_promises.clear(); + { + std::lock_guard lock(indexeddb_completion_mutex); + indexeddb_completions.clear(); + } + pending_indexeddb_promises.clear(); // Native host and file completions can still be pending when a window // closes. Their persistent context/resolver handles must be released // while the isolate is alive; member destruction happens after this diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 53236f570..06723eae5 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -86,6 +86,16 @@ pending_interop_promises; std::unordered_map pending_callback_promises; + std::string storage_directory; + std::string storage_partition_key; + uint64_t storage_quota_bytes{0}; + std::unique_ptr indexeddb; + std::mutex indexeddb_completion_mutex; + std::deque indexeddb_completions; + std::unordered_map + pending_indexeddb_promises; + std::atomic indexeddb_work_ready{false}; + uint64_t next_indexeddb_operation_id{1U}; std::unordered_map> node_wrappers; uint32_t next_attr_id{1U}; std::unordered_map> attr_states; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc index 0e60725e8..4753264a4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc @@ -120,6 +120,17 @@ v8::Global resolver; }; + struct pending_indexeddb_promise final { + v8::Global context; + v8::Global resolver; + bool deserialize_data{false}; + }; + + struct indexeddb_completion final { + uint64_t id{0}; + indexeddb_storage_result result; + }; + struct frame_script final { std::string source; std::string code; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index f37c57733..900b0bfc6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -562,6 +562,9 @@ // compilation and optimization work. An embedder must service that // queue; otherwise hot application code remains on its initial tier. pump_v8_platform_tasks(); + if (indexeddb_work_ready.load(std::memory_order_acquire)) { + return drain_indexeddb_completion(); + } #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_GRAPHICS) if (graphics && !graphics_delivering && graphics->has_ready_work()) { struct delivery_guard { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/indexeddb_storage_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/indexeddb_storage_tests.cpp new file mode 100644 index 000000000..4fa452372 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/indexeddb_storage_tests.cpp @@ -0,0 +1,298 @@ +#include "webscene_indexeddb_storage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#endif + +namespace { + +using webscene_native::indexeddb_storage; +using webscene_native::indexeddb_storage_result; +using webscene_native::indexeddb_storage_status; + +#if defined(_WIN32) +using child_process = intptr_t; +#else +using child_process = pid_t; +#endif + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +class temporary_directory final { +public: + temporary_directory() + : path_(std::filesystem::temp_directory_path() + / ("webscene-indexeddb-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch().count()))) + { + std::filesystem::create_directories(path_); + } + ~temporary_directory() + { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + const std::filesystem::path& path() const noexcept { return path_; } + +private: + std::filesystem::path path_; +}; + +void test_round_trip_revision_and_partitioning() +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "app-profile", 4U * 1024U * 1024U); + const std::vector value{0U, 1U, 2U, 0xffU}; + const auto stored = storage.store_sync("https://example.test", "state", 0U, value); + require(stored.status == indexeddb_storage_status::ok && stored.revision == 1U, + "first commit did not create revision one"); + const auto loaded = storage.load_sync("https://example.test", "state"); + require(loaded.status == indexeddb_storage_status::ok + && loaded.revision == 1U && loaded.bytes == value, + "committed bytes did not round trip"); + require(storage.load_sync("https://other.test", "state").status + == indexeddb_storage_status::not_found, + "a distinct origin observed another origin's database"); + indexeddb_storage other_profile(root.path(), "other-profile", 4U * 1024U * 1024U); + require(other_profile.load_sync("https://example.test", "state").status + == indexeddb_storage_status::not_found, + "a distinct profile observed another profile's database"); +} + +void test_conflict_preserves_prior_commit() +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + const std::vector first{1U, 2U, 3U}; + const std::vector second{9U, 8U, 7U}; + require(storage.store_sync("http://loopback", "state", 0U, first).status + == indexeddb_storage_status::ok, + "initial commit failed"); + const auto conflict = storage.store_sync("http://loopback", "state", 0U, second); + require(conflict.status == indexeddb_storage_status::conflict, + "stale transaction was not rejected"); + require(storage.load_sync("http://loopback", "state").bytes == first, + "failed transaction changed the prior commit"); +} + +child_process spawn_commit_child( + const std::string& executable, + const std::filesystem::path& root, + const char* value) +{ + const auto root_text = root.string(); +#if defined(_WIN32) + return _spawnl(_P_NOWAIT, executable.c_str(), executable.c_str(), + "--commit-child", root_text.c_str(), value, nullptr); +#else + const auto process = ::fork(); + if (process == 0) { + ::execl(executable.c_str(), executable.c_str(), "--commit-child", + root_text.c_str(), value, static_cast(nullptr)); + _exit(127); + } + return process; +#endif +} + +int wait_for_child(child_process process) +{ +#if defined(_WIN32) + int status = 0; + return _cwait(&status, process, 0) < 0 ? -1 : status; +#else + int status = 0; + if (::waitpid(process, &status, 0) < 0 || !WIFEXITED(status)) return -1; + return WEXITSTATUS(status); +#endif +} + +void test_cross_process_conflict(const std::string& executable) +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + require(storage.store_sync("http://loopback", "cross-process", 0U, {1U}).status + == indexeddb_storage_status::ok, + "cross-process fixture commit failed"); + const auto first = spawn_commit_child(executable, root.path(), "7"); + const auto second = spawn_commit_child(executable, root.path(), "9"); + require(first > 0 && second > 0, "cross-process writers did not start"); + const auto first_status = wait_for_child(first); + const auto second_status = wait_for_child(second); + require((first_status == 0 && second_status == 10) + || (first_status == 10 && second_status == 0), + "cross-process stale writer was not rejected exactly once"); + const auto committed = storage.load_sync("http://loopback", "cross-process"); + require(committed.status == indexeddb_storage_status::ok + && committed.revision == 2U && committed.bytes.size() == 1U + && (committed.bytes[0] == 7U || committed.bytes[0] == 9U), + "cross-process conflict changed the durable winner"); +} + +void test_corruption_and_quota_are_honest() +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "profile", 1024U * 1024U); + std::vector large(700U * 1024U, 0x5aU); + require(storage.store_sync("https://quota.test", "first", 0U, large).status + == indexeddb_storage_status::ok, + "in-quota payload was rejected"); + require(storage.store_sync("https://quota.test", "second", 0U, large).status + == indexeddb_storage_status::quota_exceeded, + "profile quota was not enforced"); + + std::filesystem::path committed; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root.path())) { + if (entry.path().extension() == ".wsidb") { + committed = entry.path(); + break; + } + } + require(!committed.empty(), "committed database file was not found"); + std::fstream file(committed, std::ios::binary | std::ios::in | std::ios::out); + file.seekg(-1, std::ios::end); + const auto prior = file.get(); + require(prior != std::char_traits::eof(), "committed database file was empty"); + file.seekp(-1, std::ios::end); + file.put(static_cast(prior ^ 0xff)); + file.close(); + require(storage.load_sync("https://quota.test", "first").status + == indexeddb_storage_status::corrupt, + "corrupt content was accepted"); + require(storage.erase_sync("https://quota.test", "first").status + == indexeddb_storage_status::ok + && storage.load_sync("https://quota.test", "first").status + == indexeddb_storage_status::not_found, + "explicit deletion did not recover a corrupt database"); +} + +void test_interrupted_write_and_stale_lock_recovery() +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + const std::vector first{1U, 3U, 5U, 7U}; + const std::vector second{2U, 4U, 6U, 8U}; + require(storage.store_sync("https://recovery.test", "state", 0U, first).status + == indexeddb_storage_status::ok, + "interruption fixture commit failed"); + + std::filesystem::path committed; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root.path())) { + if (entry.path().extension() == ".wsidb") { + committed = entry.path(); + break; + } + } + require(!committed.empty(), "interruption fixture file was not found"); + const auto abandoned = std::filesystem::path(committed.string() + ".999.1.tmp"); + std::ofstream(abandoned, std::ios::binary | std::ios::trunc).write("WSID", 4); + const auto stale_lock = std::filesystem::path(committed.string() + ".lock"); + std::filesystem::create_directory(stale_lock); + std::filesystem::last_write_time( + stale_lock, + std::filesystem::file_time_type::clock::now() - std::chrono::minutes(1)); + + indexeddb_storage restarted(root.path(), "profile", 4U * 1024U * 1024U); + const auto recovered = restarted.load_sync("https://recovery.test", "state"); + require(recovered.status == indexeddb_storage_status::ok + && recovered.revision == 1U && recovered.bytes == first, + "an abandoned temporary write hid the prior durable revision"); + const auto committed_after_restart = restarted.store_sync( + "https://recovery.test", "state", 1U, second); + require(committed_after_restart.status == indexeddb_storage_status::ok + && committed_after_restart.revision == 2U, + "a stale crash lock prevented the next atomic commit"); + require(restarted.load_sync("https://recovery.test", "state").bytes == second, + "post-interruption commit did not replace the prior revision"); +} + +void test_async_io_and_commit_throughput() +{ + temporary_directory root; + indexeddb_storage storage(root.path(), "profile", 8U * 1024U * 1024U); + std::mutex mutex; + std::condition_variable ready; + bool completed = false; + std::thread::id callback_thread; + const auto caller_thread = std::this_thread::get_id(); + storage.store("https://async.test", "state", 0U, {1U, 2U, 3U}, + [&](indexeddb_storage_result result) { + require(result.status == indexeddb_storage_status::ok, + "asynchronous store failed"); + { + std::lock_guard lock(mutex); + callback_thread = std::this_thread::get_id(); + completed = true; + } + ready.notify_one(); + }); + { + std::unique_lock lock(mutex); + require(ready.wait_for(lock, std::chrono::seconds(5), [&] { return completed; }), + "asynchronous store did not complete"); + } + require(callback_thread != caller_thread, "disk callback ran on the caller thread"); + + std::vector payload(4096U, 0x42U); + auto revision = uint64_t{1U}; + constexpr uint64_t iterations = 100U; + const auto started = std::chrono::steady_clock::now(); + for (uint64_t index = 0; index < iterations; ++index) { + payload[0] = static_cast(index); + const auto result = storage.store_sync( + "https://async.test", "state", revision, payload); + require(result.status == indexeddb_storage_status::ok, + "throughput commit failed"); + revision = result.revision; + } + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 10.0, "100 small durable commits exceeded ten seconds"); + std::cout << "indexeddb durable commits/s=" + << static_cast(iterations) / elapsed << '\n'; +} + +} // namespace + +int main(int argc, char** argv) +{ + try { + if (argc == 4 && std::string_view(argv[1]) == "--commit-child") { + indexeddb_storage storage(argv[2], "profile", 4U * 1024U * 1024U); + const auto value = static_cast(std::stoi(argv[3])); + const auto result = storage.store_sync( + "http://loopback", "cross-process", 1U, {value}); + if (result.status == indexeddb_storage_status::ok) return 0; + if (result.status == indexeddb_storage_status::conflict) return 10; + return 20; + } + test_round_trip_revision_and_partitioning(); + test_conflict_preserves_prior_commit(); + test_cross_process_conflict(std::filesystem::absolute(argv[0]).string()); + test_corruption_and_quota_are_honest(); + test_interrupted_write_and_stale_lock_recovery(); + test_async_io_and_commit_throughput(); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc new file mode 100644 index 000000000..6ffb88dba --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc @@ -0,0 +1,294 @@ +webscene_engine* create_indexeddb_engine( + const std::filesystem::path& root, + std::string_view partition, + uint64_t quota = 4U * 1024U * 1024U) +{ + const auto root_text = root.string(); + webscene_engine_options options{}; + options.struct_size = sizeof(options); + options.storage_directory = root_text.data(); + options.storage_directory_length = root_text.size(); + options.storage_partition_key = partition.data(); + options.storage_partition_key_length = partition.size(); + options.storage_quota_bytes = quota; + return webscene_engine_create_with_options(&options); +} + +void configure_indexeddb_test_origin(webscene_engine* engine, uint16_t port) +{ + const auto source = std::string( + "location.protocol='http:';location.hostname='127.0.0.1';" + "location.origin='http://127.0.0.1:") + std::to_string(port) + + "';location.href=location.origin+'/';"; + execute_and_wait(engine, source, "indexeddb-test-origin.js"); +} + +void wait_for_indexeddb_flag(webscene_engine* engine, std::string_view expression) +{ + require( + evaluate_until_equals( + engine, expression, "indexeddb-completion-probe.js", "true", 2500) + == "true", + "IndexedDB operation did not settle: " + std::string(expression)); +} + +void test_indexeddb_persistence_and_transaction_contract() +{ + const auto root = std::filesystem::temp_directory_path() + / ("webscene-indexeddb-runtime-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch().count())); + std::filesystem::create_directories(root); + const auto cleanup = [&] { + std::error_code error; + std::filesystem::remove_all(root, error); + }; + + auto* engine = create_indexeddb_engine(root, "code-oss-profile"); + require(engine != nullptr, "IndexedDB engine creation failed"); + configure_indexeddb_test_origin(engine, 41001U); + execute_and_wait(engine, R"JS( + globalThis.indexedDbProbe = { done: false }; + let stage = 'open-v1'; + (async () => { + const open = (version, upgrade) => new Promise((resolve, reject) => { + const request = indexedDB.open('vscode-web-state-db-global', version); + request.onupgradeneeded = event => upgrade?.(request.result, event); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + const transactionDone = transaction => new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onabort = transaction.onerror = () => reject(transaction.error); + }); + let upgradeOld = -1; + const database = await open(1, (db, event) => { + upgradeOld = event.oldVersion; + db.createObjectStore('ItemTable'); + }); + stage = 'write-v1'; + const write = database.transaction('ItemTable', 'readwrite'); + write.objectStore('ItemTable').put( + { nested: { enabled: true }, list: [1, 2, 3], bytes: new Uint8Array([0, 7, 255]) }, + 'alpha'); + write.objectStore('ItemTable').put('second', 'beta'); + await transactionDone(write); + + stage = 'abort-v1'; + const rolledBack = database.transaction('ItemTable', 'readwrite'); + rolledBack.objectStore('ItemTable').put('must-not-commit', 'alpha'); + let abortName = ''; + rolledBack.onabort = () => { abortName = rolledBack.error?.name || ''; }; + rolledBack.abort(); + await new Promise(resolve => setTimeout(resolve, 5)); + + stage = 'cursor-v1'; + const cursorValues = []; + const read = database.transaction('ItemTable', 'readonly'); + await new Promise((resolve, reject) => { + const cursor = read.objectStore('ItemTable').openCursor(); + cursor.onerror = () => reject(cursor.error); + cursor.onsuccess = () => { + if (!cursor.result) { resolve(); return; } + cursorValues.push([cursor.result.key, cursor.result.value]); + cursor.result.continue(); + }; + }); + let versionChange = ''; + database.onversionchange = event => { + versionChange = `${event.oldVersion}->${event.newVersion}`; + database.close(); + }; + stage = 'open-v2'; + const upgraded = await open(2, db => db.createObjectStore('SecondTable')); + globalThis.indexedDbProbe = { + done: true, + upgradeOld, + abortName, + versionChange, + version: upgraded.version, + stores: Array.from(upgraded.objectStoreNames), + cursorValues + }; + upgraded.close(); + })().catch(error => { + globalThis.indexedDbProbe = { + done: true, error: `${error?.name}:${error?.message}`, + stage: typeof stage === 'string' ? stage : 'unknown' + }; + }); + )JS", "indexeddb-runtime-contract.js"); + wait_for_indexeddb_flag(engine, "indexedDbProbe.done===true"); + const auto runtime_probe = evaluate( + engine, "JSON.stringify(indexedDbProbe)", "indexeddb-probe-details.js"); + require(evaluate(engine, "!indexedDbProbe.error", "indexeddb-no-error.js") == "true", + "IndexedDB runtime contract failed: " + runtime_probe); + require(evaluate(engine, + "indexedDbProbe.upgradeOld===0&&indexedDbProbe.abortName==='AbortError'" + "&&indexedDbProbe.versionChange==='1->2'&&indexedDbProbe.version===2" + "&&indexedDbProbe.stores.join(',')==='ItemTable,SecondTable'" + "&&indexedDbProbe.cursorValues.length===2" + "&&indexedDbProbe.cursorValues[0][0]==='alpha'" + "&&indexedDbProbe.cursorValues[0][1].nested.enabled" + "&&indexedDbProbe.cursorValues[0][1].bytes[2]===255", + "indexeddb-runtime-result.js") == "true", + "upgrade, rollback, structured clone, or cursor behavior changed"); + webscene_engine_destroy(engine); + + engine = create_indexeddb_engine(root, "code-oss-profile"); + require(engine != nullptr, "IndexedDB restart engine creation failed"); + // A different ephemeral loopback port retains the explicit app partition. + configure_indexeddb_test_origin(engine, 41999U); + execute_and_wait(engine, R"JS( + globalThis.indexedDbRestartProbe = { done: false }; + (() => new Promise((resolve, reject) => { + const request = indexedDB.open('vscode-web-state-db-global'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }))().then(database => new Promise((resolve, reject) => { + const transaction = database.transaction('ItemTable', 'readonly'); + const request = transaction.objectStore('ItemTable').get('alpha'); + transaction.onerror = transaction.onabort = () => reject(transaction.error); + transaction.oncomplete = () => { + globalThis.indexedDbRestartProbe = { + done: true, + restored: request.result.nested.enabled + && request.result.list.join(',') === '1,2,3' + && request.result.bytes[1] === 7 + }; + database.close(); + resolve(); + }; + })).catch(error => { + globalThis.indexedDbRestartProbe = { + done: true, error: `${error?.name}:${error?.message}` + }; + }); + )JS", "indexeddb-restart-contract.js"); + wait_for_indexeddb_flag(engine, "indexedDbRestartProbe.done===true"); + require(evaluate(engine, + "indexedDbRestartProbe.restored===true&&!indexedDbRestartProbe.error", + "indexeddb-restart-result.js") == "true", + "IndexedDB state did not survive an engine restart and port change"); + webscene_engine_destroy(engine); + cleanup(); +} + +void test_indexeddb_quota_and_corruption_failures() +{ + const auto root = std::filesystem::temp_directory_path() + / ("webscene-indexeddb-failure-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch().count())); + std::filesystem::create_directories(root); + auto* engine = create_indexeddb_engine(root, "failure-profile", 1024U * 1024U); + require(engine != nullptr, "IndexedDB failure engine creation failed"); + configure_indexeddb_test_origin(engine, 42001U); + execute_and_wait(engine, R"JS( + globalThis.indexedDbQuotaProbe = { done: false }; + new Promise((resolve, reject) => { + const open = indexedDB.open('quota-db', 1); + open.onupgradeneeded = () => open.result.createObjectStore('items'); + open.onerror = () => reject(open.error); + open.onsuccess = () => resolve(open.result); + }).then(database => new Promise(resolve => { + const transaction = database.transaction('items', 'readwrite'); + transaction.objectStore('items').put('x'.repeat(2 * 1024 * 1024), 'large'); + transaction.onabort = transaction.onerror = () => { + globalThis.indexedDbQuotaProbe = { + done: true, name: transaction.error?.name + }; + database.close(); + resolve(); + }; + transaction.oncomplete = () => { + globalThis.indexedDbQuotaProbe = { done: true, name: 'committed' }; + database.close(); + resolve(); + }; + })).catch(error => { + globalThis.indexedDbQuotaProbe = { done: true, name: error?.name }; + }); + )JS", "indexeddb-quota-contract.js"); + wait_for_indexeddb_flag(engine, "indexedDbQuotaProbe.done===true"); + require(evaluate(engine, + "indexedDbQuotaProbe.name==='QuotaExceededError'", + "indexeddb-quota-result.js") == "true", + "quota failure did not abort with QuotaExceededError"); + webscene_engine_destroy(engine); + + std::filesystem::path database_file; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root)) { + if (entry.path().extension() == ".wsidb") { + database_file = entry.path(); + break; + } + } + require(!database_file.empty(), "quota database file was not created"); + std::fstream corrupt(database_file, std::ios::binary | std::ios::in | std::ios::out); + corrupt.seekg(-1, std::ios::end); + const auto prior = corrupt.get(); + require(prior != std::char_traits::eof(), "quota database file was empty"); + corrupt.seekp(-1, std::ios::end); + corrupt.put(static_cast(prior ^ 0xff)); + corrupt.close(); + + engine = create_indexeddb_engine(root, "failure-profile", 1024U * 1024U); + require(engine != nullptr, "IndexedDB corruption engine creation failed"); + configure_indexeddb_test_origin(engine, 42002U); + execute_and_wait(engine, R"JS( + globalThis.indexedDbCorruptionProbe = { done: false }; + const request = indexedDB.open('quota-db'); + request.onsuccess = () => { + globalThis.indexedDbCorruptionProbe = { done: true, name: 'opened' }; + }; + request.onerror = () => { + globalThis.indexedDbCorruptionProbe = { + done: true, name: request.error?.name + }; + }; + )JS", "indexeddb-corruption-contract.js"); + wait_for_indexeddb_flag(engine, "indexedDbCorruptionProbe.done===true"); + require(evaluate(engine, + "indexedDbCorruptionProbe.name==='DataError'", + "indexeddb-corruption-result.js") == "true", + "corrupt data did not surface as DataError"); + execute_and_wait(engine, R"JS( + globalThis.indexedDbRecoveryProbe = { done: false }; + const removed = indexedDB.deleteDatabase('quota-db'); + removed.onerror = () => { + globalThis.indexedDbRecoveryProbe = { + done: true, error: removed.error?.name + }; + }; + removed.onsuccess = () => { + const reopened = indexedDB.open('quota-db', 1); + reopened.onupgradeneeded = () => reopened.result.createObjectStore('items'); + reopened.onerror = () => { + globalThis.indexedDbRecoveryProbe = { + done: true, error: reopened.error?.name + }; + }; + reopened.onsuccess = () => { + globalThis.indexedDbRecoveryProbe = { + done: true, recovered: reopened.result.version === 1 + }; + reopened.result.close(); + }; + }; + )JS", "indexeddb-corruption-recovery.js"); + wait_for_indexeddb_flag(engine, "indexedDbRecoveryProbe.done===true"); + require(evaluate(engine, + "indexedDbRecoveryProbe.recovered===true&&!indexedDbRecoveryProbe.error", + "indexeddb-corruption-recovery-result.js") == "true", + "deleteDatabase did not recover corrupt durable state"); + webscene_engine_destroy(engine); + std::error_code error; + std::filesystem::remove_all(root, error); +} + +void test_indexeddb_runtime_contract() +{ + test_indexeddb_persistence_and_transaction_contract(); + test_indexeddb_quota_and_corruption_failures(); +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index f244b0cf7..b9e27f3c4 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -74,6 +74,7 @@ uint8_t measure_baseline_fixture_text( #include "native_v8_runtime_document_tests.inc" #include "native_v8_runtime_test_support.inc" #include "native_v8_runtime_lifecycle_tests.inc" +#include "native_v8_runtime_indexeddb_tests.inc" #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) #include "native_v8_runtime_inspector_tests.inc" #endif @@ -131,6 +132,10 @@ int main() if (const auto* filter = std::getenv("WEBSCENE_NATIVE_ENGINE_TEST_FILTER"); filter != nullptr) { const auto selected = std::string_view(filter); + if (selected == "indexeddb") { + test_indexeddb_runtime_contract(); + return 0; + } if (selected == "idle-v8-platform") { test_idle_v8_foreground_completion(); return 0; @@ -825,6 +830,7 @@ int main() test_process_wide_compilation_single_flight(); test_canvas_text_metrics_use_host_font_axes(); test_recursive_functional_selector_survives_cache_eviction(); + test_indexeddb_runtime_contract(); auto* engine = webscene_engine_create(64); require(engine != nullptr, "engine creation failed"); #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) diff --git a/src/WebScene.Backend.Abstractions/NativeWebSceneLoadOptions.cs b/src/WebScene.Backend.Abstractions/NativeWebSceneLoadOptions.cs index cc327b646..9d9f8808f 100644 --- a/src/WebScene.Backend.Abstractions/NativeWebSceneLoadOptions.cs +++ b/src/WebScene.Backend.Abstractions/NativeWebSceneLoadOptions.cs @@ -20,6 +20,21 @@ public sealed record NativeWebSceneLoadOptions public string? CompilationCacheDirectory { get; init; } + /// + /// Gets the host-controlled root for durable browser storage. IndexedDB is + /// unavailable when this or is omitted. + /// + public string? PersistentStorageDirectory { get; init; } + + /// + /// Gets the stable application/profile identity used above origin isolation. + /// Loopback applications may keep this value stable when their port changes. + /// + public string? PersistentStoragePartitionKey { get; init; } + + /// Gets the profile quota in bytes, or zero for the native default. + public ulong PersistentStorageQuotaBytes { get; init; } + public IReadOnlyList DocumentStartScripts { get; init; } = []; /// diff --git a/src/WebScene.Backend.Avalonia/NativeSceneInteropTypes.cs b/src/WebScene.Backend.Avalonia/NativeSceneInteropTypes.cs index 10cf3f570..720ed968f 100644 --- a/src/WebScene.Backend.Avalonia/NativeSceneInteropTypes.cs +++ b/src/WebScene.Backend.Avalonia/NativeSceneInteropTypes.cs @@ -651,6 +651,11 @@ internal struct EngineOptions public IntPtr WebGpuPolicyUserData; public IntPtr ResourceLoadCallbackV4; public IntPtr ResourceLoadV4UserData; + public IntPtr StorageDirectory; + public nuint StorageDirectoryLength; + public IntPtr StoragePartitionKey; + public nuint StoragePartitionKeyLength; + public ulong StorageQuotaBytes; } [StructLayout(LayoutKind.Sequential)] diff --git a/src/WebScene.Backend.Avalonia/NativeSceneRuntime.cs b/src/WebScene.Backend.Avalonia/NativeSceneRuntime.cs index 9b44448a7..50a8ff2d3 100644 --- a/src/WebScene.Backend.Avalonia/NativeSceneRuntime.cs +++ b/src/WebScene.Backend.Avalonia/NativeSceneRuntime.cs @@ -341,13 +341,22 @@ public static IntPtr EngineCreate( Action? hostRequestAvailable = null, Action? interopCallbackAvailable = null, Action? animationFrameRequested = null, - Func? admitWebGpuDocument = null) + Func? admitWebGpuDocument = null, + string? persistentStorageDirectory = null, + string? persistentStoragePartitionKey = null, + ulong persistentStorageQuotaBytes = 0) { ArgumentNullException.ThrowIfNull(resourceLoader); ArgumentNullException.ThrowIfNull(scenePublished); var directoryBytes = string.IsNullOrWhiteSpace(compilationCacheDirectory) ? [] : Encoding.UTF8.GetBytes(compilationCacheDirectory); + var storageDirectoryBytes = string.IsNullOrWhiteSpace(persistentStorageDirectory) + ? [] + : Encoding.UTF8.GetBytes(persistentStorageDirectory); + var storagePartitionBytes = string.IsNullOrWhiteSpace(persistentStoragePartitionKey) + ? [] + : Encoding.UTF8.GetBytes(persistentStoragePartitionKey); var bridgeHandle = GCHandle.Alloc( new ResourceBridge( resourceLoader, @@ -359,6 +368,8 @@ public static IntPtr EngineCreate( try { fixed (byte* directory = directoryBytes) + fixed (byte* storageDirectory = storageDirectoryBytes) + fixed (byte* storagePartition = storagePartitionBytes) { var options = new EngineOptions { @@ -399,7 +410,14 @@ public static IntPtr EngineCreate( WebGpuPolicyCallback = admitWebGpuDocument is null ? IntPtr.Zero : WebGpuPolicyAddress, WebGpuPolicyUserData = admitWebGpuDocument is null ? IntPtr.Zero : GCHandle.ToIntPtr(bridgeHandle), ResourceLoadCallbackV4 = ResourceLoadV4Address, - ResourceLoadV4UserData = GCHandle.ToIntPtr(bridgeHandle) + ResourceLoadV4UserData = GCHandle.ToIntPtr(bridgeHandle), + StorageDirectory = storageDirectoryBytes.Length == 0 + ? IntPtr.Zero : (IntPtr)storageDirectory, + StorageDirectoryLength = (nuint)storageDirectoryBytes.Length, + StoragePartitionKey = storagePartitionBytes.Length == 0 + ? IntPtr.Zero : (IntPtr)storagePartition, + StoragePartitionKeyLength = (nuint)storagePartitionBytes.Length, + StorageQuotaBytes = persistentStorageQuotaBytes }; var engine = EngineCreateWithOptions(in options); if (engine == IntPtr.Zero) return IntPtr.Zero; diff --git a/src/WebScene.Backend.Avalonia/NativeWebSceneView.cs b/src/WebScene.Backend.Avalonia/NativeWebSceneView.cs index 57f467ec7..5b4645be9 100644 --- a/src/WebScene.Backend.Avalonia/NativeWebSceneView.cs +++ b/src/WebScene.Backend.Avalonia/NativeWebSceneView.cs @@ -416,6 +416,10 @@ await _lifecycleGate.WaitAsync( { Directory.CreateDirectory(options.CompilationCacheDirectory); } + if (!string.IsNullOrWhiteSpace(options.PersistentStorageDirectory)) + { + Directory.CreateDirectory(options.PersistentStorageDirectory); + } if (lifetime is null) { @@ -447,7 +451,10 @@ await NativeWebSceneRuntime hostRequestAvailable: OnNativeHostRequestAvailable, interopCallbackAvailable: callbackSignal.Notify, animationFrameRequested: _surface.OnNativeAnimationFrameRequested, - admitWebGpuDocument: _admitWebGpuDocument); + admitWebGpuDocument: _admitWebGpuDocument, + persistentStorageDirectory: options.PersistentStorageDirectory, + persistentStoragePartitionKey: options.PersistentStoragePartitionKey, + persistentStorageQuotaBytes: options.PersistentStorageQuotaBytes); if (engine == IntPtr.Zero) { throw new InvalidOperationException( diff --git a/src/WebScene.Backend.Uno/UnoNativeSceneSurface.cs b/src/WebScene.Backend.Uno/UnoNativeSceneSurface.cs index ca948dc67..c2b257318 100644 --- a/src/WebScene.Backend.Uno/UnoNativeSceneSurface.cs +++ b/src/WebScene.Backend.Uno/UnoNativeSceneSurface.cs @@ -787,6 +787,10 @@ await NativeWebSceneRuntime.PrewarmAsync( { Directory.CreateDirectory(options.CompilationCacheDirectory); } + if (!string.IsNullOrWhiteSpace(options.PersistentStorageDirectory)) + { + Directory.CreateDirectory(options.PersistentStorageDirectory); + } var timeoutValue = documentBarrierTimeout ?? TimeSpan.FromSeconds(30); if (timeoutValue != Timeout.InfiniteTimeSpan && timeoutValue <= TimeSpan.Zero) @@ -799,7 +803,10 @@ await NativeWebSceneRuntime.PrewarmAsync( options.CompilationCacheDirectory, options.ResourceLoader ?? new UnoResourceLoader(), _surface.OnNativeScenePublished, - interopCallbackAvailable: callbackSignal.Notify); + interopCallbackAvailable: callbackSignal.Notify, + persistentStorageDirectory: options.PersistentStorageDirectory, + persistentStoragePartitionKey: options.PersistentStoragePartitionKey, + persistentStorageQuotaBytes: options.PersistentStorageQuotaBytes); if (engine == IntPtr.Zero) { throw new InvalidOperationException( diff --git a/src/WebScene.Sdk.Avalonia/WebSceneComponentHost.cs b/src/WebScene.Sdk.Avalonia/WebSceneComponentHost.cs index 9cd4802d5..5a0c1562f 100644 --- a/src/WebScene.Sdk.Avalonia/WebSceneComponentHost.cs +++ b/src/WebScene.Sdk.Avalonia/WebSceneComponentHost.cs @@ -64,6 +64,15 @@ public sealed class WebSceneComponentHost : Decorator, IAsyncDisposable public static readonly StyledProperty CompilationCacheDirectoryProperty = AvaloniaProperty.Register(nameof(CompilationCacheDirectory)); + public static readonly StyledProperty PersistentStorageDirectoryProperty = + AvaloniaProperty.Register(nameof(PersistentStorageDirectory)); + + public static readonly StyledProperty PersistentStoragePartitionKeyProperty = + AvaloniaProperty.Register(nameof(PersistentStoragePartitionKey)); + + public static readonly StyledProperty PersistentStorageQuotaBytesProperty = + AvaloniaProperty.Register(nameof(PersistentStorageQuotaBytes)); + public static readonly StyledProperty AutoMountProperty = AvaloniaProperty.Register( nameof(AutoMount), @@ -122,6 +131,24 @@ public string? CompilationCacheDirectory set => SetValue(CompilationCacheDirectoryProperty, value); } + public string? PersistentStorageDirectory + { + get => GetValue(PersistentStorageDirectoryProperty); + set => SetValue(PersistentStorageDirectoryProperty, value); + } + + public string? PersistentStoragePartitionKey + { + get => GetValue(PersistentStoragePartitionKeyProperty); + set => SetValue(PersistentStoragePartitionKeyProperty, value); + } + + public ulong PersistentStorageQuotaBytes + { + get => GetValue(PersistentStorageQuotaBytesProperty); + set => SetValue(PersistentStorageQuotaBytesProperty, value); + } + public bool AutoMount { get => GetValue(AutoMountProperty); @@ -399,6 +426,10 @@ await View.LoadAsync( Source = resources.DocumentUrl, NativeLibraryPath = ResolveNativeLibraryPath(NativeLibraryPath), CompilationCacheDirectory = CompilationCacheDirectory, + PersistentStorageDirectory = PersistentStorageDirectory, + PersistentStoragePartitionKey = PersistentStoragePartitionKey + ?? package.Manifest.Id, + PersistentStorageQuotaBytes = PersistentStorageQuotaBytes, DocumentStartScripts = DocumentStartScripts, ResourceLoader = resources }, diff --git a/src/WebScene.Sdk.Uno/WebSceneComponentHost.cs b/src/WebScene.Sdk.Uno/WebSceneComponentHost.cs index 88c0f4d04..d26f423ba 100644 --- a/src/WebScene.Sdk.Uno/WebSceneComponentHost.cs +++ b/src/WebScene.Sdk.Uno/WebSceneComponentHost.cs @@ -75,6 +75,27 @@ public sealed class WebSceneComponentHost : ContentControl, IAsyncDisposable typeof(WebSceneComponentHost), new PropertyMetadata(null)); + public static readonly DependencyProperty PersistentStorageDirectoryProperty = + DependencyProperty.Register( + nameof(PersistentStorageDirectory), + typeof(string), + typeof(WebSceneComponentHost), + new PropertyMetadata(null)); + + public static readonly DependencyProperty PersistentStoragePartitionKeyProperty = + DependencyProperty.Register( + nameof(PersistentStoragePartitionKey), + typeof(string), + typeof(WebSceneComponentHost), + new PropertyMetadata(null)); + + public static readonly DependencyProperty PersistentStorageQuotaBytesProperty = + DependencyProperty.Register( + nameof(PersistentStorageQuotaBytes), + typeof(ulong), + typeof(WebSceneComponentHost), + new PropertyMetadata(0UL)); + public static readonly DependencyProperty AutoMountProperty = DependencyProperty.Register( nameof(AutoMount), @@ -139,6 +160,24 @@ public string? CompilationCacheDirectory set => SetValue(CompilationCacheDirectoryProperty, value); } + public string? PersistentStorageDirectory + { + get => (string?)GetValue(PersistentStorageDirectoryProperty); + set => SetValue(PersistentStorageDirectoryProperty, value); + } + + public string? PersistentStoragePartitionKey + { + get => (string?)GetValue(PersistentStoragePartitionKeyProperty); + set => SetValue(PersistentStoragePartitionKeyProperty, value); + } + + public ulong PersistentStorageQuotaBytes + { + get => (ulong)GetValue(PersistentStorageQuotaBytesProperty); + set => SetValue(PersistentStorageQuotaBytesProperty, value); + } + public bool AutoMount { get => (bool)GetValue(AutoMountProperty); @@ -418,6 +457,10 @@ await View.LoadAsync( Source = resources.DocumentUrl, NativeLibraryPath = ResolveNativeLibraryPath(NativeLibraryPath), CompilationCacheDirectory = CompilationCacheDirectory, + PersistentStorageDirectory = PersistentStorageDirectory, + PersistentStoragePartitionKey = PersistentStoragePartitionKey + ?? package.Manifest.Id, + PersistentStorageQuotaBytes = PersistentStorageQuotaBytes, DocumentStartScripts = DocumentStartScripts, ResourceLoader = resources }, diff --git a/src/WebScene.Sdk/CompatibilityChecker.cs b/src/WebScene.Sdk/CompatibilityChecker.cs index c0cb7d319..c620fcd85 100644 --- a/src/WebScene.Sdk/CompatibilityChecker.cs +++ b/src/WebScene.Sdk/CompatibilityChecker.cs @@ -35,7 +35,8 @@ private sealed record Rule( private static readonly Rule[] s_rules = [ Unsupported(@"\bnavigator\s*\.\s*serviceWorker\b", "WEBSCENE1001", "Service workers are not supported."), - Unsupported(@"\bindexedDB\b", "WEBSCENE1002", "IndexedDB is not supported."), + Requires(@"\bindexedDB\b", "WEBSCENE1002", WebSceneComponentCapabilities.IndexedDb, + "Persistent IndexedDB access must be declared."), Unsupported(@"\b(?:Worker|SharedWorker|Worklet)\s*\(", "WEBSCENE1003", "Web workers and worklets are not supported."), Unsupported(@"\b(?:RTCPeerConnection|MediaRecorder|AudioContext|webkitAudioContext)\b", "WEBSCENE1004", "WebRTC, recording, and Web Audio are not supported."), Unsupported(@"\bnavigator\s*\.\s*(?:mediaDevices|geolocation)\b", "WEBSCENE1005", "Media devices and geolocation are not supported."), diff --git a/src/WebScene.Sdk/ComponentManifest.cs b/src/WebScene.Sdk/ComponentManifest.cs index 63abaa4fe..f68c1c53d 100644 --- a/src/WebScene.Sdk/ComponentManifest.cs +++ b/src/WebScene.Sdk/ComponentManifest.cs @@ -14,6 +14,7 @@ public static class WebSceneComponentCapabilities public const string Keyboard = "input.keyboard"; public const string Focus = "input.focus"; public const string Clipboard = "clipboard"; + public const string IndexedDb = "storage.indexeddb"; public const string Commands = "host.commands"; public const string Settings = "host.settings"; public const string Notifications = "host.notifications"; @@ -31,6 +32,7 @@ public static class WebSceneComponentCapabilities Keyboard, Focus, Clipboard, + IndexedDb, Commands, Settings, Notifications, diff --git a/tests/WebPlatformSubset/README.md b/tests/WebPlatformSubset/README.md index 124bc6f05..c94ee2576 100644 --- a/tests/WebPlatformSubset/README.md +++ b/tests/WebPlatformSubset/README.md @@ -66,6 +66,11 @@ Use `--test ` for a focused document, `--timeout-seconds ` to alter the per-document timeout, and `--native-cache-directory ` to isolate V8 cache evidence. +The separate IndexedDB candidate profile requires +`--native-storage-directory ` and `--native-storage-partition `; an +optional `--native-storage-quota-bytes ` sets its partition quota. See +`docs/architecture/indexeddb-persistence.md` for its lifecycle and native gates. + Static reftests and self-verifying visual tests can also collect an independent Chromium differential or color-oracle result: diff --git a/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html b/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html new file mode 100644 index 000000000..d362b28e8 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html @@ -0,0 +1,137 @@ + + +IndexedDB Code OSS storage contract + diff --git a/tests/WebPlatformSubset/runner/EngineAdapters.cs b/tests/WebPlatformSubset/runner/EngineAdapters.cs index f24e52306..906dbea7e 100644 --- a/tests/WebPlatformSubset/runner/EngineAdapters.cs +++ b/tests/WebPlatformSubset/runner/EngineAdapters.cs @@ -90,14 +90,20 @@ internal NativeWptEngineEnvironment( } NativeApi.Configure(libraryPath); - _managedHostEngine = nativeNavigation || html.Contains("@font-face", StringComparison.OrdinalIgnoreCase); + _managedHostEngine = nativeNavigation + || html.Contains("@font-face", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(options.NativeStorageDirectory); if (_managedHostEngine) { // Navigation and font contracts use the product resource-loading, // registration and measurement path, not a separate harness font map. NativeWebSceneApi.ConfigureLibraryPath(libraryPath); _engine = NativeWebSceneApi.EngineCreate(0, options.NativeCacheDirectory, - new AvaloniaResourceLoader { ScriptBaseDirectory = fontBaseDirectory ?? upstreamRoot }, _ => { }); + new AvaloniaResourceLoader { ScriptBaseDirectory = fontBaseDirectory ?? upstreamRoot }, + _ => { }, + persistentStorageDirectory: options.NativeStorageDirectory, + persistentStoragePartitionKey: options.NativeStoragePartitionKey, + persistentStorageQuotaBytes: options.NativeStorageQuotaBytes); _renderer.SetWebTypefaceRegistry(NativeWebSceneApi.GetWebTypefaceRegistry(_engine)); } else _engine = NativeApi.Create(options.NativeCacheDirectory); diff --git a/tests/WebPlatformSubset/runner/ProfileModels.cs b/tests/WebPlatformSubset/runner/ProfileModels.cs index 3b4747e3b..424b8adde 100644 --- a/tests/WebPlatformSubset/runner/ProfileModels.cs +++ b/tests/WebPlatformSubset/runner/ProfileModels.cs @@ -207,5 +207,8 @@ internal sealed record RunnerOptions public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10); public string? NativeLibraryPath { get; init; } public string? NativeCacheDirectory { get; init; } + public string? NativeStorageDirectory { get; init; } + public string? NativeStoragePartitionKey { get; init; } + public ulong NativeStorageQuotaBytes { get; init; } public string? ChromiumPath { get; init; } } diff --git a/tests/WebPlatformSubset/runner/Program.cs b/tests/WebPlatformSubset/runner/Program.cs index 317a07da4..49bf55467 100644 --- a/tests/WebPlatformSubset/runner/Program.cs +++ b/tests/WebPlatformSubset/runner/Program.cs @@ -16,6 +16,9 @@ internal static RunnerOptions Parse(string[] args) var timeout = TimeSpan.FromSeconds(10); string? nativeLibraryPath = null; string? nativeCacheDirectory = null; + string? nativeStorageDirectory = null; + string? nativeStoragePartitionKey = null; + ulong nativeStorageQuotaBytes = 0; string? chromiumPath = null; for (var index = 0; index < args.Length; index++) @@ -47,6 +50,15 @@ internal static RunnerOptions Parse(string[] args) case "--native-cache-directory": nativeCacheDirectory = Path.GetFullPath(RequireValue(args, ref index)); break; + case "--native-storage-directory": + nativeStorageDirectory = Path.GetFullPath(RequireValue(args, ref index)); + break; + case "--native-storage-partition": + nativeStoragePartitionKey = RequireValue(args, ref index); + break; + case "--native-storage-quota-bytes": + nativeStorageQuotaBytes = ulong.Parse(RequireValue(args, ref index)); + break; case "--chromium-path": chromiumPath = Path.GetFullPath(RequireValue(args, ref index)); break; @@ -74,6 +86,9 @@ internal static RunnerOptions Parse(string[] args) Timeout = timeout, NativeLibraryPath = nativeLibraryPath, NativeCacheDirectory = nativeCacheDirectory, + NativeStorageDirectory = nativeStorageDirectory, + NativeStoragePartitionKey = nativeStoragePartitionKey, + NativeStorageQuotaBytes = nativeStorageQuotaBytes, ChromiumPath = chromiumPath }; } @@ -115,6 +130,9 @@ private static void PrintHelp() Console.WriteLine(" --timeout-seconds Per-document timeout (default: 10)"); Console.WriteLine(" --native-library Native engine library for native mode"); Console.WriteLine(" --native-cache-directory Native V8 compilation cache"); + Console.WriteLine(" --native-storage-directory Native durable browser storage root"); + Console.WriteLine(" --native-storage-partition Stable native storage profile key"); + Console.WriteLine(" --native-storage-quota-bytes Native storage profile quota"); Console.WriteLine(" --chromium-path Optional Chromium rendering oracle executable"); Console.WriteLine(" --output Artifact directory"); Console.WriteLine(" --manifest Profile manifest path"); diff --git a/tests/WebPlatformSubset/webscene-indexeddb-profile.json b/tests/WebPlatformSubset/webscene-indexeddb-profile.json new file mode 100644 index 000000000..e5ef64ac9 --- /dev/null +++ b/tests/WebPlatformSubset/webscene-indexeddb-profile.json @@ -0,0 +1,64 @@ +{ + "profile": "webscene-indexeddb-code-oss-1", + "wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853", + "runtime": "v8", + "viewport": { + "width": 800, + "height": 600, + "deviceScaleFactor": 1 + }, + "required": [], + "candidate": [ + { + "path": "contracts/indexeddb-code-oss-storage.html", + "type": "contract", + "capabilities": [ + "indexeddb-open-upgrade-versionchange", + "indexeddb-transaction-commit-rollback", + "indexeddb-cursor", + "indexeddb-structured-clone", + "indexeddb-request-error-cancellation", + "indexeddb-quota", + "indexeddb-close-reopen-persistence", + "indexeddb-engine-restart-persistence", + "indexeddb-corruption-recovery", + "indexeddb-cross-connection-conflict", + "indexeddb-cross-process-conflict", + "indexeddb-interrupted-write-recovery" + ], + "evidence": [ + "webscene_native_indexeddb_contract:test_indexeddb_persistence_and_transaction_contract", + "webscene_native_indexeddb_contract:test_indexeddb_quota_and_corruption_failures", + "webscene_indexeddb_storage_tests:test_cross_process_conflict", + "webscene_indexeddb_storage_tests:test_interrupted_write_and_stale_lock_recovery" + ], + "reason": "Project-owned WPT-style reduction of the exact versioned ItemTable flow used by VS Code OSS. Persistence/restart, quota, corruption recovery, and process-conflict behavior are gated by the paired native tests because they require host lifecycle or file-system control." + } + ], + "harnessBlocked": [ + { + "path": "IndexedDB/idbobjectstore-request-source.any.js", + "type": "testharness", + "capabilities": ["indexeddb-object-store-request"], + "reason": "Upstream candidate pending broader keyPath, autoIncrement, and IDL coverage." + }, + { + "path": "IndexedDB/idbcursor-continue.any.js", + "type": "testharness", + "capabilities": ["indexeddb-cursor"], + "reason": "Upstream candidate pending IDBKeyRange and complete IndexedDB key ordering." + }, + { + "path": "IndexedDB/transaction-abort-request-error.any.js", + "type": "testharness", + "capabilities": ["indexeddb-transaction-rollback"], + "reason": "Upstream candidate pending full transaction active-state timing and event propagation." + } + ], + "excluded": [ + { + "area": "IndexedDB indexes and key generators", + "reason": "This compatibility slice deliberately rejects indexes, keyPath stores, and autoIncrement until their algorithms and upstream WPT groups are implemented." + } + ] +} diff --git a/tests/WebScene.Sdk.Tests/CompatibilityCheckerTests.cs b/tests/WebScene.Sdk.Tests/CompatibilityCheckerTests.cs index 73956e7c6..69e8798d9 100644 --- a/tests/WebScene.Sdk.Tests/CompatibilityCheckerTests.cs +++ b/tests/WebScene.Sdk.Tests/CompatibilityCheckerTests.cs @@ -37,7 +37,7 @@ public void DeclaredHostCapabilitiesPassAndDirectNetworkingWarns() } [Fact] - public void AllowsInMemoryWebStorageButRejectsIndexedDbSpecifically() + public void IndexedDbRequiresItsPersistentStorageCapability() { var report = WebSceneCompatibilityChecker.Check( "localStorage.setItem('theme', 'dark');\n" + @@ -48,7 +48,18 @@ public void AllowsInMemoryWebStorageButRejectsIndexedDbSpecifically() var diagnostic = Assert.Single( report.Diagnostics, static value => value.Code == "WEBSCENE1002"); - Assert.Equal("IndexedDB is not supported.", diagnostic.Message); + Assert.Equal(WebSceneComponentCapabilities.IndexedDb, diagnostic.RequiredCapability); + Assert.Equal( + "Persistent IndexedDB access must be declared. Missing capability 'storage.indexeddb'.", + diagnostic.Message); Assert.Equal(3, diagnostic.Line); + + var declared = ComponentManifestTests.CreateManifest() with + { + Capabilities = [WebSceneComponentCapabilities.Dom, WebSceneComponentCapabilities.IndexedDb] + }; + Assert.DoesNotContain( + WebSceneCompatibilityChecker.Check("indexedDB.open('durable');", declared).Diagnostics, + static value => value.Code == "WEBSCENE1002"); } } diff --git a/tooling/webscene/compatibility.mjs b/tooling/webscene/compatibility.mjs index c7d9b282b..a48a48adc 100644 --- a/tooling/webscene/compatibility.mjs +++ b/tooling/webscene/compatibility.mjs @@ -4,13 +4,13 @@ export const profileVersion = '1.0'; export const knownCapabilities = Object.freeze([ 'dom', 'css.layout', 'canvas.2d', 'svg', 'input.pointer', 'input.keyboard', - 'input.focus', 'clipboard', 'host.commands', 'host.settings', + 'input.focus', 'clipboard', 'storage.indexeddb', 'host.commands', 'host.settings', 'host.notifications', 'host.network', 'host.clipboard', 'host.files' ]); const rules = [ unsupported(/\bnavigator\s*\.\s*serviceWorker\b/g, 'WEBSCENE1001', 'Service workers are not supported.'), - unsupported(/\bindexedDB\b/g, 'WEBSCENE1002', 'IndexedDB is not supported.'), + requires(/\bindexedDB\b/g, 'WEBSCENE1002', 'storage.indexeddb', 'Persistent IndexedDB access must be declared.'), unsupported(/\b(?:Worker|SharedWorker|Worklet)\s*\(/g, 'WEBSCENE1003', 'Web workers and worklets are not supported.'), unsupported(/\b(?:RTCPeerConnection|MediaRecorder|AudioContext|webkitAudioContext)\b/g, 'WEBSCENE1004', 'WebRTC, recording, and Web Audio are not supported.'), unsupported(/\bnavigator\s*\.\s*(?:mediaDevices|geolocation)\b/g, 'WEBSCENE1005', 'Media devices and geolocation are not supported.'), diff --git a/tooling/webscene/tests/compatibility.test.mjs b/tooling/webscene/tests/compatibility.test.mjs index 5ca02bdd2..79e2b7a28 100644 --- a/tooling/webscene/tests/compatibility.test.mjs +++ b/tooling/webscene/tests/compatibility.test.mjs @@ -17,3 +17,11 @@ test('unsupported and undeclared APIs produce stable diagnostics', () => { assert.deepEqual(diagnostics.map(item => item.code), ['WEBSCENE1003', 'WEBSCENE2007']); assert.equal(diagnostics[0].line, 2); }); + +test('IndexedDB requires the durable storage capability', () => { + const missing = checkSource('indexedDB.open("state")', manifest); + assert.equal(missing[0].code, 'WEBSCENE1002'); + assert.equal(missing[0].requiredCapability, 'storage.indexeddb'); + const declared = { ...manifest, capabilities: [...manifest.capabilities, 'storage.indexeddb'] }; + assert.deepEqual(checkSource('indexedDB.open("state")', declared), []); +}); From 76282b7de0421419045120956bfda42bba48e6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 16 Sep 2026 00:53:13 +0200 Subject: [PATCH 2/2] Expand IndexedDB structured clone regressions --- .../native_v8_runtime_indexeddb_tests.inc | 25 ++++++++++++++++--- .../contracts/indexeddb-code-oss-storage.html | 23 ++++++++++++++--- .../webscene-indexeddb-profile.json | 4 ++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc index 6ffb88dba..ccdb63600 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_indexeddb_tests.inc @@ -68,9 +68,17 @@ void test_indexeddb_persistence_and_transaction_contract() }); stage = 'write-v1'; const write = database.transaction('ItemTable', 'readwrite'); - write.objectStore('ItemTable').put( - { nested: { enabled: true }, list: [1, 2, 3], bytes: new Uint8Array([0, 7, 255]) }, - 'alpha'); + const source = { + nested: { enabled: true }, + list: [1, 2, 3], + bytes: new Uint8Array([0, 7, 255]), + date: new Date(456), + map: new Map([['theme', 'dark']]) + }; + write.objectStore('ItemTable').put(source, 'alpha'); + source.nested.enabled = false; + source.bytes[1] = 99; + source.map.set('theme', 'mutated'); write.objectStore('ItemTable').put('second', 'beta'); await transactionDone(write); @@ -130,7 +138,12 @@ void test_indexeddb_persistence_and_transaction_contract() "&&indexedDbProbe.cursorValues.length===2" "&&indexedDbProbe.cursorValues[0][0]==='alpha'" "&&indexedDbProbe.cursorValues[0][1].nested.enabled" - "&&indexedDbProbe.cursorValues[0][1].bytes[2]===255", + "&&indexedDbProbe.cursorValues[0][1].bytes[2]===255" + "&&indexedDbProbe.cursorValues[0][1].bytes[1]===7" + "&&indexedDbProbe.cursorValues[0][1].date instanceof Date" + "&&indexedDbProbe.cursorValues[0][1].date.getTime()===456" + "&&indexedDbProbe.cursorValues[0][1].map instanceof Map" + "&&indexedDbProbe.cursorValues[0][1].map.get('theme')==='dark'", "indexeddb-runtime-result.js") == "true", "upgrade, rollback, structured clone, or cursor behavior changed"); webscene_engine_destroy(engine); @@ -155,6 +168,10 @@ void test_indexeddb_persistence_and_transaction_contract() restored: request.result.nested.enabled && request.result.list.join(',') === '1,2,3' && request.result.bytes[1] === 7 + && request.result.date instanceof Date + && request.result.date.getTime() === 456 + && request.result.map instanceof Map + && request.result.map.get('theme') === 'dark' }; database.close(); resolve(); diff --git a/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html b/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html index d362b28e8..1a992cc81 100644 --- a/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html +++ b/tests/WebPlatformSubset/contracts/indexeddb-code-oss-storage.html @@ -38,7 +38,16 @@ record("created object store is observable", database.objectStoreNames.contains("ItemTable")); const write = database.transaction("ItemTable", "readwrite"); - write.objectStore("ItemTable").put({ value: 1, bytes: new Uint8Array([3, 5, 8]) }, "alpha"); + const source = { + value: 1, + bytes: new Uint8Array([3, 5, 8]), + date: new Date(456), + map: new Map([["theme", "dark"]]) + }; + write.objectStore("ItemTable").put(source, "alpha"); + source.value = 99; + source.bytes[1] = 99; + source.map.set("theme", "mutated"); write.objectStore("ItemTable").put({ value: 2 }, "beta"); await transactionPromise(write); @@ -54,7 +63,13 @@ const rolledBack = await requestPromise(rollbackRead.objectStore("ItemTable").get("alpha")); await transactionPromise(rollbackRead); record("aborted transaction rolls back writes", rolledBack.value === 1); - record("structured values and typed arrays round trip", rolledBack.bytes[2] === 8); + record("put snapshots structured values before the caller mutates them", + rolledBack.value === 1 && rolledBack.bytes[1] === 5); + record("Date and Map structured values round trip", + rolledBack.date instanceof Date && rolledBack.date.getTime() === 456 && + rolledBack.map instanceof Map && rolledBack.map.get("theme") === "dark"); + rolledBack.bytes[1] = 42; + rolledBack.map.set("theme", "read-mutation"); const cursorRead = database.transaction("ItemTable", "readonly"); const keys = []; @@ -96,7 +111,9 @@ const persisted = await requestPromise(persistedRead.objectStore("ItemTable").get("alpha")); await transactionPromise(persistedRead); record("close and reopen reloads the durable snapshot", - persisted.value === 1 && persisted.bytes[1] === 5); + persisted.value === 1 && persisted.bytes[1] === 5 && + persisted.date instanceof Date && persisted.date.getTime() === 456 && + persisted.map instanceof Map && persisted.map.get("theme") === "dark"); const second = await open(); const firstWrite = first.transaction("ItemTable", "readwrite"); diff --git a/tests/WebPlatformSubset/webscene-indexeddb-profile.json b/tests/WebPlatformSubset/webscene-indexeddb-profile.json index e5ef64ac9..20a14bac6 100644 --- a/tests/WebPlatformSubset/webscene-indexeddb-profile.json +++ b/tests/WebPlatformSubset/webscene-indexeddb-profile.json @@ -17,6 +17,8 @@ "indexeddb-transaction-commit-rollback", "indexeddb-cursor", "indexeddb-structured-clone", + "indexeddb-put-snapshot-isolation", + "indexeddb-date-map-clone", "indexeddb-request-error-cancellation", "indexeddb-quota", "indexeddb-close-reopen-persistence", @@ -32,7 +34,7 @@ "webscene_indexeddb_storage_tests:test_cross_process_conflict", "webscene_indexeddb_storage_tests:test_interrupted_write_and_stale_lock_recovery" ], - "reason": "Project-owned WPT-style reduction of the exact versioned ItemTable flow used by VS Code OSS. Persistence/restart, quota, corruption recovery, and process-conflict behavior are gated by the paired native tests because they require host lifecycle or file-system control." + "reason": "Project-owned WPT-style reduction of the exact versioned ItemTable flow used by VS Code OSS. It verifies synchronous put snapshot isolation plus typed-array, Date, and Map restoration. Persistence/restart, quota, corruption recovery, and process-conflict behavior are gated by the paired native tests because they require host lifecycle or file-system control." } ], "harnessBlocked": [