Compare commits

...
Author SHA1 Message Date
l0ng-ai 4d596c7943 fix(plugins): don't block Find Usages on a failed plugin index batch
A plugin indexer batch that failed left its paths in the dirty set, so every
later Find Usages and rename failed with "Plugin indexes are still updating"
although nothing was running. Failed paths now move to a failed set that is
retried before the next usages or refactoring. Usages go ahead with a
"may be incomplete" note while an index is not up to date; refactoring still
refuses, with a message that says the index failed rather than that it is
updating.
2026-11-12 18:36:39 +08:00
8 changed files with 103 additions and 22 deletions

No files matched your search

+2 -2
View File
@@ -56,7 +56,7 @@ async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
async function prepareIndexes(view: EditorView, path: string): Promise<boolean> { async function prepareIndexes(view: EditorView, path: string): Promise<boolean> {
const before = view.state.doc; const before = view.state.doc;
try { try {
await synchronizeDocumentIndexes(path); await synchronizeDocumentIndexes(path, "refactor");
return view.state.doc === before; return view.state.doc === before;
} catch (e) { } catch (e) {
reportLspError(view, e, true); reportLspError(view, e, true);
@@ -269,7 +269,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
let renamed; let renamed;
const before = view.state.doc; const before = view.state.doc;
try { try {
await synchronizeDocumentIndexes(doc.path); await synchronizeDocumentIndexes(doc.path, "refactor");
if (renameRequest !== request || view.state.doc !== before) return; if (renameRequest !== request || view.state.doc !== before) return;
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName)); renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
if (renameRequest !== request) return; if (renameRequest !== request) return;
+18 -1
View File
@@ -160,7 +160,7 @@ describe("findUsages", () => {
const pending = findUsages(); const pending = findUsages();
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } }); const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
try { try {
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs")); await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs", "usages"));
expect(lspReferences).not.toHaveBeenCalled(); expect(lspReferences).not.toHaveBeenCalled();
expect(channels).toHaveLength(allocated); expect(channels).toHaveLength(allocated);
release(); release();
@@ -181,6 +181,23 @@ describe("findUsages", () => {
}); });
}); });
describe("findUsages with a contributed index that is not up to date", () => {
it("still searches, and says plugin usages may be missing", async () => {
const off = registerDocumentIndexSync(async () => false);
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
try {
await findUsages();
const search = activeUsageSearch(useUsages.getState())!;
expect(search.groups).toHaveLength(1);
expect(search.note).toBe("May be incomplete: plugin indexes are still updating");
} finally {
off();
resetUsages();
}
});
});
describe("usage tabs", () => { describe("usage tabs", () => {
const search = (id: number) => ({ const search = (id: number) => ({
id, id,
+3 -1
View File
@@ -6,6 +6,7 @@ import { clearLineTokens } from "@/editor/lineTokens";
import { showPendingHint } from "@/editor/lsp/hint"; import { showPendingHint } from "@/editor/lsp/hint";
import { incompleteNote } from "@/editor/lsp/lsp"; import { incompleteNote } from "@/editor/lsp/lsp";
import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc"; import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap"; import { shortcutLabel } from "@/platform/keymap";
import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions"; import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions";
import { import {
@@ -230,7 +231,7 @@ export async function fetchUsages(
let note: string | null = null; let note: string | null = null;
let librariesSkipped = false; let librariesSkipped = false;
await caretSynced(c); await caretSynced(c);
await synchronizeDocumentIndexes(c.path); const indexed = await synchronizeDocumentIndexes(c.path, "usages");
if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null }); if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null });
let settled = false; let settled = false;
const channel = new Channel<References>(); const channel = new Channel<References>();
@@ -268,6 +269,7 @@ export async function fetchUsages(
locs = fb.locs; locs = fb.locs;
note = fb.note; note = fb.note;
} }
if (!indexed) note ??= tx("May be incomplete: plugin indexes are still updating");
const files = await readManyFileLines(pathsToRead(locs, classified)); const files = await readManyFileLines(pathsToRead(locs, classified));
return { groups: groupUsages(locs, files, classified), note, librariesSkipped }; return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
} }
+31 -5
View File
@@ -135,7 +135,7 @@ it("flushes pending changes before usages or rename without waiting for the debo
await vi.advanceTimersByTimeAsync(250); await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear(); mocks.index.mockClear();
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] }); mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await synchronizeDocumentIndexes("/project/A.xml"); await synchronizeDocumentIndexes("/project/A.xml", "refactor");
expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number)); expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number));
}); });
@@ -153,7 +153,7 @@ it("waits for an active batch, then indexes changes queued while it was running"
await vi.advanceTimersByTimeAsync(40); await vi.advanceTimersByTimeAsync(40);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] }); mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] });
let settled = false; let settled = false;
const waiting = synchronizeDocumentIndexes("/project/A.xml").then(() => { const waiting = synchronizeDocumentIndexes("/project/A.xml", "refactor").then(() => {
settled = true; settled = true;
}); });
await Promise.resolve(); await Promise.resolve();
@@ -168,7 +168,33 @@ it("refuses refactoring if a queued index batch failed", async () => {
await vi.advanceTimersByTimeAsync(250); await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } }); mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] }); mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await expect(synchronizeDocumentIndexes("/project/A.xml")).rejects.toThrow("Plugin indexes are still updating"); await expect(synchronizeDocumentIndexes("/project/A.xml", "refactor")).rejects.toThrow(
"Plugin indexes could not be updated",
);
});
it("lets usages go ahead, marked incomplete, while a failed batch keeps failing", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
const failure = { status: "error", error: { kind: "unavailable", message: "indexer failed" } };
mocks.index.mockResolvedValue(failure);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
// Every later search used to fail with "still updating" although nothing was running.
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
});
it("retries a failed batch before the next usages or refactoring", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
mocks.index.mockClear();
await expect(synchronizeDocumentIndexes("/project/A.java", "refactor")).resolves.toBe(true);
expect(mocks.index).toHaveBeenCalledWith(["/project/A.xml"], expect.any(Number));
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(true);
}); });
it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])( it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])(
@@ -179,13 +205,13 @@ it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"
mocks.index.mockImplementationOnce(() => new Promise(() => {})); mocks.index.mockImplementationOnce(() => new Promise(() => {}));
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] }); mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40); await vi.advanceTimersByTimeAsync(40);
await synchronizeDocumentIndexes(path); await synchronizeDocumentIndexes(path, "refactor");
}, },
); );
it("keeps unrelated language-server queries available when plugin discovery fails", async () => { it("keeps unrelated language-server queries available when plugin discovery fails", async () => {
mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable")); mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable"));
off = startPluginNavigation(); off = startPluginNavigation();
await expect(synchronizeDocumentIndexes("/project/notes.txt")).resolves.toBeUndefined(); await expect(synchronizeDocumentIndexes("/project/notes.txt", "refactor")).resolves.toBe(true);
expect(mocks.index).not.toHaveBeenCalled(); expect(mocks.index).not.toHaveBeenCalled();
}); });
+31 -6
View File
@@ -22,9 +22,12 @@ export function startPluginNavigation(): () => void {
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let pending = new Set<string>(); let pending = new Set<string>();
const dirty = new Set<string>(); const dirty = new Set<string>();
/** Paths whose last index batch failed: retried on the next usages or refactoring. */
const failed = new Set<string>();
const views = new Map<EditorView, string>(); const views = new Map<EditorView, string>();
let discovered = false; let discovered = false;
let discovering = false; let discovering = false;
let discoveryFailed = false;
const known = new Set<string>(); const known = new Set<string>();
let signature = ""; let signature = "";
let reportedError = false; let reportedError = false;
@@ -79,13 +82,22 @@ export function startPluginNavigation(): () => void {
await call(commands.pluginNavigationIndex(paths, id)); await call(commands.pluginNavigationIndex(paths, id));
if (at === generation) { if (at === generation) {
paths.forEach((p) => { paths.forEach((p) => {
failed.delete(p);
if (!pending.has(p)) dirty.delete(p); if (!pending.has(p)) dirty.delete(p);
}); });
notify(); notify();
} }
} catch (error) { } catch (error) {
if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p)); if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p));
else if (at === generation) report(error); else if (at === generation) {
// Not "still updating": the batch is done, its paths just have no fresh index.
paths.forEach((p) => {
if (pending.has(p)) return;
dirty.delete(p);
failed.add(p);
});
report(error);
}
} finally { } finally {
request = null; request = null;
running = false; running = false;
@@ -120,12 +132,14 @@ export function startPluginNavigation(): () => void {
if (at !== generation) return; if (at !== generation) return;
fileExtensions = new Set(inventory.extensions); fileExtensions = new Set(inventory.extensions);
discovered = !inventory.indexing; discovered = !inventory.indexing;
discoveryFailed = false;
const fresh = inventory.paths.filter((p) => !known.has(p)); const fresh = inventory.paths.filter((p) => !known.has(p));
inventory.paths.forEach((p) => known.add(p)); inventory.paths.forEach((p) => known.add(p));
queue(fresh); queue(fresh);
queue([...views.values()], true); queue([...views.values()], true);
} catch (error) { } catch (error) {
discovered = false; discovered = false;
discoveryFailed = true;
report(error); report(error);
} finally { } finally {
if (at === generation) discovering = false; if (at === generation) discovering = false;
@@ -139,9 +153,11 @@ export function startPluginNavigation(): () => void {
clearTimeout(timer); clearTimeout(timer);
pending.clear(); pending.clear();
dirty.clear(); dirty.clear();
failed.clear();
known.clear(); known.clear();
discovered = false; discovered = false;
discovering = false; discovering = false;
discoveryFailed = false;
discovery = null; discovery = null;
reportedError = false; reportedError = false;
notify(); notify();
@@ -151,32 +167,41 @@ export function startPluginNavigation(): () => void {
.catch(() => {}); .catch(() => {});
} }
const offs = [ const offs = [
registerDocumentIndexSync(async (path) => { registerDocumentIndexSync(async (path, purpose) => {
if (!ready()) return; if (!ready()) return;
const root = useProject.getState().project!.root; const root = useProject.getState().project!.root;
if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return; if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return;
const at = generation; const at = generation;
await discover(); await discover();
if (!eligible(path)) return; if (!eligible(path)) return;
if (failed.size) queue([...failed]);
while (ready() && at === generation && (pending.size || flushing)) { while (ready() && at === generation && (pending.size || flushing)) {
clearTimeout(timer); clearTimeout(timer);
await flush(); await flush();
} }
if (at !== generation || !discovered || dirty.size) { const complete = at === generation && discovered && !dirty.size && !failed.size;
throw new Error(tx`Plugin indexes are still updating; try again shortly`); if (complete) return true;
if (purpose === "usages") return false;
if (at === generation && (failed.size || discoveryFailed)) {
throw new Error(tx`Plugin indexes could not be updated; see the plugin notification`);
} }
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
}), }),
pluginsBackend().onEvent((event) => { pluginsBackend().onEvent((event) => {
if (event.type === "documentIndexesChanged") reset(); if (event.type === "documentIndexesChanged") reset();
}), }),
registerNavigationProvider(async (view, offset) => { registerNavigationProvider(async (view, offset) => {
const doc = docOf(view); const doc = docOf(view);
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null; if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path) || failed.has(doc.path))
return null;
const before = view.state.doc; const before = view.state.doc;
await whenSynced(view); await whenSynced(view);
const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset))); const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset)));
if (!result || view.state.doc !== before) return null; if (!result || view.state.doc !== before) return null;
return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) }; return {
...rangeToOffsets(before, result.range),
targets: result.targets.filter((t) => !dirty.has(t.path) && !failed.has(t.path)),
};
}), }),
registerEditorContribution({ registerEditorContribution({
id: "plugins.navigation", id: "plugins.navigation",
+3 -1
View File
@@ -1344,5 +1344,7 @@
"Close Agent Task": "Close Agent Task", "Close Agent Task": "Close Agent Task",
"Close and New Agent Task": "Close and New Agent Task", "Close and New Agent Task": "Close and New Agent Task",
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.", "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page." "Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification"
} }
+3 -1
View File
@@ -7731,5 +7731,7 @@
"Download Remote Files Into Folder": "将远程文件下载到文件夹", "Download Remote Files Into Folder": "将远程文件下载到文件夹",
"Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })", "Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })",
"Delegate IDE build/run actions to Maven": "将 IDE 构建/运行操作委托给 Maven", "Delegate IDE build/run actions to Maven": "将 IDE 构建/运行操作委托给 Maven",
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。" "Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。",
"May be incomplete: plugin indexes are still updating": "可能不完整:插件索引仍在更新",
"Plugin indexes could not be updated; see the plugin notification": "插件索引更新失败,详见插件通知"
} }
+12 -5
View File
@@ -1825,16 +1825,23 @@ export function addContributedFileTemplate(t: import("@/platform/fileTemplates")
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) })); return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
} }
/** Flush contributed document indexes before workspace usages or refactoring. */ /**
const documentIndexSync = new Set<(path: string) => Promise<void>>(); * Flush contributed document indexes before workspace usages or refactoring. A refactoring
export function registerDocumentIndexSync(sync: (path: string) => Promise<void>): () => void { * refuses (throws) while an index is incomplete; usages go ahead and resolve `false`, so the
* results can say they may be incomplete.
*/
export type DocumentIndexPurpose = "usages" | "refactor";
type DocumentIndexSync = (path: string, purpose: DocumentIndexPurpose) => Promise<boolean | void>;
const documentIndexSync = new Set<DocumentIndexSync>();
export function registerDocumentIndexSync(sync: DocumentIndexSync): () => void {
documentIndexSync.add(sync); documentIndexSync.add(sync);
return () => { return () => {
documentIndexSync.delete(sync); documentIndexSync.delete(sync);
}; };
} }
export async function synchronizeDocumentIndexes(path: string): Promise<void> { export async function synchronizeDocumentIndexes(path: string, purpose: DocumentIndexPurpose): Promise<boolean> {
await Promise.all([...documentIndexSync].map((sync) => sync(path))); const complete = await Promise.all([...documentIndexSync].map((sync) => sync(path, purpose)));
return complete.every((c) => c !== false);
} }
/** Font zoom for non-CodeMirror surfaces, owned and released by their features. */ /** Font zoom for non-CodeMirror surfaces, owned and released by their features. */