diff --git a/apps/web-wasm/src/App.vue b/apps/web-wasm/src/App.vue
index d586840c..86f31109 100644
--- a/apps/web-wasm/src/App.vue
+++ b/apps/web-wasm/src/App.vue
@@ -1,5 +1,5 @@
diff --git a/apps/web-wasm/src/workspace-lock.test.ts b/apps/web-wasm/src/workspace-lock.test.ts
new file mode 100644
index 00000000..5a20263b
--- /dev/null
+++ b/apps/web-wasm/src/workspace-lock.test.ts
@@ -0,0 +1,71 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import {
+ WebWasmWorkspaceBusyError,
+ acquireWebWasmWorkspaceLease,
+ handleWebWasmWorkspacePageHide,
+ type WebWasmLockManager,
+ type WebWasmWorkspaceLease,
+} from './workspace-lock'
+
+describe('acquireWebWasmWorkspaceLease', () => {
+ it('rejects a second Web WASM page while the workspace lock is occupied', async () => {
+ const lockManager: WebWasmLockManager = {
+ async request(_name, options, callback) {
+ assert.deepEqual(options, { mode: 'exclusive', ifAvailable: true })
+ await callback(null)
+ },
+ }
+
+ await assert.rejects(acquireWebWasmWorkspaceLease(lockManager), WebWasmWorkspaceBusyError)
+ })
+
+ it('holds the workspace lock until the lease is released', async () => {
+ let requestCompleted = false
+ const lockManager: WebWasmLockManager = {
+ async request(_name, _options, callback) {
+ await callback({})
+ requestCompleted = true
+ },
+ }
+
+ const lease = await acquireWebWasmWorkspaceLease(lockManager)
+ assert.equal(requestCompleted, false)
+
+ lease.release()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ assert.equal(requestCompleted, true)
+
+ lease.release()
+ })
+})
+
+describe('handleWebWasmWorkspacePageHide', () => {
+ it('keeps the workspace lease while the page enters the back-forward cache', () => {
+ let releaseCount = 0
+ const lease: WebWasmWorkspaceLease = {
+ release() {
+ releaseCount++
+ },
+ }
+
+ const retainedLease = handleWebWasmWorkspacePageHide({ persisted: true }, lease)
+
+ assert.equal(retainedLease, lease)
+ assert.equal(releaseCount, 0)
+ })
+
+ it('releases and clears the workspace lease when the page is discarded', () => {
+ let releaseCount = 0
+ const lease: WebWasmWorkspaceLease = {
+ release() {
+ releaseCount++
+ },
+ }
+
+ const retainedLease = handleWebWasmWorkspacePageHide({ persisted: false }, lease)
+
+ assert.equal(retainedLease, undefined)
+ assert.equal(releaseCount, 1)
+ })
+})
diff --git a/apps/web-wasm/src/workspace-lock.ts b/apps/web-wasm/src/workspace-lock.ts
new file mode 100644
index 00000000..003511cb
--- /dev/null
+++ b/apps/web-wasm/src/workspace-lock.ts
@@ -0,0 +1,75 @@
+import { handleWebWasmPageHide } from './runtime-lifecycle'
+
+export const WEB_WASM_WORKSPACE_LOCK = 'chatlab-web-wasm-opfs-workspace'
+
+export interface WebWasmLockManager {
+ request(
+ name: string,
+ options: { mode: 'exclusive'; ifAvailable: true },
+ callback: (lock: object | null) => Promise
+ ): Promise
+}
+
+export interface WebWasmWorkspaceLease {
+ release(): void
+}
+
+export class WebWasmWorkspaceBusyError extends Error {
+ readonly code = 'OPFS_WORKSPACE_BUSY'
+
+ constructor() {
+ super('The Web WASM workspace is already open in another tab')
+ this.name = 'WebWasmWorkspaceBusyError'
+ }
+}
+
+export function handleWebWasmWorkspacePageHide(
+ event: Pick,
+ lease: WebWasmWorkspaceLease | undefined
+): WebWasmWorkspaceLease | undefined {
+ let retainedLease = lease
+ handleWebWasmPageHide(event, () => {
+ lease?.release()
+ retainedLease = undefined
+ })
+ return retainedLease
+}
+
+export async function acquireWebWasmWorkspaceLease(
+ lockManager: WebWasmLockManager | undefined
+): Promise {
+ if (!lockManager) return { release: () => undefined }
+
+ let releaseLock: () => void = () => undefined
+ const lockReleased = new Promise((resolve) => {
+ releaseLock = resolve
+ })
+ let released = false
+ const lease: WebWasmWorkspaceLease = {
+ release() {
+ if (released) return
+ released = true
+ releaseLock()
+ },
+ }
+
+ let resolveAcquired!: (lease: WebWasmWorkspaceLease) => void
+ let rejectAcquired!: (error: unknown) => void
+ const acquired = new Promise((resolve, reject) => {
+ resolveAcquired = resolve
+ rejectAcquired = reject
+ })
+
+ void lockManager
+ .request(WEB_WASM_WORKSPACE_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
+ if (!lock) {
+ rejectAcquired(new WebWasmWorkspaceBusyError())
+ return
+ }
+ resolveAcquired(lease)
+ await lockReleased
+ })
+ .catch(rejectAcquired)
+
+ return acquired
+}
diff --git a/packages/web-runtime/src/sqlite/opfs.test.ts b/packages/web-runtime/src/sqlite/opfs.test.ts
new file mode 100644
index 00000000..c831e9c6
--- /dev/null
+++ b/packages/web-runtime/src/sqlite/opfs.test.ts
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import { WebRuntimeError } from '../runtime-error'
+import { normalizeOpfsPoolInitializationError } from './opfs'
+
+describe('normalizeOpfsPoolInitializationError', () => {
+ it('maps an occupied OPFS access handle to a stable runtime error', () => {
+ const cause = new DOMException(
+ 'Access Handles cannot be created if there is another open Access Handle',
+ 'NoModificationAllowedError'
+ )
+
+ const error = normalizeOpfsPoolInitializationError(cause)
+
+ assert.ok(error instanceof WebRuntimeError)
+ assert.equal(error.code, 'OPFS_WORKSPACE_BUSY')
+ assert.match(error.message, /another Web WASM tab/i)
+ assert.equal(error.cause, cause)
+ })
+
+ it('preserves unrelated initialization errors', () => {
+ const cause = new Error('unexpected failure')
+
+ assert.equal(normalizeOpfsPoolInitializationError(cause), cause)
+ })
+})
diff --git a/packages/web-runtime/src/sqlite/opfs.ts b/packages/web-runtime/src/sqlite/opfs.ts
index f5e32ff5..7556d801 100644
--- a/packages/web-runtime/src/sqlite/opfs.ts
+++ b/packages/web-runtime/src/sqlite/opfs.ts
@@ -36,8 +36,24 @@ export async function initializeOpfsSqlite(
initialCapacity: 8,
forceReinitIfPreviouslyFailed: true,
}
- const pool = await sqlite3.installOpfsSAHPoolVfs(poolOptions)
+ let pool: SAHPoolUtil
+ try {
+ pool = await sqlite3.installOpfsSAHPoolVfs(poolOptions)
+ } catch (error) {
+ throw normalizeOpfsPoolInitializationError(error)
+ }
onStage?.('opfs-pool-ready')
return { sqlite3, pool }
}
+
+export function normalizeOpfsPoolInitializationError(error: unknown): unknown {
+ if (error instanceof Error && error.name === 'NoModificationAllowedError') {
+ return new WebRuntimeError(
+ 'OPFS_WORKSPACE_BUSY',
+ 'The Web WASM workspace is already open in another Web WASM tab. Close the other tab and retry.',
+ { cause: error }
+ )
+ }
+ return error
+}
diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json
index 01ae39b5..a9228a21 100644
--- a/src/i18n/locales/en-US/common.json
+++ b/src/i18n/locales/en-US/common.json
@@ -11,6 +11,7 @@
"done": "Done",
"retry": "Retry",
"initFailed": "App initialization failed",
+ "webWasmWorkspaceBusy": "Web WASM is already open in another tab. Close the other ChatLab Web WASM tab and retry.",
"loading": "Loading...",
"initializing": "Initializing...",
"noResults": "No results found",
diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json
index 77545a7f..09c4f877 100644
--- a/src/i18n/locales/ja-JP/common.json
+++ b/src/i18n/locales/ja-JP/common.json
@@ -11,6 +11,7 @@
"done": "完了",
"retry": "リトライ",
"initFailed": "アプリの初期化に失敗しました",
+ "webWasmWorkspaceBusy": "Web WASM は別のタブで開かれています。もう一方の ChatLab Web WASM タブを閉じてから再試行してください。",
"loading": "読み込み中...",
"initializing": "初期化中...",
"noResults": "結果が見つかりません",
diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json
index 773e242d..6e2ccc51 100644
--- a/src/i18n/locales/zh-CN/common.json
+++ b/src/i18n/locales/zh-CN/common.json
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重试",
"initFailed": "应用初始化失败",
+ "webWasmWorkspaceBusy": "Web WASM 已在另一个标签页中打开。请关闭另一个 ChatLab Web WASM 标签页后重试。",
"loading": "加载中...",
"initializing": "初始化中...",
"noResults": "无匹配结果",
diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json
index e10bd914..e6395b54 100644
--- a/src/i18n/locales/zh-TW/common.json
+++ b/src/i18n/locales/zh-TW/common.json
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重試",
"initFailed": "應用程式初始化失敗",
+ "webWasmWorkspaceBusy": "Web WASM 已在另一個分頁中開啟。請關閉另一個 ChatLab Web WASM 分頁後重試。",
"loading": "載入中...",
"initializing": "初始化中...",
"noResults": "無匹配結果",