mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(web): prevent concurrent OPFS access
This commit is contained in:
1 parent
8e9e098391
commit
8ca8b4f623
9 files changed
+226
-4
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完了",
|
||||
"retry": "リトライ",
|
||||
"initFailed": "アプリの初期化に失敗しました",
|
||||
"webWasmWorkspaceBusy": "Web WASM は別のタブで開かれています。もう一方の ChatLab Web WASM タブを閉じてから再試行してください。",
|
||||
"loading": "読み込み中...",
|
||||
"initializing": "初期化中...",
|
||||
"noResults": "結果が見つかりません",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完成",
|
||||
"retry": "重试",
|
||||
"initFailed": "应用初始化失败",
|
||||
"webWasmWorkspaceBusy": "Web WASM 已在另一个标签页中打开。请关闭另一个 ChatLab Web WASM 标签页后重试。",
|
||||
"loading": "加载中...",
|
||||
"initializing": "初始化中...",
|
||||
"noResults": "无匹配结果",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完成",
|
||||
"retry": "重試",
|
||||
"initFailed": "應用程式初始化失敗",
|
||||
"webWasmWorkspaceBusy": "Web WASM 已在另一個分頁中開啟。請關閉另一個 ChatLab Web WASM 分頁後重試。",
|
||||
"loading": "載入中...",
|
||||
"initializing": "初始化中...",
|
||||
"noResults": "無匹配結果",
|
||||
|
||||
Reference in new issue
Block a user