fix: harden opfs capability recovery

This commit is contained in:
Youzini-afk
2026-04-15 22:47:26 +08:00
parent 61d1260bfc
commit e2e51c9508
4 changed files with 210 additions and 23 deletions

108
index.js
View File

@@ -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 {

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 () => ({}),
});