From 96966f9155bca7ef593851aa2b1c0bb01e79ea37 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Mon, 7 Sep 2026 12:54:22 -0400 Subject: [PATCH 1/5] src: throw on a malformed localStorage file The localStorage backing file is a user-specified path, and the schema is created with CREATE TABLE IF NOT EXISTS, so a file that already contains tables of those names is adopted as-is. Its stored values may then have any SQLite type, but every read asserted the expected type with CHECK, so a wrong-typed value aborted the process. A bad schema_version was the worst case: that assertion is in Storage::Open(), so any access aborted and the application had no chance to inspect or repair the file. Report these as ERR_INVALID_STATE instead, matching the throw four lines below the schema_version assertion for a version that is too new. Storage::GetAll() has no JavaScript caller to throw at, so it returns std::nullopt and the DOM storage inspector agent reports a protocol error. Now that a failed open returns instead of aborting, Open() has to clean up after itself: adopt the sqlite3* into a conn_unique_ptr immediately, so that an error does not leak the connection and leave the next access to open another one. Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and the status its row loop ended on, reporting a malformed file or a mid-scan error as an empty store. Both now return std::nullopt. Also drop a redundant second sqlite3_exec() of the init SQL that clobbered the result of the sqlite3_prepare_v2() above it, hiding prepare failures behind a misleading "bad parameter or other API misuse". Signed-off-by: Trevor Burnham Assisted-by: Claude Opus 5 --- src/inspector/dom_storage_agent.cc | 4 + src/node_webstorage.cc | 73 ++++++++++++-- src/node_webstorage.h | 8 +- test/parallel/test-webstorage.js | 155 ++++++++++++++++++++++++++++- 4 files changed, 224 insertions(+), 16 deletions(-) diff --git a/src/inspector/dom_storage_agent.cc b/src/inspector/dom_storage_agent.cc index caf7ca98f7d3..9835a0a31252 100644 --- a/src/inspector/dom_storage_agent.cc +++ b/src/inspector/dom_storage_agent.cc @@ -106,6 +106,10 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems( "Could not read DOM storage items"); } storage_map_fallback = web_storage_obj.value()->GetAll(); + if (!storage_map_fallback.has_value()) { + return protocol::DispatchResponse::ServerError( + "Could not read DOM storage items"); + } storage_map = &storage_map_fallback.value(); } diff --git a/src/node_webstorage.cc b/src/node_webstorage.cc index 21f846fbeb62..d30dac025445 100644 --- a/src/node_webstorage.cc +++ b/src/node_webstorage.cc @@ -59,6 +59,19 @@ using v8::Value; } \ } while (0) +// The backing file is a user-specified path, and the schema below is created +// with IF NOT EXISTS, so a file that already holds tables of those names is +// adopted as-is and its values may have any type. A wrong type is therefore a +// statement about untrusted input, not a broken internal invariant. +#define CHECK_COLUMN_TYPE_OR_THROW(env, stmt, idx, expected, detail, ret) \ + do { \ + if (sqlite3_column_type((stmt), (idx)) != (expected)) { \ + THROW_ERR_INVALID_STATE((env), \ + "localStorage database is malformed: " detail); \ + return (ret); \ + } \ + } while (0) + static void ThrowQuotaExceededException(Local context) { Isolate* isolate = Isolate::GetCurrent(); auto quota_exceeded_str = @@ -173,6 +186,12 @@ Maybe Storage::Open() { } int r = sqlite3_open(location_.c_str(), &db); + // Adopt the connection before anything below can return early, so that a + // failure does not leak it. sqlite3_open() allocates a connection to be + // closed even when it fails. This is declared ahead of the statement below + // so that the statement is finalized first; sqlite3_close() fails while a + // statement is still open, and conn_deleter treats that as fatal. + auto conn = conn_unique_ptr(db); CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing()); r = sqlite3_exec(db, init_sql_v0.data(), nullptr, nullptr, nullptr); CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing()); @@ -184,12 +203,16 @@ Maybe Storage::Open() { get_schema_version_sql.size(), &s, nullptr); - r = sqlite3_exec(db, init_sql_v0.data(), nullptr, nullptr, nullptr); - CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing()); auto stmt = stmt_unique_ptr(s); + CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing()); CHECK_ERROR_OR_THROW( env(), sqlite3_step(stmt.get()), SQLITE_ROW, Nothing()); - CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_INTEGER); + CHECK_COLUMN_TYPE_OR_THROW(env(), + stmt.get(), + 0, + SQLITE_INTEGER, + "expected schema_version to be an integer", + Nothing()); int schema_version = sqlite3_column_int(stmt.get(), 0); stmt = nullptr; // Force finalization. @@ -209,7 +232,7 @@ Maybe Storage::Open() { CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Nothing()); } - db_ = conn_unique_ptr(db); + db_ = std::move(conn); return JustVoid(); } @@ -266,7 +289,12 @@ MaybeLocal Storage::Enumerate() { LocalVector values(env()->isolate()); Local value; while ((r = sqlite3_step(stmt.get())) == SQLITE_ROW) { - CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB); + CHECK_COLUMN_TYPE_OR_THROW(env(), + stmt.get(), + 0, + SQLITE_BLOB, + "expected key to be a blob", + Local()); auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t); if (!String::NewFromTwoByte(env()->isolate(), reinterpret_cast( @@ -282,9 +310,10 @@ MaybeLocal Storage::Enumerate() { return Array::New(env()->isolate(), values.data(), values.size()); } -std::unordered_map Storage::GetAll() { +std::optional> +Storage::GetAll() { if (!Open().IsJust()) { - return {}; + return std::nullopt; } static constexpr std::string_view sql = @@ -292,10 +321,17 @@ std::unordered_map Storage::GetAll() { sqlite3_stmt* s = nullptr; int r = sqlite3_prepare_v2(db_.get(), sql.data(), sql.size(), &s, nullptr); auto stmt = stmt_unique_ptr(s); + // Unlike the other accessors, this one has no JavaScript caller to throw at, + // so every failure below is reported to the inspector agent instead. + if (r != SQLITE_OK) { + return std::nullopt; + } std::unordered_map result; while ((r = sqlite3_step(stmt.get())) == SQLITE_ROW) { - CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB); - CHECK(sqlite3_column_type(stmt.get(), 1) == SQLITE_BLOB); + if (sqlite3_column_type(stmt.get(), 0) != SQLITE_BLOB || + sqlite3_column_type(stmt.get(), 1) != SQLITE_BLOB) { + return std::nullopt; + } auto key_size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t); auto value_size = sqlite3_column_bytes(stmt.get(), 1) / sizeof(uint16_t); auto key_uint16( @@ -308,6 +344,9 @@ std::unordered_map Storage::GetAll() { result.emplace(std::move(key), std::move(value)); } + if (r != SQLITE_DONE) { + return std::nullopt; + } return result; } @@ -324,6 +363,8 @@ MaybeLocal Storage::Length() { auto stmt = stmt_unique_ptr(s); CHECK_ERROR_OR_THROW( env(), sqlite3_step(stmt.get()), SQLITE_ROW, Local()); + // Unlike the reads above, this one is not a claim about the file's contents: + // count(*) is an integer whatever the table holds. CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_INTEGER); int result = sqlite3_column_int(stmt.get(), 0); return Integer::New(env()->isolate(), result); @@ -351,7 +392,12 @@ MaybeLocal Storage::Load(Local key) { CHECK_ERROR_OR_THROW(env(), r, SQLITE_OK, Local()); r = sqlite3_step(stmt.get()); if (r == SQLITE_ROW) { - CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB); + CHECK_COLUMN_TYPE_OR_THROW(env(), + stmt.get(), + 0, + SQLITE_BLOB, + "expected value to be a blob", + Local()); auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t); return String::NewFromTwoByte(env()->isolate(), reinterpret_cast( @@ -383,7 +429,12 @@ MaybeLocal Storage::LoadKey(const int index) { r = sqlite3_step(stmt.get()); if (r == SQLITE_ROW) { - CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB); + CHECK_COLUMN_TYPE_OR_THROW(env(), + stmt.get(), + 0, + SQLITE_BLOB, + "expected key to be a blob", + Local()); auto size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t); return String::NewFromTwoByte(env()->isolate(), reinterpret_cast( diff --git a/src/node_webstorage.h b/src/node_webstorage.h index 938a2333194b..02de9c79b84c 100644 --- a/src/node_webstorage.h +++ b/src/node_webstorage.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include #include #include "base_object.h" #include "node_mem.h" @@ -41,7 +42,12 @@ class Storage : public BaseObject { v8::MaybeLocal LoadKey(const int index); v8::Maybe Remove(v8::Local key); v8::Maybe Store(v8::Local key, v8::Local value); - std::unordered_map GetAll(); + // Returns nothing if the backing store could not be read, e.g. because it + // holds values of an unexpected type. Opening the store can also throw, so + // the caller must hold a v8::TryCatch: an empty return does not say which of + // the two happened, and a pending exception is left for the caller to + // handle. + std::optional> GetAll(); SET_MEMORY_INFO_NAME(Storage) SET_SELF_SIZE(Storage) diff --git a/test/parallel/test-webstorage.js b/test/parallel/test-webstorage.js index 383e239d7d68..106a969cb2fe 100644 --- a/test/parallel/test-webstorage.js +++ b/test/parallel/test-webstorage.js @@ -1,11 +1,14 @@ 'use strict'; -const { skipIfSQLiteMissing, spawnPromisified } = require('../common'); +const { + isLinux, isMacOS, skipIfSQLiteMissing, spawnPromisified, +} = require('../common'); skipIfSQLiteMissing(); const tmpdir = require('../common/tmpdir'); const assert = require('node:assert'); const { join } = require('node:path'); const { readdir } = require('node:fs/promises'); +const { DatabaseSync } = require('node:sqlite'); const { test, describe } = require('node:test'); let cnt = 0; @@ -15,6 +18,16 @@ function nextLocalStorage() { return join(tmpdir.path, `${++cnt}.localstorage`); } +// The tests below assert on which .localstorage files exist, so malformed +// fixtures are named so as not to be counted among them. +function nextMalformedLocalStorage() { + return join(tmpdir.path, `malformed-${++cnt}.db`); +} + +async function localStorageFiles() { + return (await readdir(tmpdir.path)).filter((f) => f.endsWith('.localstorage')); +} + test('Storage instances cannot be created in userland', async () => { const cp = await spawnPromisified(process.execPath, [ '-e', 'new globalThis.Storage()', @@ -46,7 +59,7 @@ test('sessionStorage is not persisted', async () => { ]); assert.strictEqual(cp.code, 0); assert.match(cp.stdout, /undefined/); - assert.strictEqual((await readdir(tmpdir.path)).length, 0); + assert.deepStrictEqual(await localStorageFiles(), []); }); test('localStorage returns undefined and warns without --localstorage-file', async () => { @@ -74,7 +87,7 @@ test('localStorage is not persisted if it is unused', async () => { ]); assert.strictEqual(cp.code, 0); assert.match(cp.stdout, /true/); - assert.strictEqual((await readdir(tmpdir.path)).length, 0); + assert.deepStrictEqual(await localStorageFiles(), []); }); test('localStorage is persisted if it is used', async () => { @@ -85,7 +98,7 @@ test('localStorage is persisted if it is used', async () => { ]); assert.strictEqual(cp.code, 0); assert.match(cp.stdout, /barbaz/); - const entries = await readdir(tmpdir.path); + const entries = await localStorageFiles(); assert.strictEqual(entries.length, 1); assert.match(entries[0], /\d+\.localstorage/); @@ -146,3 +159,137 @@ test('disabled with --no-webstorage', async () => { assert(cp.stderr.includes(`ReferenceError: ${api} is not defined`)); } }); + +describe('a malformed localStorage file throws instead of aborting', () => { + // Node's own tables are STRICT, so it cannot store a wrong-typed value + // itself. But they are created with IF NOT EXISTS, so a file that already + // contains tables of those names is adopted as-is. Declare the same schema + // without STRICT: BLOB columns have no affinity, so TEXT stays TEXT. + function malformedLocalStorage(fill) { + const file = nextMalformedLocalStorage(); + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE nodejs_webstorage( + key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY(key) + ); + CREATE TABLE nodejs_webstorage_state( + max_size INTEGER NOT NULL DEFAULT 10485760, + total_size INTEGER NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1), + PRIMARY KEY(single_row_) + ); + `); + fill({ + insert: (key, value) => db.prepare( + 'INSERT INTO nodejs_webstorage (key, value) VALUES (?, ?)', + ).run(key, value), + setSchemaVersion: (schemaVersion) => db.prepare( + 'INSERT INTO nodejs_webstorage_state (total_size, schema_version)' + + ' VALUES (0, ?)', + ).run(schemaVersion), + }); + db.close(); + return file; + } + + // Keys are stored UTF-16LE, so a real key is needed for lookups to match. + const utf16 = (str) => Buffer.from(str, 'utf16le'); + + for (const [name, fill, expression, detail] of [ + [ + 'a text schema_version', + ({ setSchemaVersion }) => setSchemaVersion('one'), + 'localStorage.length', + 'expected schema_version to be an integer', + ], + [ + 'a text key read by key()', + ({ insert, setSchemaVersion }) => { + insert('greeting', utf16('hello')); + setSchemaVersion(1); + }, + 'localStorage.key(0)', + 'expected key to be a blob', + ], + [ + 'a text key read by enumeration', + ({ insert, setSchemaVersion }) => { + insert('greeting', utf16('hello')); + setSchemaVersion(1); + }, + 'Object.keys(localStorage)', + 'expected key to be a blob', + ], + [ + 'a text value', + ({ insert, setSchemaVersion }) => { + insert(utf16('greeting'), 'hello'); + setSchemaVersion(1); + }, + "localStorage.getItem('greeting')", + 'expected value to be a blob', + ], + ]) { + test(`${name}, via ${expression}`, async () => { + const cp = await spawnPromisified(process.execPath, [ + '--localstorage-file', malformedLocalStorage(fill), + '-e', expression, + ]); + + assert.strictEqual(cp.code, 1); + assert.strictEqual(cp.signal, null); + assert(cp.stderr.includes( + `Error: localStorage database is malformed: ${detail}`, + )); + assert(cp.stderr.includes("code: 'ERR_INVALID_STATE'")); + }); + } +}); + +test('a malformed localStorage file does not leak connections', { + // Counting the process's own descriptors needs a /proc/self/fd or /dev/fd + // that lists all of them. AIX and IBM i expose only 0, 1 and 2 there, which + // would make the count constant and the test vacuous. + skip: (!isLinux && !isMacOS) && 'cannot enumerate open descriptors', +}, async () => { + const file = nextMalformedLocalStorage(); + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE nodejs_webstorage_state( + max_size INTEGER NOT NULL DEFAULT 10485760, + total_size INTEGER NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1), + PRIMARY KEY(single_row_) + ); + `); + db.prepare('INSERT INTO nodejs_webstorage_state (total_size, schema_version)' + + ' VALUES (0, ?)').run('one'); + db.close(); + + // A failed open used to leave its sqlite3* behind, two descriptors at a time, + // so repeated access exhausted the descriptor limit and degraded the error + // into a misleading "unable to open database file". + const cp = await spawnPromisified(process.execPath, [ + '--localstorage-file', file, + '-e', ` + const assert = require('assert'); + const { readdirSync } = require('fs'); + const fdDir = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd'; + const openDescriptors = () => readdirSync(fdDir).length; + const attempt = () => assert.throws(() => localStorage.length, { + code: 'ERR_INVALID_STATE', + message: /expected schema_version to be an integer/, + }); + + attempt(); + const before = openDescriptors(); + for (let i = 0; i < 200; i++) attempt(); + const leaked = openDescriptors() - before; + assert.ok(leaked < 20, 'leaked ' + leaked + ' descriptors'); + `, + ]); + assert.strictEqual(cp.code, 0, cp.stderr); + assert.strictEqual(cp.stdout, ''); +}); From 1812f50945939167923d08c9e854db9ed075a5dd Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Mon, 7 Sep 2026 12:54:34 -0400 Subject: [PATCH 2/5] inspector: catch errors reading DOM storage A protocol message from a remote frontend is dispatched from a libuv callback with no HandleScope on the stack, inside the SealHandleScope that MainThreadInterface::DispatchMessages() installs. Opening the localStorage backing file can throw, so allocating the error object was fatal: FATAL ERROR: v8::HandleScope::CreateHandle() Cannot create a handle without a HandleScope Every Storage::Open() failure was affected, including a --localstorage-file that names a directory, so this did not need a malformed file to reach. getWebStorage() already opens a HandleScope and a TryCatch for its own handle use; do the same around the GetAll() call and report the failure as a protocol error. Signed-off-by: Trevor Burnham Assisted-by: Claude Opus 5 --- src/inspector/dom_storage_agent.cc | 21 ++++ .../test-inspector-dom-storage-malformed.js | 95 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/parallel/test-inspector-dom-storage-malformed.js diff --git a/src/inspector/dom_storage_agent.cc b/src/inspector/dom_storage_agent.cc index 9835a0a31252..a7de97d5341d 100644 --- a/src/inspector/dom_storage_agent.cc +++ b/src/inspector/dom_storage_agent.cc @@ -105,7 +105,28 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems( return protocol::DispatchResponse::ServerError( "Could not read DOM storage items"); } + // A message from a remote frontend is dispatched without a HandleScope + // on the stack, and opening the backing file can throw, so give the + // exception a scope to be allocated in and somewhere to land. + v8::HandleScope handle_scope(env_->isolate()); + v8::TryCatch try_catch(env_->isolate()); storage_map_fallback = web_storage_obj.value()->GetAll(); + if (try_catch.HasCaught()) { + // Pass the reason along; "the file was written by a newer Node.js" and + // "the file is locked" are not the same problem to the user. Read it + // off the Message, which was built when the exception was thrown. + // Converting the exception itself would call a user-patchable + // Error.prototype.toString, and there is no JavaScript frame here to + // run it from. + Local message = try_catch.Message(); + if (!message.IsEmpty()) { + Utf8Value reason(env_->isolate(), message->Get()); + return protocol::DispatchResponse::ServerError( + std::string("Could not read DOM storage items: ") + reason.out()); + } + return protocol::DispatchResponse::ServerError( + "Could not read DOM storage items"); + } if (!storage_map_fallback.has_value()) { return protocol::DispatchResponse::ServerError( "Could not read DOM storage items"); diff --git a/test/parallel/test-inspector-dom-storage-malformed.js b/test/parallel/test-inspector-dom-storage-malformed.js new file mode 100644 index 000000000000..525adb7380a1 --- /dev/null +++ b/test/parallel/test-inspector-dom-storage-malformed.js @@ -0,0 +1,95 @@ +// Reading a malformed localStorage file through the DOMStorage domain should +// report a protocol error rather than abort the process. A message from a +// remote frontend is dispatched without a HandleScope on the stack, so this +// drives the protocol over the WebSocket endpoint rather than through an +// in-process inspector Session. +'use strict'; + +const common = require('../common'); +common.skipIfSQLiteMissing(); +common.skipIfInspectorDisabled(); +const { NodeInstance } = require('../common/inspector-helper.js'); +const tmpdir = require('../common/tmpdir'); +const assert = require('node:assert'); +const { join } = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +tmpdir.refresh(); + +// Node's own tables are STRICT, but they are created with IF NOT EXISTS, so a +// file that already contains tables of those names is adopted as-is. Declare +// the same schema without STRICT: BLOB columns have no affinity, so a TEXT +// value stays TEXT. +function malformedLocalStorage(name, schemaVersion, value) { + const file = join(tmpdir.path, name); + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE nodejs_webstorage( + key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY(key) + ); + CREATE TABLE nodejs_webstorage_state( + max_size INTEGER NOT NULL DEFAULT 10485760, + total_size INTEGER NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1), + PRIMARY KEY(single_row_) + ); + `); + db.prepare('INSERT INTO nodejs_webstorage (key, value) VALUES (?, ?)') + .run(Buffer.from('greeting', 'utf16le'), value); + db.prepare('INSERT INTO nodejs_webstorage_state (total_size, schema_version)' + + ' VALUES (0, ?)').run(schemaVersion); + db.close(); + return file; +} + +async function getDOMStorageItems(localStorageFile) { + const instance = new NodeInstance([ + '--inspect=0', + '--experimental-storage-inspection', + `--localstorage-file=${localStorageFile}`, + ], 'setInterval(() => {}, 1000);'); + + const session = await instance.connectInspectorSession(); + await session.send({ method: 'DOMStorage.enable' }); + const { storageKey } = await session.send({ + method: 'Storage.getStorageKey', + }); + + try { + return await session.send({ + method: 'DOMStorage.getDOMStorageItems', + params: { + storageId: { isLocalStorage: true, securityOrigin: '', storageKey }, + }, + }); + } finally { + await session.disconnect(); + await instance.kill(); + } +} + +(async () => { + // A wrong-typed value is rejected by Storage::GetAll() itself, which has no + // exception to report, so the reason is not available. + await assert.rejects( + getDOMStorageItems( + malformedLocalStorage('bad-value.db', 1, 'hello')), + { message: 'Could not read DOM storage items' }, + ); + + // A wrong-typed schema_version makes Storage::Open() throw, which has to be + // caught rather than left pending on an isolate with no JavaScript running. + // Its message reaches the frontend. + await assert.rejects( + getDOMStorageItems( + malformedLocalStorage( + 'bad-schema-version.db', 'one', Buffer.from('hello', 'utf16le'))), + { + // The reason comes off the v8::Message, hence the "Uncaught" prefix; + // converting the exception itself would run user JavaScript. + message: 'Could not read DOM storage items: Uncaught Error: ' + + 'localStorage database is malformed: expected schema_version to be ' + + 'an integer', + }, + ); +})().then(common.mustCall()); From 4a1eb6fef33539aef45ad8436fd51923bad83dcf Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Tue, 22 Sep 2026 12:43:22 -0400 Subject: [PATCH 3/5] fixup! inspector: catch errors reading DOM storage The test asserted that the reason a store could not be read always reaches the frontend, which the code does not promise. The reason is read off the v8::Message, and V8 only builds one on a best-effort basis: Isolate::Throw() skips it while the bootstrapper is active, and PropagateExceptionToExternalTryCatch() does not hand it to an external TryCatch when a JavaScript handler is the topmost one. The agent already falls back to a message without a reason for that case. The assertion held at -j1 and failed under -j 4 on macOS, in the step that re-runs the suite from a directory with unusual characters. The directory is incidental; that step is the only one that runs tests in parallel. Accept either message, both of which prove that Open() threw and was caught. Reading a store can now fail in four ways that a frontend could not tell apart, so report a different reason for each. This changes the message added in 37305e1128f for an unavailable store, which its test matches with a regular expression. Signed-off-by: Trevor Burnham Assisted-by: Claude Opus 5 --- src/inspector/dom_storage_agent.cc | 12 +++++++--- .../test-inspector-dom-storage-malformed.js | 23 +++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/inspector/dom_storage_agent.cc b/src/inspector/dom_storage_agent.cc index a7de97d5341d..7d3ce9dc5d06 100644 --- a/src/inspector/dom_storage_agent.cc +++ b/src/inspector/dom_storage_agent.cc @@ -101,9 +101,12 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems( std::optional storage_map_fallback; if (storage_map->empty()) { auto web_storage_obj = getWebStorage(is_local_storage); + // Each way of failing below says something different about the store, so + // each reports a different reason. A frontend that cannot read a store + // otherwise has no way to tell a missing one from a corrupt one. if (!web_storage_obj) { return protocol::DispatchResponse::ServerError( - "Could not read DOM storage items"); + "Could not read DOM storage items: storage is unavailable"); } // A message from a remote frontend is dispatched without a HandleScope // on the stack, and opening the backing file can throw, so give the @@ -124,12 +127,15 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems( return protocol::DispatchResponse::ServerError( std::string("Could not read DOM storage items: ") + reason.out()); } + // V8 builds that Message on a best-effort basis, so the throw is all we + // can report when it is missing. return protocol::DispatchResponse::ServerError( - "Could not read DOM storage items"); + "Could not read DOM storage items: the backing store could not be " + "opened"); } if (!storage_map_fallback.has_value()) { return protocol::DispatchResponse::ServerError( - "Could not read DOM storage items"); + "Could not read DOM storage items: the backing file is malformed"); } storage_map = &storage_map_fallback.value(); } diff --git a/test/parallel/test-inspector-dom-storage-malformed.js b/test/parallel/test-inspector-dom-storage-malformed.js index 525adb7380a1..0eed2405dd33 100644 --- a/test/parallel/test-inspector-dom-storage-malformed.js +++ b/test/parallel/test-inspector-dom-storage-malformed.js @@ -69,27 +69,32 @@ async function getDOMStorageItems(localStorageFile) { } (async () => { - // A wrong-typed value is rejected by Storage::GetAll() itself, which has no - // exception to report, so the reason is not available. + // A wrong-typed value is rejected by Storage::GetAll() itself, which opens + // the file successfully and has no exception to report. await assert.rejects( getDOMStorageItems( malformedLocalStorage('bad-value.db', 1, 'hello')), - { message: 'Could not read DOM storage items' }, + { message: 'Could not read DOM storage items: the backing file is malformed' }, ); // A wrong-typed schema_version makes Storage::Open() throw, which has to be // caught rather than left pending on an isolate with no JavaScript running. - // Its message reaches the frontend. + // + // Which of the two messages below comes back is not something this test can + // pin down. The reason is read off the v8::Message, hence the "Uncaught" + // prefix, but V8 only builds one on a best-effort basis: Isolate::Throw() + // skips it while the bootstrapper is active, and it is not handed to an + // external TryCatch when a JavaScript handler is the topmost one. Both + // outcomes prove the point, which is that Open() threw and was caught. await assert.rejects( getDOMStorageItems( malformedLocalStorage( 'bad-schema-version.db', 'one', Buffer.from('hello', 'utf16le'))), { - // The reason comes off the v8::Message, hence the "Uncaught" prefix; - // converting the exception itself would run user JavaScript. - message: 'Could not read DOM storage items: Uncaught Error: ' + - 'localStorage database is malformed: expected schema_version to be ' + - 'an integer', + message: new RegExp('^Could not read DOM storage items: (?:' + + 'Uncaught Error: localStorage database is malformed: expected ' + + 'schema_version to be an integer' + + '|the backing store could not be opened)$'), }, ); })().then(common.mustCall()); From e9c2808aedc625e218f5530ce510b5dcc5090616 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 24 Sep 2026 17:17:36 -0400 Subject: [PATCH 4/5] fixup! src: throw on a malformed localStorage file Keys are stored as a blob of raw uint16_t memory, so their byte order is the platform's, but the test fixture wrote them with Buffer's utf16le encoding. On AIX and s390x the stored key therefore did not match the one that localStorage.getItem() binds, so the row was not found, no value was type-checked, and the child exited 0 instead of throwing. Encode fixture keys in the platform's byte order. Signed-off-by: Trevor Burnham Assisted-by: Claude Opus 5 --- test/parallel/test-webstorage.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-webstorage.js b/test/parallel/test-webstorage.js index 106a969cb2fe..d8ef0819b5e1 100644 --- a/test/parallel/test-webstorage.js +++ b/test/parallel/test-webstorage.js @@ -8,6 +8,7 @@ const tmpdir = require('../common/tmpdir'); const assert = require('node:assert'); const { join } = require('node:path'); const { readdir } = require('node:fs/promises'); +const { endianness } = require('node:os'); const { DatabaseSync } = require('node:sqlite'); const { test, describe } = require('node:test'); let cnt = 0; @@ -193,8 +194,12 @@ describe('a malformed localStorage file throws instead of aborting', () => { return file; } - // Keys are stored UTF-16LE, so a real key is needed for lookups to match. - const utf16 = (str) => Buffer.from(str, 'utf16le'); + // Keys are stored as UTF-16 code units in the platform's byte order, so a + // key only matches a lookup if it is encoded the same way. + const utf16 = (str) => { + const buf = Buffer.from(str, 'utf16le'); + return endianness() === 'BE' ? buf.swap16() : buf; + }; for (const [name, fill, expression, detail] of [ [ From d4e2d89abb21a9433e019c838ce4c7c1cc05ec1a Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Fri, 25 Sep 2026 09:12:14 -0400 Subject: [PATCH 5/5] fixup! inspector: catch errors reading DOM storage The inspector accepts connections before pre-execution defines globalThis.localStorage, and messages reach the main thread through an interrupt that can run during that startup JavaScript. A command that arrived first found no Storage object and reported the store as unavailable. The Storage.getStorageKey round trip usually hid this, but on Windows it failed every time. Wait for the child's script to start before sending commands. This race, not a missing v8::Message, is the likely cause of the macOS failure that the previous fixup loosened the schema_version assertion for: that run reported the message then used for an unavailable store. Restore the exact assertion. Signed-off-by: Trevor Burnham Assisted-by: Claude Opus 5.5 --- .../test-inspector-dom-storage-malformed.js | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/test/parallel/test-inspector-dom-storage-malformed.js b/test/parallel/test-inspector-dom-storage-malformed.js index 0eed2405dd33..d9ac2bbfd0be 100644 --- a/test/parallel/test-inspector-dom-storage-malformed.js +++ b/test/parallel/test-inspector-dom-storage-malformed.js @@ -11,6 +11,7 @@ common.skipIfInspectorDisabled(); const { NodeInstance } = require('../common/inspector-helper.js'); const tmpdir = require('../common/tmpdir'); const assert = require('node:assert'); +const { once } = require('node:events'); const { join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); tmpdir.refresh(); @@ -47,9 +48,14 @@ async function getDOMStorageItems(localStorageFile) { '--inspect=0', '--experimental-storage-inspection', `--localstorage-file=${localStorageFile}`, - ], 'setInterval(() => {}, 1000);'); + ], 'console.log("ready"); setInterval(() => {}, 1000);'); + // The inspector accepts connections before pre-execution defines + // globalThis.localStorage, and a command that arrives first reports the + // store as unavailable. + const ready = once(instance, 'stdout'); const session = await instance.connectInspectorSession(); + await ready; await session.send({ method: 'DOMStorage.enable' }); const { storageKey } = await session.send({ method: 'Storage.getStorageKey', @@ -79,22 +85,17 @@ async function getDOMStorageItems(localStorageFile) { // A wrong-typed schema_version makes Storage::Open() throw, which has to be // caught rather than left pending on an isolate with no JavaScript running. - // - // Which of the two messages below comes back is not something this test can - // pin down. The reason is read off the v8::Message, hence the "Uncaught" - // prefix, but V8 only builds one on a best-effort basis: Isolate::Throw() - // skips it while the bootstrapper is active, and it is not handed to an - // external TryCatch when a JavaScript handler is the topmost one. Both - // outcomes prove the point, which is that Open() threw and was caught. + // Its message reaches the frontend. await assert.rejects( getDOMStorageItems( malformedLocalStorage( 'bad-schema-version.db', 'one', Buffer.from('hello', 'utf16le'))), { - message: new RegExp('^Could not read DOM storage items: (?:' + - 'Uncaught Error: localStorage database is malformed: expected ' + - 'schema_version to be an integer' + - '|the backing store could not be opened)$'), + // The reason comes off the v8::Message, hence the "Uncaught" prefix; + // converting the exception itself would run user JavaScript. + message: 'Could not read DOM storage items: Uncaught Error: ' + + 'localStorage database is malformed: expected schema_version to be ' + + 'an integer', }, ); })().then(common.mustCall());