From e2e51c9508923c4cbae044f560ceaa512cc552fa Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 15 Apr 2026 22:47:26 +0800 Subject: [PATCH] fix: harden opfs capability recovery --- index.js | 108 +++++++++++++++++++++++++++++++----- sync/bme-opfs-store.js | 58 ++++++++++++++++--- tests/graph-persistence.mjs | 41 ++++++++++++++ tests/opfs-persistence.mjs | 26 +++++++++ 4 files changed, 210 insertions(+), 23 deletions(-) diff --git a/index.js b/index.js index 867a1f4..61b542b 100644 --- a/index.js +++ b/index.js @@ -1308,6 +1308,7 @@ let bmeLocalStoreCapabilitySnapshot = { reason: "unprobed", }; let bmeLocalStoreCapabilityWarningShown = false; +const BME_LOCAL_STORE_CAPABILITY_FAILURE_RETRY_MS = 4000; const bmeIndexedDbSnapshotCacheByChatId = new Map(); const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map(); @@ -4425,23 +4426,65 @@ function isCachedIndexedDbSnapshotCompatible(snapshot = null, expectedStore = nu } async function getGraphLocalStoreCapability(forceRefresh = false) { - if (!forceRefresh && bmeLocalStoreCapabilitySnapshot.checked) { + const settings = + arguments.length > 1 && arguments[1] && typeof arguments[1] === "object" + ? arguments[1].settings || getSettings() + : getSettings(); + const eagerRetry = + arguments.length > 1 && + arguments[1] && + typeof arguments[1] === "object" && + arguments[1].eagerRetry === true; + const requestedMode = getRequestedGraphLocalStorageMode(settings); + const usesOpfsPreference = + requestedMode === "auto" || isGraphLocalStorageModeOpfs(requestedMode); + const capabilityReason = String( + bmeLocalStoreCapabilitySnapshot?.reason || "", + ).trim(); + const capabilityFailureStable = + capabilityReason === "missing-directory-handle" || + capabilityReason === "OPFS 不可用" || + /not.?supported/i.test(capabilityReason) || + /missing.+getdirectory/i.test(capabilityReason); + const capabilityFailureRetryable = + usesOpfsPreference && + bmeLocalStoreCapabilitySnapshot.checked === true && + bmeLocalStoreCapabilitySnapshot.opfsAvailable !== true && + capabilityFailureStable !== true; + const capabilityFailureAgeMs = Math.max( + 0, + Date.now() - Number(bmeLocalStoreCapabilitySnapshot?.checkedAt || 0), + ); + const shouldRetryFailedProbe = + forceRefresh !== true && + capabilityFailureRetryable && + (eagerRetry === true || + capabilityFailureAgeMs >= BME_LOCAL_STORE_CAPABILITY_FAILURE_RETRY_MS); + + if ( + !forceRefresh && + !shouldRetryFailedProbe && + bmeLocalStoreCapabilitySnapshot.checked + ) { return bmeLocalStoreCapabilitySnapshot; } - if (!forceRefresh && bmeLocalStoreCapabilityPromise) { + if (!forceRefresh && !shouldRetryFailedProbe && bmeLocalStoreCapabilityPromise) { return await bmeLocalStoreCapabilityPromise; } bmeLocalStoreCapabilityPromise = detectOpfsSupport() - .then((result) => { - bmeLocalStoreCapabilitySnapshot = { - checked: true, - checkedAt: Date.now(), - opfsAvailable: Boolean(result?.available), - reason: String(result?.reason || (result?.available ? "ok" : "unavailable")), - }; - return bmeLocalStoreCapabilitySnapshot; - }) + .then((result) => { + bmeLocalStoreCapabilitySnapshot = { + checked: true, + checkedAt: Date.now(), + opfsAvailable: Boolean(result?.available), + reason: String(result?.reason || (result?.available ? "ok" : "unavailable")), + }; + if (bmeLocalStoreCapabilitySnapshot.opfsAvailable) { + bmeLocalStoreCapabilityWarningShown = false; + } + return bmeLocalStoreCapabilitySnapshot; + }) .catch((error) => { bmeLocalStoreCapabilitySnapshot = { checked: true, @@ -4480,7 +4523,9 @@ function getPreferredGraphLocalStorePresentationSync(settings = getSettings()) { ) { const requestedMode = getRequestedGraphLocalStorageMode(settings); if (requestedMode === "auto") { - const capability = await getGraphLocalStoreCapability(); + const capability = await getGraphLocalStoreCapability(false, { + settings, + }); return capability.opfsAvailable ? buildOpfsStorePresentation(BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY) : buildIndexedDbStorePresentation(); @@ -4489,7 +4534,9 @@ function getPreferredGraphLocalStorePresentationSync(settings = getSettings()) { return buildIndexedDbStorePresentation(); } - const capability = await getGraphLocalStoreCapability(); + const capability = await getGraphLocalStoreCapability(false, { + settings, + }); if (capability.opfsAvailable) { return buildOpfsStorePresentation(requestedMode); } @@ -4536,7 +4583,10 @@ async function refreshCurrentChatLocalStoreBinding( isGraphLocalStorageModeOpfs(requestedMode); if (shouldProbeCapability) { - await getGraphLocalStoreCapability(forceCapabilityRefresh === true); + await getGraphLocalStoreCapability(forceCapabilityRefresh === true, { + settings, + eagerRetry: forceCapabilityRefresh === true, + }); } const preferredLocalStore = @@ -4643,6 +4693,21 @@ function buildPanelOpenLocalStoreRefreshPlan( const resolvedIsOpfs = resolvedLocalStoreKey.startsWith("opfs:"); const preferredIsOpfs = preferredLocalStore.storagePrimary === "opfs"; const capabilityUnchecked = bmeLocalStoreCapabilitySnapshot.checked !== true; + const capabilityRetryRecommended = + usesOpfsPreference && + bmeLocalStoreCapabilitySnapshot.checked === true && + bmeLocalStoreCapabilitySnapshot.opfsAvailable !== true && + !( + String(bmeLocalStoreCapabilitySnapshot.reason || "") === + "missing-directory-handle" || + String(bmeLocalStoreCapabilitySnapshot.reason || "") === "OPFS 不可用" || + /not.?supported/i.test( + String(bmeLocalStoreCapabilitySnapshot.reason || ""), + ) || + /missing.+getdirectory/i.test( + String(bmeLocalStoreCapabilitySnapshot.reason || ""), + ) + ); const pendingPersist = graphPersistenceState.pendingPersist === true; const writesBlocked = graphPersistenceState.writesBlocked === true; const loadState = String(graphPersistenceState.loadState || ""); @@ -4658,6 +4723,7 @@ function buildPanelOpenLocalStoreRefreshPlan( const shouldRefresh = usesOpfsPreference && (capabilityUnchecked || + capabilityRetryRecommended || pendingPersist || writesBlocked || blocked || @@ -4666,6 +4732,7 @@ function buildPanelOpenLocalStoreRefreshPlan( localStoreMismatch); const forceCapabilityRefresh = capabilityUnchecked || + capabilityRetryRecommended || pendingPersist || blocked || loadingWithoutDb || @@ -4676,6 +4743,7 @@ function buildPanelOpenLocalStoreRefreshPlan( (pendingPersist || writesBlocked || blocked || Boolean(persistError) || localStoreMismatch); const reasons = []; if (capabilityUnchecked) reasons.push("capability-unchecked"); + if (capabilityRetryRecommended) reasons.push("capability-retryable-failure"); if (pendingPersist) reasons.push("pending-persist"); if (writesBlocked) reasons.push("writes-blocked"); if (blocked) reasons.push("load-blocked"); @@ -5715,6 +5783,18 @@ async function syncBmeChatManagerWithCurrentChat( source = "unknown", context = getContext(), ) { + const currentSettings = getSettings(); + const requestedMode = getRequestedGraphLocalStorageMode(currentSettings); + if ( + requestedMode === "auto" || + isGraphLocalStorageModeOpfs(requestedMode) + ) { + await getGraphLocalStoreCapability(false, { + settings: currentSettings, + eagerRetry: true, + }); + } + const manager = ensureBmeChatManager(); if (!manager) { return { diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js index a527122..1ce5ae0 100644 --- a/sync/bme-opfs-store.js +++ b/sync/bme-opfs-store.js @@ -321,12 +321,52 @@ function isNotFoundError(error) { return name === "NotFoundError" || /not.?found/i.test(message); } +function isTypeMismatchError(error) { + const name = String(error?.name || ""); + const message = String(error?.message || ""); + return ( + name === "TypeMismatchError" || + /type.?mismatch/i.test(message) || + /different file type/i.test(message) + ); +} + async function ensureDirectoryHandle(parentHandle, name) { return await parentHandle.getDirectoryHandle(String(name || ""), { create: true, }); } +async function ensureOpfsRootDirectory( + rootDirectory, + { repairFileConflict = false } = {}, +) { + if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") { + throw new Error("OPFS 根目录不可用"); + } + + try { + return await ensureDirectoryHandle(rootDirectory, OPFS_ROOT_DIRECTORY_NAME); + } catch (error) { + if (!repairFileConflict || !isTypeMismatchError(error)) { + throw error; + } + + const conflictingFile = await maybeGetFileHandle( + rootDirectory, + OPFS_ROOT_DIRECTORY_NAME, + ).catch(() => null); + if (!conflictingFile || typeof rootDirectory.removeEntry !== "function") { + throw error; + } + + await rootDirectory.removeEntry(OPFS_ROOT_DIRECTORY_NAME, { + recursive: false, + }); + return await ensureDirectoryHandle(rootDirectory, OPFS_ROOT_DIRECTORY_NAME); + } +} + async function maybeGetFileHandle(parentHandle, name) { try { return await parentHandle.getFileHandle(String(name || ""), { @@ -598,7 +638,9 @@ export async function detectOpfsSupport(options = {}) { reason: "missing-directory-handle", }; } - await ensureDirectoryHandle(rootDirectory, OPFS_ROOT_DIRECTORY_NAME); + await ensureOpfsRootDirectory(rootDirectory, { + repairFileConflict: true, + }); return { available: true, reason: "ok", @@ -1534,10 +1576,9 @@ class LegacyOpfsGraphStore { if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") { throw new Error("OPFS 根目录不可用"); } - const opfsRoot = await ensureDirectoryHandle( - rootDirectory, - OPFS_ROOT_DIRECTORY_NAME, - ); + const opfsRoot = await ensureOpfsRootDirectory(rootDirectory, { + repairFileConflict: true, + }); const chatsDirectory = await ensureDirectoryHandle( opfsRoot, OPFS_CHATS_DIRECTORY_NAME, @@ -2810,10 +2851,9 @@ export class OpfsGraphStore { if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") { throw new Error("OPFS 根目录不可用"); } - const opfsRoot = await ensureDirectoryHandle( - rootDirectory, - OPFS_ROOT_DIRECTORY_NAME, - ); + const opfsRoot = await ensureOpfsRootDirectory(rootDirectory, { + repairFileConflict: true, + }); const chatsDirectory = await ensureDirectoryHandle( opfsRoot, OPFS_CHATS_DIRECTORY_NAME, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index bf1ea7d..892a1d6 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -2186,6 +2186,47 @@ result = { assert.equal(plan.reasons.includes("resolved-store-mismatch"), true); } +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-panel-open-capability-retry", + globalChatId: "chat-panel-open-capability-retry", + chatMetadata: { + integrity: "chat-panel-open-capability-retry-integrity", + }, + }); + harness.runtimeContext.extension_settings[MODULE_NAME] = { + graphLocalStorageMode: "auto", + }; + harness.api.setLocalStoreCapabilitySnapshot({ + checked: true, + checkedAt: Date.now(), + opfsAvailable: false, + reason: "UnknownError: transient-opfs-init-failure", + }); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId: "chat-panel-open-capability-retry", + reason: "healthy", + dbReady: true, + writesBlocked: false, + pendingPersist: false, + indexedDbLastError: "", + resolvedLocalStore: "indexeddb:indexeddb", + storagePrimary: "indexeddb", + storageMode: "indexeddb", + }); + + const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan(); + + assert.equal(plan.shouldRefresh, true); + assert.equal(plan.forceCapabilityRefresh, true); + assert.equal( + plan.reasons.includes("capability-retryable-failure"), + true, + "可恢复的 OPFS 探测失败应在面板打开时触发重新探测", + ); +} + { const harness = await createGraphPersistenceHarness({ chatId: "chat-luker-panel-open", diff --git a/tests/opfs-persistence.mjs b/tests/opfs-persistence.mjs index 10f7095..d246b28 100644 --- a/tests/opfs-persistence.mjs +++ b/tests/opfs-persistence.mjs @@ -23,6 +23,12 @@ function createNotFoundError(message) { return error; } +function createTypeMismatchError(message) { + const error = new Error(String(message || "Type mismatch")); + error.name = "TypeMismatchError"; + return error; +} + class MemoryOpfsFileHandle { constructor(parent, name) { this.parent = parent; @@ -71,6 +77,11 @@ class MemoryOpfsDirectoryHandle { async getDirectoryHandle(name, options = {}) { const normalizedName = String(name || ""); + if (this.files.has(normalizedName)) { + throw createTypeMismatchError( + `A file already exists for directory: ${normalizedName}`, + ); + } let directory = this.directories.get(normalizedName) || null; if (!directory) { if (!options.create) { @@ -84,6 +95,11 @@ class MemoryOpfsDirectoryHandle { async getFileHandle(name, options = {}) { const normalizedName = String(name || ""); + if (this.directories.has(normalizedName)) { + throw createTypeMismatchError( + `A directory already exists for file: ${normalizedName}`, + ); + } if (!this.files.has(normalizedName)) { if (!options.create) { throw createNotFoundError(`File not found: ${normalizedName}`); @@ -177,6 +193,16 @@ async function testDetectOpfsSupport() { assert.equal(supported.available, true); assert.equal(supported.reason, "ok"); + const conflictedRootDirectory = createMemoryOpfsRoot(); + conflictedRootDirectory.files.set("st-bme", "legacy-conflict"); + const repairedConflict = await detectOpfsSupport({ + rootDirectoryFactory: async () => conflictedRootDirectory, + }); + assert.equal(repairedConflict.available, true); + assert.equal(repairedConflict.reason, "ok"); + assert.equal(conflictedRootDirectory.files.has("st-bme"), false); + assert.ok(conflictedRootDirectory.directories.has("st-bme")); + const missingHandle = await detectOpfsSupport({ rootDirectoryFactory: async () => ({}), });