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", reason: "unprobed",
}; };
let bmeLocalStoreCapabilityWarningShown = false; let bmeLocalStoreCapabilityWarningShown = false;
const BME_LOCAL_STORE_CAPABILITY_FAILURE_RETRY_MS = 4000;
const bmeIndexedDbSnapshotCacheByChatId = new Map(); const bmeIndexedDbSnapshotCacheByChatId = new Map();
const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbLoadInFlightByChatId = new Map();
const bmeIndexedDbWriteInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map();
@@ -4425,23 +4426,65 @@ function isCachedIndexedDbSnapshotCompatible(snapshot = null, expectedStore = nu
} }
async function getGraphLocalStoreCapability(forceRefresh = false) { 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; return bmeLocalStoreCapabilitySnapshot;
} }
if (!forceRefresh && bmeLocalStoreCapabilityPromise) { if (!forceRefresh && !shouldRetryFailedProbe && bmeLocalStoreCapabilityPromise) {
return await bmeLocalStoreCapabilityPromise; return await bmeLocalStoreCapabilityPromise;
} }
bmeLocalStoreCapabilityPromise = detectOpfsSupport() bmeLocalStoreCapabilityPromise = detectOpfsSupport()
.then((result) => { .then((result) => {
bmeLocalStoreCapabilitySnapshot = { bmeLocalStoreCapabilitySnapshot = {
checked: true, checked: true,
checkedAt: Date.now(), checkedAt: Date.now(),
opfsAvailable: Boolean(result?.available), opfsAvailable: Boolean(result?.available),
reason: String(result?.reason || (result?.available ? "ok" : "unavailable")), reason: String(result?.reason || (result?.available ? "ok" : "unavailable")),
}; };
return bmeLocalStoreCapabilitySnapshot; if (bmeLocalStoreCapabilitySnapshot.opfsAvailable) {
}) bmeLocalStoreCapabilityWarningShown = false;
}
return bmeLocalStoreCapabilitySnapshot;
})
.catch((error) => { .catch((error) => {
bmeLocalStoreCapabilitySnapshot = { bmeLocalStoreCapabilitySnapshot = {
checked: true, checked: true,
@@ -4480,7 +4523,9 @@ function getPreferredGraphLocalStorePresentationSync(settings = getSettings()) {
) { ) {
const requestedMode = getRequestedGraphLocalStorageMode(settings); const requestedMode = getRequestedGraphLocalStorageMode(settings);
if (requestedMode === "auto") { if (requestedMode === "auto") {
const capability = await getGraphLocalStoreCapability(); const capability = await getGraphLocalStoreCapability(false, {
settings,
});
return capability.opfsAvailable return capability.opfsAvailable
? buildOpfsStorePresentation(BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY) ? buildOpfsStorePresentation(BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY)
: buildIndexedDbStorePresentation(); : buildIndexedDbStorePresentation();
@@ -4489,7 +4534,9 @@ function getPreferredGraphLocalStorePresentationSync(settings = getSettings()) {
return buildIndexedDbStorePresentation(); return buildIndexedDbStorePresentation();
} }
const capability = await getGraphLocalStoreCapability(); const capability = await getGraphLocalStoreCapability(false, {
settings,
});
if (capability.opfsAvailable) { if (capability.opfsAvailable) {
return buildOpfsStorePresentation(requestedMode); return buildOpfsStorePresentation(requestedMode);
} }
@@ -4536,7 +4583,10 @@ async function refreshCurrentChatLocalStoreBinding(
isGraphLocalStorageModeOpfs(requestedMode); isGraphLocalStorageModeOpfs(requestedMode);
if (shouldProbeCapability) { if (shouldProbeCapability) {
await getGraphLocalStoreCapability(forceCapabilityRefresh === true); await getGraphLocalStoreCapability(forceCapabilityRefresh === true, {
settings,
eagerRetry: forceCapabilityRefresh === true,
});
} }
const preferredLocalStore = const preferredLocalStore =
@@ -4643,6 +4693,21 @@ function buildPanelOpenLocalStoreRefreshPlan(
const resolvedIsOpfs = resolvedLocalStoreKey.startsWith("opfs:"); const resolvedIsOpfs = resolvedLocalStoreKey.startsWith("opfs:");
const preferredIsOpfs = preferredLocalStore.storagePrimary === "opfs"; const preferredIsOpfs = preferredLocalStore.storagePrimary === "opfs";
const capabilityUnchecked = bmeLocalStoreCapabilitySnapshot.checked !== true; 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 pendingPersist = graphPersistenceState.pendingPersist === true;
const writesBlocked = graphPersistenceState.writesBlocked === true; const writesBlocked = graphPersistenceState.writesBlocked === true;
const loadState = String(graphPersistenceState.loadState || ""); const loadState = String(graphPersistenceState.loadState || "");
@@ -4658,6 +4723,7 @@ function buildPanelOpenLocalStoreRefreshPlan(
const shouldRefresh = const shouldRefresh =
usesOpfsPreference && usesOpfsPreference &&
(capabilityUnchecked || (capabilityUnchecked ||
capabilityRetryRecommended ||
pendingPersist || pendingPersist ||
writesBlocked || writesBlocked ||
blocked || blocked ||
@@ -4666,6 +4732,7 @@ function buildPanelOpenLocalStoreRefreshPlan(
localStoreMismatch); localStoreMismatch);
const forceCapabilityRefresh = const forceCapabilityRefresh =
capabilityUnchecked || capabilityUnchecked ||
capabilityRetryRecommended ||
pendingPersist || pendingPersist ||
blocked || blocked ||
loadingWithoutDb || loadingWithoutDb ||
@@ -4676,6 +4743,7 @@ function buildPanelOpenLocalStoreRefreshPlan(
(pendingPersist || writesBlocked || blocked || Boolean(persistError) || localStoreMismatch); (pendingPersist || writesBlocked || blocked || Boolean(persistError) || localStoreMismatch);
const reasons = []; const reasons = [];
if (capabilityUnchecked) reasons.push("capability-unchecked"); if (capabilityUnchecked) reasons.push("capability-unchecked");
if (capabilityRetryRecommended) reasons.push("capability-retryable-failure");
if (pendingPersist) reasons.push("pending-persist"); if (pendingPersist) reasons.push("pending-persist");
if (writesBlocked) reasons.push("writes-blocked"); if (writesBlocked) reasons.push("writes-blocked");
if (blocked) reasons.push("load-blocked"); if (blocked) reasons.push("load-blocked");
@@ -5715,6 +5783,18 @@ async function syncBmeChatManagerWithCurrentChat(
source = "unknown", source = "unknown",
context = getContext(), context = getContext(),
) { ) {
const currentSettings = getSettings();
const requestedMode = getRequestedGraphLocalStorageMode(currentSettings);
if (
requestedMode === "auto" ||
isGraphLocalStorageModeOpfs(requestedMode)
) {
await getGraphLocalStoreCapability(false, {
settings: currentSettings,
eagerRetry: true,
});
}
const manager = ensureBmeChatManager(); const manager = ensureBmeChatManager();
if (!manager) { if (!manager) {
return { return {

View File

@@ -321,12 +321,52 @@ function isNotFoundError(error) {
return name === "NotFoundError" || /not.?found/i.test(message); 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) { async function ensureDirectoryHandle(parentHandle, name) {
return await parentHandle.getDirectoryHandle(String(name || ""), { return await parentHandle.getDirectoryHandle(String(name || ""), {
create: true, 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) { async function maybeGetFileHandle(parentHandle, name) {
try { try {
return await parentHandle.getFileHandle(String(name || ""), { return await parentHandle.getFileHandle(String(name || ""), {
@@ -598,7 +638,9 @@ export async function detectOpfsSupport(options = {}) {
reason: "missing-directory-handle", reason: "missing-directory-handle",
}; };
} }
await ensureDirectoryHandle(rootDirectory, OPFS_ROOT_DIRECTORY_NAME); await ensureOpfsRootDirectory(rootDirectory, {
repairFileConflict: true,
});
return { return {
available: true, available: true,
reason: "ok", reason: "ok",
@@ -1534,10 +1576,9 @@ class LegacyOpfsGraphStore {
if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") { if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") {
throw new Error("OPFS 根目录不可用"); throw new Error("OPFS 根目录不可用");
} }
const opfsRoot = await ensureDirectoryHandle( const opfsRoot = await ensureOpfsRootDirectory(rootDirectory, {
rootDirectory, repairFileConflict: true,
OPFS_ROOT_DIRECTORY_NAME, });
);
const chatsDirectory = await ensureDirectoryHandle( const chatsDirectory = await ensureDirectoryHandle(
opfsRoot, opfsRoot,
OPFS_CHATS_DIRECTORY_NAME, OPFS_CHATS_DIRECTORY_NAME,
@@ -2810,10 +2851,9 @@ export class OpfsGraphStore {
if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") { if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") {
throw new Error("OPFS 根目录不可用"); throw new Error("OPFS 根目录不可用");
} }
const opfsRoot = await ensureDirectoryHandle( const opfsRoot = await ensureOpfsRootDirectory(rootDirectory, {
rootDirectory, repairFileConflict: true,
OPFS_ROOT_DIRECTORY_NAME, });
);
const chatsDirectory = await ensureDirectoryHandle( const chatsDirectory = await ensureDirectoryHandle(
opfsRoot, opfsRoot,
OPFS_CHATS_DIRECTORY_NAME, OPFS_CHATS_DIRECTORY_NAME,

View File

@@ -2186,6 +2186,47 @@ result = {
assert.equal(plan.reasons.includes("resolved-store-mismatch"), true); 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({ const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-panel-open", chatId: "chat-luker-panel-open",

View File

@@ -23,6 +23,12 @@ function createNotFoundError(message) {
return error; return error;
} }
function createTypeMismatchError(message) {
const error = new Error(String(message || "Type mismatch"));
error.name = "TypeMismatchError";
return error;
}
class MemoryOpfsFileHandle { class MemoryOpfsFileHandle {
constructor(parent, name) { constructor(parent, name) {
this.parent = parent; this.parent = parent;
@@ -71,6 +77,11 @@ class MemoryOpfsDirectoryHandle {
async getDirectoryHandle(name, options = {}) { async getDirectoryHandle(name, options = {}) {
const normalizedName = String(name || ""); 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; let directory = this.directories.get(normalizedName) || null;
if (!directory) { if (!directory) {
if (!options.create) { if (!options.create) {
@@ -84,6 +95,11 @@ class MemoryOpfsDirectoryHandle {
async getFileHandle(name, options = {}) { async getFileHandle(name, options = {}) {
const normalizedName = String(name || ""); const normalizedName = String(name || "");
if (this.directories.has(normalizedName)) {
throw createTypeMismatchError(
`A directory already exists for file: ${normalizedName}`,
);
}
if (!this.files.has(normalizedName)) { if (!this.files.has(normalizedName)) {
if (!options.create) { if (!options.create) {
throw createNotFoundError(`File not found: ${normalizedName}`); throw createNotFoundError(`File not found: ${normalizedName}`);
@@ -177,6 +193,16 @@ async function testDetectOpfsSupport() {
assert.equal(supported.available, true); assert.equal(supported.available, true);
assert.equal(supported.reason, "ok"); 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({ const missingHandle = await detectOpfsSupport({
rootDirectoryFactory: async () => ({}), rootDirectoryFactory: async () => ({}),
}); });