From 65b309c35fba9d090e2fcacf5295a261421b8691 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 8 Dec 2025 12:30:32 +0200 Subject: [PATCH 1/3] fix: clear retry ops after failure --- .changeset/wild-pants-shave.md | 5 +++++ src/sqlite-api.js | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/wild-pants-shave.md diff --git a/.changeset/wild-pants-shave.md b/.changeset/wild-pants-shave.md new file mode 100644 index 00000000..510f8462 --- /dev/null +++ b/.changeset/wild-pants-shave.md @@ -0,0 +1,5 @@ +--- +'@journeyapps/wa-sqlite': patch +--- + +Clear retryOps after waiting for ops. diff --git a/src/sqlite-api.js b/src/sqlite-api.js index 935383eb..6e75e853 100644 --- a/src/sqlite-api.js +++ b/src/sqlite-api.js @@ -898,8 +898,11 @@ export function Factory(Module) { // Wait for all pending retry operations to complete. This is // normally empty on the first loop iteration. if (Module.retryOps.length) { - await Promise.all(Module.retryOps); - Module.retryOps = []; + try { + await Promise.all(Module.retryOps); + } finally { + Module.retryOps = []; + } } rc = await f(); From cb67abc2c023116318ee2de03c2ec264a5de3fbd Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 8 Dec 2025 12:49:03 +0200 Subject: [PATCH 2/3] update changeset --- .changeset/wild-pants-shave.md | 2 +- PR_DESCRIPTION.md | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 PR_DESCRIPTION.md diff --git a/.changeset/wild-pants-shave.md b/.changeset/wild-pants-shave.md index 510f8462..dd8a1427 100644 --- a/.changeset/wild-pants-shave.md +++ b/.changeset/wild-pants-shave.md @@ -2,4 +2,4 @@ '@journeyapps/wa-sqlite': patch --- -Clear retryOps after waiting for ops. +Clear retryOps after waiting for ops. This can fix issues on OPFSCoopSyncVFS where is an access handle failed to be obtained once, it could lock the entire connection indefinitely. diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..09ddc962 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,61 @@ +# Fix: Clear `retryOps` array after awaiting to prevent connection breakage + +## Problem + +When an async operation pushed to `Module.retryOps` rejects, the rejected promise remains in the array indefinitely. This causes all subsequent SQLite operations to fail immediately, effectively breaking the database connection until a page refresh. + +## Root Cause + +The `retry()` function in `sqlite-api.js` awaits all pending retry operations before retrying a SQLite call. Previously, the array was only cleared after a successful `Promise.all()`: + +```javascript +// Before (problematic) +if (Module.retryOps.length) { + await Promise.all(Module.retryOps); + Module.retryOps = []; // Never reached if Promise.all throws +} +``` + +If any promise rejects, `Promise.all()` throws and `Module.retryOps = []` is never executed. + +## Example Scenario + +1. User opens a database file +2. VFS `xOpen` pushes an async operation to `retryOps` (e.g., acquiring a file access handle) +3. The async operation fails (e.g., `createSyncAccessHandle()` throws due to the file being locked by another tab) +4. `Promise.all(Module.retryOps)` rejects +5. The rejected promise stays in `Module.retryOps` +6. **Any subsequent SQLite operation** (queries, transactions, etc.) will: + - Enter `retry()` + - Call `Promise.all(Module.retryOps)` which contains the already-rejected promise + - Immediately throw the same error + - Never execute any actual SQLite code + +The connection is now permanently broken—even unrelated operations that would otherwise succeed will fail. + +## Fix + +Use a `finally` block to ensure `retryOps` is always cleared, regardless of whether the promises resolve or reject: + +```javascript +// After (fixed) +if (Module.retryOps.length) { + try { + await Promise.all(Module.retryOps); + } finally { + Module.retryOps = []; // Always executed + } +} +``` + +This ensures each retry iteration starts with a clean state. The original error still propagates to the caller (as expected), but future operations are not blocked by stale rejected promises. + +## Why This is Correct + +The `retryOps` mechanism works as follows: + +1. Synchronous VFS methods push async operations to the array and return `SQLITE_BUSY` +2. `retry()` awaits all pending ops, then retries the synchronous call +3. Each retry attempt may push **new** async operations + +Each iteration should operate on a fresh set of promises. Rejected promises from a failed attempt have no bearing on subsequent attempts—the VFS will push new promises as needed. From 034aa54a71e7f88ea9249fde3a17c2cc98d902fc Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 8 Dec 2025 12:49:22 +0200 Subject: [PATCH 3/3] delete markdown --- PR_DESCRIPTION.md | 61 ----------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 09ddc962..00000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,61 +0,0 @@ -# Fix: Clear `retryOps` array after awaiting to prevent connection breakage - -## Problem - -When an async operation pushed to `Module.retryOps` rejects, the rejected promise remains in the array indefinitely. This causes all subsequent SQLite operations to fail immediately, effectively breaking the database connection until a page refresh. - -## Root Cause - -The `retry()` function in `sqlite-api.js` awaits all pending retry operations before retrying a SQLite call. Previously, the array was only cleared after a successful `Promise.all()`: - -```javascript -// Before (problematic) -if (Module.retryOps.length) { - await Promise.all(Module.retryOps); - Module.retryOps = []; // Never reached if Promise.all throws -} -``` - -If any promise rejects, `Promise.all()` throws and `Module.retryOps = []` is never executed. - -## Example Scenario - -1. User opens a database file -2. VFS `xOpen` pushes an async operation to `retryOps` (e.g., acquiring a file access handle) -3. The async operation fails (e.g., `createSyncAccessHandle()` throws due to the file being locked by another tab) -4. `Promise.all(Module.retryOps)` rejects -5. The rejected promise stays in `Module.retryOps` -6. **Any subsequent SQLite operation** (queries, transactions, etc.) will: - - Enter `retry()` - - Call `Promise.all(Module.retryOps)` which contains the already-rejected promise - - Immediately throw the same error - - Never execute any actual SQLite code - -The connection is now permanently broken—even unrelated operations that would otherwise succeed will fail. - -## Fix - -Use a `finally` block to ensure `retryOps` is always cleared, regardless of whether the promises resolve or reject: - -```javascript -// After (fixed) -if (Module.retryOps.length) { - try { - await Promise.all(Module.retryOps); - } finally { - Module.retryOps = []; // Always executed - } -} -``` - -This ensures each retry iteration starts with a clean state. The original error still propagates to the caller (as expected), but future operations are not blocked by stale rejected promises. - -## Why This is Correct - -The `retryOps` mechanism works as follows: - -1. Synchronous VFS methods push async operations to the array and return `SQLITE_BUSY` -2. `retry()` awaits all pending ops, then retries the synchronous call -3. Each retry attempt may push **new** async operations - -Each iteration should operate on a fresh set of promises. Rejected promises from a failed attempt have no bearing on subsequent attempts—the VFS will push new promises as needed.