fix(web): prevent concurrent OPFS access

This commit is contained in:
digua committed 2026-07-20 17:24:55 +08:00
1 parent 8e9e098391
commit 8ca8b4f623
9 files changed
+226 -4

No files matched your search

+33 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useColorMode } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
@@ -13,6 +13,13 @@ import { reportError } from '@/services/log-report'
import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
import {
WebWasmWorkspaceBusyError,
acquireWebWasmWorkspaceLease,
handleWebWasmWorkspacePageHide,
type WebWasmLockManager,
type WebWasmWorkspaceLease,
} from './workspace-lock'
const { t } = useI18n()
const route = useRoute()
@@ -35,6 +42,7 @@ const toaster = {
}
let initInProgress = false
let workspaceLease: WebWasmWorkspaceLease | undefined
async function initializeApp() {
if (initInProgress || isInitialized.value) return
@@ -42,6 +50,7 @@ async function initializeApp() {
initError.value = null
try {
workspaceLease ??= await acquireWebWasmWorkspaceLease(navigator.locks as WebWasmLockManager | undefined)
await initializeAppRuntime({
capabilities: PLATFORM_CAPABILITIES,
initializeServices: () => initServices(),
@@ -52,14 +61,35 @@ async function initializeApp() {
})
} catch (error) {
console.error('Web WASM application initialization failed', error)
initError.value = error instanceof Error ? error.message : String(error)
initError.value =
error instanceof WebWasmWorkspaceBusyError
? t('common.webWasmWorkspaceBusy')
: error instanceof Error
? error.message
: String(error)
reportError(initError.value, error instanceof Error ? error.stack : undefined)
} finally {
initInProgress = false
}
}
onMounted(initializeApp)
function releaseWorkspaceLease() {
workspaceLease?.release()
workspaceLease = undefined
}
function handlePageHide(event: PageTransitionEvent) {
workspaceLease = handleWebWasmWorkspacePageHide(event, workspaceLease)
}
onMounted(() => {
window.addEventListener('pagehide', handlePageHide)
void initializeApp()
})
onBeforeUnmount(() => {
window.removeEventListener('pagehide', handlePageHide)
releaseWorkspaceLease()
})
</script>
<template>
+71
View File
@@ -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)
})
})
+75
View File
@@ -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<void>
): Promise<void>
}
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<PageTransitionEvent, 'persisted'>,
lease: WebWasmWorkspaceLease | undefined
): WebWasmWorkspaceLease | undefined {
let retainedLease = lease
handleWebWasmPageHide(event, () => {
lease?.release()
retainedLease = undefined
})
return retainedLease
}
export async function acquireWebWasmWorkspaceLease(
lockManager: WebWasmLockManager | undefined
): Promise<WebWasmWorkspaceLease> {
if (!lockManager) return { release: () => undefined }
let releaseLock: () => void = () => undefined
const lockReleased = new Promise<void>((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<WebWasmWorkspaceLease>((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
}
@@ -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)
})
})
+17 -1
View File
@@ -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
}
+1
View File
@@ -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",
+1
View File
@@ -11,6 +11,7 @@
"done": "完了",
"retry": "リトライ",
"initFailed": "アプリの初期化に失敗しました",
"webWasmWorkspaceBusy": "Web WASM は別のタブで開かれています。もう一方の ChatLab Web WASM タブを閉じてから再試行してください。",
"loading": "読み込み中...",
"initializing": "初期化中...",
"noResults": "結果が見つかりません",
+1
View File
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重试",
"initFailed": "应用初始化失败",
"webWasmWorkspaceBusy": "Web WASM 已在另一个标签页中打开。请关闭另一个 ChatLab Web WASM 标签页后重试。",
"loading": "加载中...",
"initializing": "初始化中...",
"noResults": "无匹配结果",
+1
View File
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重試",
"initFailed": "應用程式初始化失敗",
"webWasmWorkspaceBusy": "Web WASM 已在另一個分頁中開啟。請關閉另一個 ChatLab Web WASM 分頁後重試。",
"loading": "載入中...",
"initializing": "初始化中...",
"noResults": "無匹配結果",