From 1844c7612c6db99df1e42f2f87875dcf7773d449 Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Sat, 3 Oct 2026 16:24:47 +0000 Subject: [PATCH 1/5] Recover IDBMirrorVFS from a commit whose IndexedDB transaction aborts. The view already includes a transaction when its IndexedDB commit aborts. A later commit then carried the failed rows (synchronous=full) or was stored on top of a state that never was (synchronous=normal), including commits already queued behind the aborted one. Such a view is no longer published: the commits built on it are refused or dropped, the view is reloaded from IndexedDB when SQLite next validates its cache, and its journal is not played back. Co-Authored-By: Claude Opus 5.5 --- src/examples/IDBMirrorVFS.js | 191 ++++++++++++++++++++++++----------- 1 file changed, 131 insertions(+), 60 deletions(-) diff --git a/src/examples/IDBMirrorVFS.js b/src/examples/IDBMirrorVFS.js index 3de1296b..48929e0a 100644 --- a/src/examples/IDBMirrorVFS.js +++ b/src/examples/IDBMirrorVFS.js @@ -56,6 +56,8 @@ class File { this.broadcastReceived = []; this.lockState = VFS.SQLITE_LOCK_NONE; this.locks = {}; + this.abortController = new AbortController(); + this.commitsPending = 0; this.txActive = null; this.txWriteHint = false; this.txOverwrite = false; @@ -141,39 +143,7 @@ export class IDBMirrorVFS extends FacadeVFS { } } - // Load pages into memory from IndexedDB. - await new Promise((resolve, reject) => { - const range = IDBKeyRange.bound([path, 0], [path, Infinity]); - const request = blocks.openCursor(range); - request.onsuccess = () => { - const cursor = request.result; - if (cursor) { - const { offset, data } = cursor.value; - file.blocks.set(offset, data); - cursor.continue(); - } else { - resolve(); - } - }; - request.onerror = () => reject(request.error); - }); - file.blockSize = file.blocks.get(0)?.byteLength ?? 0; - - // Get the last transaction id. - const transactions = idbTx.objectStore('tx'); - file.viewTx = await new Promise((resolve, reject) => { - const range = IDBKeyRange.bound([path, 0], [path, Infinity]); - const request = transactions.openCursor(range, 'prev'); - request.onsuccess = () => { - const cursor = request.result; - if (cursor) { - resolve(cursor.value); - } else { - resolve({ txId: 0 }); - } - }; - request.onerror = () => reject(request.error); - }); + await this.#loadFile(file, idbTx); // Publish our view of the database. This prevents other connections // from overwriting file data we still need. @@ -263,6 +233,10 @@ export class IDBMirrorVFS extends FacadeVFS { this.#mapIdToFile.delete(fileId); if (file?.flags & VFS.SQLITE_OPEN_MAIN_DB) { + if (file.abortController.signal.aborted) { + // The journal belongs to a view that was never stored. + this.#mapPathToFile.delete(file.path + '-journal'); + } file.broadcastChannel.close(); file.viewReleaser?.(); } @@ -418,6 +392,11 @@ export class IDBMirrorVFS extends FacadeVFS { if (lockType <= file.lockState) return VFS.SQLITE_OK; switch (lockType) { case VFS.SQLITE_LOCK_SHARED: + if (file.abortController.signal.aborted) { + // SQLite validates its cache against the file here, so this is + // where a view including an aborted commit can be replaced. + await this.#reloadFile(file); + } if (file.txWriteHint) { // xFileControl() has hinted that this transaction will // write. Acquire the hint lock, which is required to reach @@ -471,6 +450,12 @@ export class IDBMirrorVFS extends FacadeVFS { } console.assert(entries[0]?.txId === file.viewTx.txId || !file.viewTx.txId); + if (file.abortController.signal.aborted) { + // Our view includes a commit that was never stored. Make the + // transaction start over, which reloads the file. + file.locks.reserved(); + return VFS.SQLITE_BUSY; + } break; case VFS.SQLITE_LOCK_EXCLUSIVE: await this.#lock(file, 'write'); @@ -670,6 +655,12 @@ export class IDBMirrorVFS extends FacadeVFS { * @param {File} file */ async #commitTx(file) { + if (file.abortController.signal.aborted) { + // This transaction was built on a commit that was never stored. + this.#dropTx(file); + throw new Error('an earlier commit was aborted'); + } + // Advance our own view. Even if we received our own broadcasts (we // don't), we want our view to be updated synchronously. this.#acceptTx(file, file.txActive); @@ -677,47 +668,127 @@ export class IDBMirrorVFS extends FacadeVFS { const oldestTxId = await this.#getOldestTxInUse(file); - // Update IndexedDB page data. + const tx = file.txActive; const idbTx = this.#idb.transaction(['blocks', 'tx'], 'readwrite'); - const blocks = idbTx.objectStore('blocks'); - for (const [offset, data] of file.txActive.blocks) { - blocks.put({ path: file.path, offset, data }); - } - - // Delete blocks past the end of the file. - blocks.delete(IDBKeyRange.bound( - [file.path, file.txActive.fileSize], [file.path, Infinity])); - - // Delete obsolete transactions no longer needed. - const oldRange = IDBKeyRange.bound( - [file.path, -Infinity], [file.path, oldestTxId], - false, true); - idbTx.objectStore('tx').delete(oldRange); - - // Save transaction object. Omit page data as an optimization. - const txSansData = Object.assign({}, file.txActive); - txSansData.blocks = new Map(Array.from(file.txActive.blocks, ([k]) => [k, null])); - idbTx.objectStore('tx').put(txSansData); - - // Broadcast transaction once it commits. + const gated = file.commitsPending++ > 0; const complete = new Promise((resolve, reject) => { - const message = file.txActive; idbTx.oncomplete = () => { - file.broadcastChannel.postMessage(message); + file.commitsPending--; + file.broadcastChannel.postMessage(tx); resolve(); }; - idbTx.onabort = () => reject(idbTx.error); - idbTx.commit(); + idbTx.onabort = () => { + // Our view includes this transaction, so it must be reloaded. + file.commitsPending--; + file.abortController.abort(); + reject(idbTx.error); + }; + + const write = () => { + if (file.abortController.signal.aborted) { + idbTx.abort(); + return; + } + + // Update IndexedDB page data. + const blocks = idbTx.objectStore('blocks'); + for (const [offset, data] of tx.blocks) { + blocks.put({ path: file.path, offset, data }); + } + + // Delete blocks past the end of the file. + blocks.delete(IDBKeyRange.bound( + [file.path, tx.fileSize], [file.path, Infinity])); + + // Delete obsolete transactions no longer needed. + const oldRange = IDBKeyRange.bound( + [file.path, -Infinity], [file.path, oldestTxId], + false, true); + idbTx.objectStore('tx').delete(oldRange); + + // Save transaction object. Omit page data as an optimization. + const txSansData = Object.assign({}, tx); + txSansData.blocks = new Map(Array.from(tx.blocks, ([k]) => [k, null])); + idbTx.objectStore('tx').put(txSansData); + idbTx.commit(); + }; + + if (gated) { + // A request runs only after the earlier commits have finished, + // so the check above sees whether one of them was aborted. + idbTx.objectStore('tx').get([file.path, -1]).onsuccess = write; + } else { + write(); + } }); if (file.synchronous === 'full') { - await complete; + try { + await complete; + } catch (e) { + // SQLite discards its cache after this error, so the view can be + // reloaded now, even in exclusive locking mode. + await this.#reloadFile(file); + throw e; + } + } else { + // A failure is handled by the next transaction. + complete.catch(() => {}); } file.txActive = null; file.txWriteHint = false; } + /** + * Load the stored blocks and the last transaction into a main database. + * @param {File} file + * @param {IDBTransaction} idbTx + */ + async #loadFile(file, idbTx) { + const blocks = new Map(); + await new Promise((resolve, reject) => { + const range = IDBKeyRange.bound([file.path, 0], [file.path, Infinity]); + const request = idbTx.objectStore('blocks').openCursor(range); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const { offset, data } = cursor.value; + blocks.set(offset, data); + cursor.continue(); + } else { + resolve(); + } + }; + request.onerror = () => reject(request.error); + }); + + const viewTx = await new Promise((resolve, reject) => { + const range = IDBKeyRange.bound([file.path, 0], [file.path, Infinity]); + const request = idbTx.objectStore('tx').openCursor(range, 'prev'); + request.onsuccess = () => resolve(request.result?.value ?? { txId: 0 }); + request.onerror = () => reject(request.error); + }); + + file.blocks = blocks; + file.blockSize = blocks.get(0)?.byteLength ?? 0; + file.viewTx = viewTx; + } + + /** + * Replace a view that includes an aborted commit with the stored one. + * @param {File} file + */ + async #reloadFile(file) { + await this.#loadFile(file, this.#idb.transaction(['blocks', 'tx'])); + file.txActive = null; + file.abortController = new AbortController(); + await this.#setView(file, file.viewTx); + if (file.lockState === VFS.SQLITE_LOCK_NONE) { + this.#processBroadcasts(file); + } + } + /** * @param {File} file */ From 1f7b253366e6fb41fd37bdd53e03e3bf5facf714 Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Sat, 3 Oct 2026 16:24:47 +0000 Subject: [PATCH 2/5] Add IDBMirrorVFS tests for a commit whose IndexedDB transaction aborts. Co-Authored-By: Claude Opus 5.5 --- test/IDBMirrorVFS.test.js | 2 + test/vfs_commit_abort-worker.js | 108 ++++++++++++++++++++++++++ test/vfs_commit_abort.js | 129 ++++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 test/vfs_commit_abort-worker.js create mode 100644 test/vfs_commit_abort.js diff --git a/test/IDBMirrorVFS.test.js b/test/IDBMirrorVFS.test.js index 734a78a9..538a424b 100644 --- a/test/IDBMirrorVFS.test.js +++ b/test/IDBMirrorVFS.test.js @@ -6,6 +6,7 @@ import { vfs_xRead } from "./vfs_xRead.js"; import { vfs_xWrite } from "./vfs_xWrite.js"; import { vfs_leak } from "./vfs_leak.js"; import { vfs_rollback } from "./vfs_rollback.js"; +import { vfs_commit_abort } from "./vfs_commit_abort.js"; const CONFIG = 'IDBMirrorVFS'; const BUILDS = ['asyncify', 'jspi']; @@ -26,6 +27,7 @@ describe(CONFIG, function() { vfs_xWrite(context); vfs_leak(context); vfs_rollback(context); + vfs_commit_abort({ build }); }); } }); diff --git a/test/vfs_commit_abort-worker.js b/test/vfs_commit_abort-worker.js new file mode 100644 index 00000000..dd8f11e8 --- /dev/null +++ b/test/vfs_commit_abort-worker.js @@ -0,0 +1,108 @@ +// A worker for vfs_commit_abort.js, holding one connection on +// IDBMirrorVFS. It can make the next IndexedDB commit abort, as a full +// quota would, optionally after keeping the transaction alive. +import * as SQLite from '../src/sqlite-api.js'; +import { IDBMirrorVFS } from '../src/examples/IDBMirrorVFS.js'; + +const BUILDS = new Map([ + ['default', '../dist/wa-sqlite.mjs'], + ['asyncify', '../dist/wa-sqlite-async.mjs'], + ['jspi', '../dist/wa-sqlite-jspi.mjs'], +]); + +const searchParams = new URLSearchParams(location.search); + +let abortNextCommit = false; +let abortDelay = 0; +const commit = IDBTransaction.prototype.commit; +IDBTransaction.prototype.commit = function() { + if (abortNextCommit && this.mode === 'readwrite') { + abortNextCommit = false; + if (!abortDelay) return this.abort(); + + // Keep the transaction active with requests, then abort it. + const deadline = Date.now() + abortDelay; + const store = this.objectStore('tx'); + const keepAlive = () => { + if (Date.now() < deadline) { + store.get(['', 0]).onsuccess = keepAlive; + } else { + this.abort(); + } + }; + return keepAlive(); + } + return commit.call(this); +}; + +const ready = (async () => { + const { default: moduleFactory } = await import(BUILDS.get(searchParams.get('build'))); + const module = await moduleFactory(); + const sqlite3 = SQLite.Factory(module); + const vfs = await IDBMirrorVFS.create(searchParams.get('idb'), module); + sqlite3.vfs_register(vfs, true); + return { sqlite3, db: await sqlite3.open_v2('commit-abort') }; +})(); + +// A read-only transaction completes after the read-write transactions +// created before it, and after their completion or abort events. Closing +// before a commit completes makes its broadcast throw, which is not what +// these tests are about. +async function commitsFinished() { + const idb = await new Promise((resolve, reject) => { + const request = indexedDB.open(searchParams.get('idb')); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await new Promise(resolve => { + const tx = idb.transaction(['blocks', 'tx']); + tx.objectStore('tx').count(); + tx.oncomplete = tx.onabort = resolve; + }); + idb.close(); +} + +addEventListener('message', async ({ data }) => { + try { + const state = await ready; + const { sqlite3 } = state; + switch (data.type) { + case 'exec': { + const rows = []; + try { + await sqlite3.exec(state.db, data.sql, row => rows.push(row)); + postMessage({ rows }); + } catch (e) { + postMessage({ rows, error: e.message }); + } + break; + } + case 'abort-next-commit': + abortNextCommit = true; + abortDelay = data.delay ?? 0; + postMessage({}); + break; + case 'sleep': + await new Promise(resolve => setTimeout(resolve, data.ms)); + postMessage({}); + break; + case 'settle': + await commitsFinished(); + postMessage({}); + break; + case 'reopen': + await commitsFinished(); + await sqlite3.close(state.db); + state.db = await sqlite3.open_v2('commit-abort'); + postMessage({}); + break; + case 'close': + await commitsFinished(); + await sqlite3.close(state.db); + postMessage({}); + break; + } + } catch (e) { + postMessage({ error: e.message }); + } +}); diff --git a/test/vfs_commit_abort.js b/test/vfs_commit_abort.js new file mode 100644 index 00000000..f8a466c2 --- /dev/null +++ b/test/vfs_commit_abort.js @@ -0,0 +1,129 @@ +const ROWS = ` + CREATE TABLE t(x BLOB); + WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 200) + INSERT INTO t SELECT randomblob(500) FROM c;`; +const ABORTED = 'INSERT INTO t VALUES (zeroblob(10)), (zeroblob(10)), (zeroblob(10))'; + +/** + * A commit whose IndexedDB transaction aborts must never reach the stored + * database, nor any commit built on it, and the connection must go on. + * @param {{ build: string }} params + */ +export function vfs_commit_abort({ build }) { + describe('vfs_commit_abort', function() { + const workers = []; + afterEach(function() { + for (const worker of workers.splice(0)) worker.terminate(); + }); + + function connect(idb) { + const url = new URL('./vfs_commit_abort-worker.js', import.meta.url); + url.searchParams.set('build', build); + url.searchParams.set('idb', idb); + const worker = new Worker(url, { type: 'module' }); + workers.push(worker); + return (message) => new Promise((resolve, reject) => { + worker.addEventListener('message', ({ data }) => resolve(data), { once: true }); + worker.addEventListener('error', event => reject(new Error(event.message)), { once: true }); + worker.postMessage(message); + }); + } + const exec = (context, sql) => context({ type: 'exec', sql }); + const query = async (context, sql) => (await exec(context, sql)).rows[0][0]; + + // A new connection reads what is stored. + async function expectStored(idb, count) { + const c = connect(idb); + expect(await query(c, 'SELECT count(*) FROM t')).toBe(count); + expect(await query(c, 'SELECT count(*) FROM t WHERE length(x) = 10')).toBe(0); + expect(await query(c, 'SELECT length(x) FROM t WHERE rowid = 1')).toBe(500); + expect((await exec(c, 'PRAGMA integrity_check')).rows).toEqual([['ok']]); + } + + for (const synchronous of ['full', 'normal']) { + for (const lockingMode of ['normal', 'exclusive']) { + it(`should not keep an aborted commit with synchronous=${synchronous}, locking_mode=${lockingMode}`, async function() { + const idb = `commit-abort-${Math.random().toString(36).slice(2)}`; + const a = connect(idb); + expect((await exec(a, ROWS)).error).toBeUndefined(); + await exec(a, ` + PRAGMA synchronous = ${synchronous}; + PRAGMA locking_mode = ${lockingMode}; + PRAGMA busy_timeout = 1000;`); + expect(await query(a, 'SELECT count(*) FROM t')).toBe(200); + + // With synchronous=full the commit fails. With synchronous=normal + // it has already returned when the abort arrives. + await a({ type: 'abort-next-commit' }); + const aborted = await exec(a, ABORTED); + expect(aborted.error !== undefined).toBe(synchronous === 'full'); + + if (synchronous === 'normal' && lockingMode === 'exclusive') { + // Once the abort is known, commits fail. The lock is never + // released, so the view is reloaded only when reopened. + await a({ type: 'settle' }); + expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeDefined(); + expect(await query(a, 'SELECT count(*) FROM t WHERE length(x) = 20')).toBe(0); + await a({ type: 'reopen' }); + } + expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeUndefined(); + expect(await query(a, 'SELECT count(*) FROM t')).toBe(201); + await a({ type: 'close' }); + + await expectStored(idb, 201); + }); + } + } + + it('should not store a commit queued behind an aborted one', async function() { + const idb = `commit-abort-${Math.random().toString(36).slice(2)}`; + const a = connect(idb); + expect((await exec(a, ROWS)).error).toBeUndefined(); + await exec(a, 'PRAGMA synchronous = normal; PRAGMA locking_mode = exclusive'); + expect(await query(a, 'SELECT count(*) FROM t')).toBe(200); + + // The update is committed while the aborted transaction is pending. + await a({ type: 'abort-next-commit', delay: 300 }); + expect((await exec(a, ABORTED)).error).toBeUndefined(); + expect((await exec(a, 'UPDATE t SET x = zeroblob(11) WHERE rowid = 1')).error).toBeUndefined(); + await a({ type: 'sleep', ms: 600 }); + expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeDefined(); + + await a({ type: 'reopen' }); + expect(await query(a, 'SELECT length(x) FROM t WHERE rowid = 1')).toBe(500); + expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeUndefined(); + await a({ type: 'close' }); + + await expectStored(idb, 201); + }); + + it('should not play back a journal written on an aborted view', async function() { + const idb = `commit-abort-${Math.random().toString(36).slice(2)}`; + const a = connect(idb); + expect((await exec(a, ROWS)).error).toBeUndefined(); + await exec(a, ` + PRAGMA synchronous = normal; + PRAGMA locking_mode = exclusive; + PRAGMA cache_size = 10;`); + expect(await query(a, 'SELECT count(*) FROM t')).toBe(200); + + // A transaction larger than the cache writes a rollback journal + // before its commit is refused. + await a({ type: 'abort-next-commit' }); + expect((await exec(a, ABORTED)).error).toBeUndefined(); + await a({ type: 'settle' }); + expect((await exec(a, ` + BEGIN; + WITH RECURSIVE c(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM c WHERE i < 2000) + INSERT INTO t SELECT randomblob(500) FROM c; + COMMIT;`)).error).toBeDefined(); + + await a({ type: 'reopen' }); + expect(await query(a, 'SELECT count(*) FROM t')).toBe(200); + expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeUndefined(); + await a({ type: 'close' }); + + await expectStored(idb, 201); + }); + }); +} From 3367cb65eaa0ea1625eb691e5cf191159f218629 Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Sat, 3 Oct 2026 17:05:54 +0000 Subject: [PATCH 3/5] Reload the IDBMirrorVFS view when a commit is refused. In exclusive locking mode the lock is never released, so a connection with synchronous=normal stayed failed until reopened after an abort. SQLite discards its cache after the refused commit's error, so the view can be reloaded there, except when SQLite rolls the transaction back through a journal written on the aborted view. Co-Authored-By: Claude Opus 5.5 --- src/examples/IDBMirrorVFS.js | 5 +++++ test/vfs_commit_abort.js | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/examples/IDBMirrorVFS.js b/src/examples/IDBMirrorVFS.js index 48929e0a..4dde7de5 100644 --- a/src/examples/IDBMirrorVFS.js +++ b/src/examples/IDBMirrorVFS.js @@ -657,7 +657,12 @@ export class IDBMirrorVFS extends FacadeVFS { async #commitTx(file) { if (file.abortController.signal.aborted) { // This transaction was built on a commit that was never stored. + // SQLite discards its cache after this error, so the view can be + // reloaded, unless SQLite rolls back from a journal written on it. this.#dropTx(file); + if (!this.#mapPathToFile.has(file.path + '-journal')) { + await this.#reloadFile(file); + } throw new Error('an earlier commit was aborted'); } diff --git a/test/vfs_commit_abort.js b/test/vfs_commit_abort.js index f8a466c2..a58ea903 100644 --- a/test/vfs_commit_abort.js +++ b/test/vfs_commit_abort.js @@ -59,12 +59,11 @@ export function vfs_commit_abort({ build }) { expect(aborted.error !== undefined).toBe(synchronous === 'full'); if (synchronous === 'normal' && lockingMode === 'exclusive') { - // Once the abort is known, commits fail. The lock is never - // released, so the view is reloaded only when reopened. + // The lock is never released, so the first commit after the + // abort is known fails, and the view is reloaded then. await a({ type: 'settle' }); expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeDefined(); expect(await query(a, 'SELECT count(*) FROM t WHERE length(x) = 20')).toBe(0); - await a({ type: 'reopen' }); } expect((await exec(a, 'INSERT INTO t VALUES (zeroblob(20))')).error).toBeUndefined(); expect(await query(a, 'SELECT count(*) FROM t')).toBe(201); @@ -108,7 +107,7 @@ export function vfs_commit_abort({ build }) { expect(await query(a, 'SELECT count(*) FROM t')).toBe(200); // A transaction larger than the cache writes a rollback journal - // before its commit is refused. + // before its commit is refused, so only a reopen recovers. await a({ type: 'abort-next-commit' }); expect((await exec(a, ABORTED)).error).toBeUndefined(); await a({ type: 'settle' }); From 874303eb8107b776b24421c919369045b516123d Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Fri, 9 Oct 2026 08:00:04 +0000 Subject: [PATCH 4/5] Make IDBMirrorVFS's commit write path sequential, per review. The commit fence is now awaited inside an async executor instead of gating a nested write function. Also declare commitsPending with the other File properties and note a faster way to load the database. Co-Authored-By: Claude Opus 5.5 --- src/examples/IDBMirrorVFS.js | 71 ++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/examples/IDBMirrorVFS.js b/src/examples/IDBMirrorVFS.js index 4dde7de5..9cedd053 100644 --- a/src/examples/IDBMirrorVFS.js +++ b/src/examples/IDBMirrorVFS.js @@ -36,6 +36,7 @@ class File { /** @type {{write?: function, reserved?: function, hint?: function}} */ locks; /** @type {AbortController} */ abortController; + /** @type {number} */ commitsPending; /** @type {Transaction?} */ txActive; /** @type {boolean} */ txWriteHint; @@ -675,8 +676,7 @@ export class IDBMirrorVFS extends FacadeVFS { const tx = file.txActive; const idbTx = this.#idb.transaction(['blocks', 'tx'], 'readwrite'); - const gated = file.commitsPending++ > 0; - const complete = new Promise((resolve, reject) => { + const complete = new Promise(async (resolve, reject) => { idbTx.oncomplete = () => { file.commitsPending--; file.broadcastChannel.postMessage(tx); @@ -689,42 +689,41 @@ export class IDBMirrorVFS extends FacadeVFS { reject(idbTx.error); }; - const write = () => { - if (file.abortController.signal.aborted) { - idbTx.abort(); - return; - } - - // Update IndexedDB page data. - const blocks = idbTx.objectStore('blocks'); - for (const [offset, data] of tx.blocks) { - blocks.put({ path: file.path, offset, data }); - } + if (file.commitsPending++ > 0) { + // There are other write transactions still in flight. Use + // a read request purely for the side effect of flushing + // the IDB pipeline. Then we can ensure we are not building + // the new transaction on a failed transaction. + const fence = idbTx.objectStore('tx').get([file.path, -1]); + await idbX(fence); + } - // Delete blocks past the end of the file. - blocks.delete(IDBKeyRange.bound( - [file.path, tx.fileSize], [file.path, Infinity])); - - // Delete obsolete transactions no longer needed. - const oldRange = IDBKeyRange.bound( - [file.path, -Infinity], [file.path, oldestTxId], - false, true); - idbTx.objectStore('tx').delete(oldRange); - - // Save transaction object. Omit page data as an optimization. - const txSansData = Object.assign({}, tx); - txSansData.blocks = new Map(Array.from(tx.blocks, ([k]) => [k, null])); - idbTx.objectStore('tx').put(txSansData); - idbTx.commit(); - }; + if (file.abortController.signal.aborted) { + idbTx.abort(); + return; + } - if (gated) { - // A request runs only after the earlier commits have finished, - // so the check above sees whether one of them was aborted. - idbTx.objectStore('tx').get([file.path, -1]).onsuccess = write; - } else { - write(); + // Update IndexedDB page data. + const blocks = idbTx.objectStore('blocks'); + for (const [offset, data] of tx.blocks) { + blocks.put({ path: file.path, offset, data }); } + + // Delete blocks past the end of the file. + blocks.delete(IDBKeyRange.bound( + [file.path, tx.fileSize], [file.path, Infinity])); + + // Delete obsolete transactions no longer needed. + const oldRange = IDBKeyRange.bound( + [file.path, -Infinity], [file.path, oldestTxId], + false, true); + idbTx.objectStore('tx').delete(oldRange); + + // Save transaction object. Omit page data as an optimization. + const txSansData = Object.assign({}, tx); + txSansData.blocks = new Map(Array.from(tx.blocks, ([k]) => [k, null])); + idbTx.objectStore('tx').put(txSansData); + idbTx.commit(); }); if (file.synchronous === 'full') { @@ -753,6 +752,8 @@ export class IDBMirrorVFS extends FacadeVFS { async #loadFile(file, idbTx) { const blocks = new Map(); await new Promise((resolve, reject) => { + // TODO: Reading the database in batches with `getAll()` and a + // batch size count would be faster than using a cursor. const range = IDBKeyRange.bound([file.path, 0], [file.path, Infinity]); const request = idbTx.objectStore('blocks').openCursor(range); request.onsuccess = () => { From cb97deedcc4d6c2cd952df0495fec4268594ebff Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Sat, 10 Oct 2026 15:52:45 +0000 Subject: [PATCH 5/5] IDBMirrorVFS: abort the commit transaction if its write body throws The write body now runs after an await, outside any request event handler, so an exception there no longer aborts the IndexedDB transaction: the requests already issued auto-commit and the commit resolves. Abort explicitly so a failed write is never stored. Co-Authored-By: Claude Opus 5.5 --- src/examples/IDBMirrorVFS.js | 55 ++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/examples/IDBMirrorVFS.js b/src/examples/IDBMirrorVFS.js index 9cedd053..b7dcb844 100644 --- a/src/examples/IDBMirrorVFS.js +++ b/src/examples/IDBMirrorVFS.js @@ -698,32 +698,39 @@ export class IDBMirrorVFS extends FacadeVFS { await idbX(fence); } - if (file.abortController.signal.aborted) { - idbTx.abort(); - return; - } + try { + if (file.abortController.signal.aborted) { + idbTx.abort(); + return; + } - // Update IndexedDB page data. - const blocks = idbTx.objectStore('blocks'); - for (const [offset, data] of tx.blocks) { - blocks.put({ path: file.path, offset, data }); - } + // Update IndexedDB page data. + const blocks = idbTx.objectStore('blocks'); + for (const [offset, data] of tx.blocks) { + blocks.put({ path: file.path, offset, data }); + } - // Delete blocks past the end of the file. - blocks.delete(IDBKeyRange.bound( - [file.path, tx.fileSize], [file.path, Infinity])); - - // Delete obsolete transactions no longer needed. - const oldRange = IDBKeyRange.bound( - [file.path, -Infinity], [file.path, oldestTxId], - false, true); - idbTx.objectStore('tx').delete(oldRange); - - // Save transaction object. Omit page data as an optimization. - const txSansData = Object.assign({}, tx); - txSansData.blocks = new Map(Array.from(tx.blocks, ([k]) => [k, null])); - idbTx.objectStore('tx').put(txSansData); - idbTx.commit(); + // Delete blocks past the end of the file. + blocks.delete(IDBKeyRange.bound( + [file.path, tx.fileSize], [file.path, Infinity])); + + // Delete obsolete transactions no longer needed. + const oldRange = IDBKeyRange.bound( + [file.path, -Infinity], [file.path, oldestTxId], + false, true); + idbTx.objectStore('tx').delete(oldRange); + + // Save transaction object. Omit page data as an optimization. + const txSansData = Object.assign({}, tx); + txSansData.blocks = new Map(Array.from(tx.blocks, ([k]) => [k, null])); + idbTx.objectStore('tx').put(txSansData); + idbTx.commit(); + } catch (e) { + // This code no longer runs inside a request event handler, so an + // exception would not abort the transaction on its own. + idbTx.abort(); + throw e; + } }); if (file.synchronous === 'full') {