Repository: oliverschwendener/ueli
Pull request #1440 (2025-06-05 08:13:08)

fix: recover from uncaught extension errors
If any extension raises an uncaught exception while generating search results, the user is presented with an ugly stack trace and the main UI stops updating (see e.g. #1411):

![error](https://github.com/user-attachments/assets/5a300d8d-e775-4bce-b46b-0f53da76b4c1)

This PR simply catches exceptions that surface while iterating over extensions during search result retrieval and logs the errors instead of dying. No results are displayed from the offending extension:

![success_log](https://github.com/user-attachments/assets/7989d84c-5d33-431f-b526-a1b202f9d905)

<img width="712" alt="success" src="https://github.com/user-attachments/assets/f2a84c61-4f2a-482e-9d51-239c62e44a71" />



Patch:
diff --git a/src/main/Core/ExtensionManager/ExtensionManager.ts b/src/main/Core/ExtensionManager/ExtensionManager.ts
index e1c6bdf9..310bc864 100644
--- a/src/main/Core/ExtensionManager/ExtensionManager.ts
+++ b/src/main/Core/ExtensionManager/ExtensionManager.ts
@@ -60,9 +60,17 @@ export class ExtensionManager {
         const result: InstantSearchResultItems = createEmptyInstantSearchResult();
 
         for (const extension of this.getEnabledExtensions()) {
-            const instantSearchResultItems: InstantSearchResultItems = extension.getInstantSearchResultItems
-                ? extension.getInstantSearchResultItems(searchTerm)
-                : createEmptyInstantSearchResult();
+            let instantSearchResultItems: InstantSearchResultItems = createEmptyInstantSearchResult();
+
+            try {
+                instantSearchResultItems = extension.getInstantSearchResultItems
+                    ? extension.getInstantSearchResultItems(searchTerm)
+                    : createEmptyInstantSearchResult();
+            } catch (error) {
+                this.logger.error(
+                    `Unable to retrieve instant search result items from extension "${extension.id}". Reason: ${error}`,
+                );
+            }
 
             for (const searchResultItem of instantSearchResultItems.after) {
                 result.after.push(searchResultItem);


Test patch:
diff --git a/src/main/Core/ExtensionManager/ExtensionManager.test.ts b/src/main/Core/ExtensionManager/ExtensionManager.test.ts
index ec67d18f..ba19bade 100644
--- a/src/main/Core/ExtensionManager/ExtensionManager.test.ts
+++ b/src/main/Core/ExtensionManager/ExtensionManager.test.ts
@@ -106,4 +106,49 @@ describe(ExtensionManager, () => {
         expect(getInstantSearchResultItemsMock).toHaveBeenCalledTimes(2);
         expect(getInstantSearchResultItemsMock).toHaveBeenCalledWith("search term");
     });
+
+    it("should survive an uncaught exception from an extension", () => {
+        const getInstantSearchResultItem = (extensionId: string): SearchResultItem =>
+            <SearchResultItem>{ id: `instant-result-item-${extensionId}` };
+
+        const getInstantSearchResultItemsMock = vi.fn();
+
+        const extension1 = <Extension>{
+            id: "extension1",
+            isSupported: () => true,
+            getInstantSearchResultItems: (s): InstantSearchResultItems => {
+                getInstantSearchResultItemsMock(s);
+                return { after: [getInstantSearchResultItem("extension1")], before: [] };
+            },
+        };
+
+        const extension2 = <Extension>{
+            id: "extension2",
+            isSupported: () => true,
+            getInstantSearchResultItems: (s): InstantSearchResultItems => {
+                getInstantSearchResultItemsMock(s);
+                throw new Error("simulated error");
+            },
+        };
+
+        const extensionRegistry = <ExtensionRegistry>{ getAll: () => [extension1, extension2] };
+        const getValueMock = vi.fn().mockReturnValue([extension1.id, extension2.id]);
+        const settingsManager = <SettingsManager>{ getValue: (key, defaultValue) => getValueMock(key, defaultValue) };
+        const mockedLogger = <Logger>({ error: vi.fn() } as unknown);
+
+        const extensionManager = new ExtensionManager(
+            extensionRegistry,
+            <SearchIndex>{},
+            settingsManager,
+            mockedLogger,
+        );
+
+        const { after } = extensionManager.getInstantSearchResultItems("search term");
+
+        expect(after).toEqual([getInstantSearchResultItem("extension1")]);
+        expect(getInstantSearchResultItemsMock).toHaveBeenCalledTimes(2);
+        expect(getInstantSearchResultItemsMock).toHaveBeenCalledWith("search term");
+        expect(mockedLogger.error).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("simulated error"));
+        expect(mockedLogger.error).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("extension2"));
+    });
 });