From c3cee28db006cf50bd4c9f07aa3e4810e1862a13 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:57:22 +0530 Subject: [PATCH] fix(settings): bound provider connection checks --- apps/desktop/electron/ai/providers.test.ts | 24 ++++++ apps/desktop/electron/ai/providers.ts | 19 ++++- .../src/settings/ProviderSettings.test.tsx | 83 +++++++++++++++++++ .../desktop/src/settings/ProviderSettings.tsx | 5 ++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/settings/ProviderSettings.test.tsx diff --git a/apps/desktop/electron/ai/providers.test.ts b/apps/desktop/electron/ai/providers.test.ts index b48256f..329c342 100644 --- a/apps/desktop/electron/ai/providers.test.ts +++ b/apps/desktop/electron/ai/providers.test.ts @@ -62,6 +62,30 @@ describe("inspectProvider", () => { expect(offline.message).toContain("ollama serve"); }); + it("aborts a provider check that exceeds its timeout", async () => { + const request = vi.fn().mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }), + ); + + const result = await inspectProvider(profile(), undefined, request, 5); + + expect(result).toEqual({ + ok: false, + message: + "Connection check timed out. Check the address and make sure LM Studio is running, then try again.", + }); + expect(request.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }); + it("requires credentials before contacting a cloud provider", async () => { const request = vi.fn(); const result = await inspectProvider( diff --git a/apps/desktop/electron/ai/providers.ts b/apps/desktop/electron/ai/providers.ts index 91db6c3..9eb530a 100644 --- a/apps/desktop/electron/ai/providers.ts +++ b/apps/desktop/electron/ai/providers.ts @@ -5,6 +5,8 @@ import { } from "../ipc/types"; import { redactSensitive, secureProviderFetch } from "./security"; +const INSPECTION_TIMEOUT_MS = 10_000; + const providerFor = (profile: ProviderProfile) => AI_PROVIDER_CATALOG.find((provider) => provider.kind === profile.kind); @@ -58,15 +60,20 @@ export async function inspectProvider( profile: ProviderProfile, secret?: string, doFetch: typeof fetch = fetch, + timeoutMs = INSPECTION_TIMEOUT_MS, ): Promise { const provider = providerFor(profile); if (!provider) return { ok: false, message: "That provider type is not supported." }; if (provider.credential === "required" && !secret) return { ok: false, message: `Add an API key for ${provider.label}, then try again.` }; + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); try { const response = await secureProviderFetch( modelsUrl(profile), - { headers: headersFor(profile, secret) }, + { headers: headersFor(profile, secret), signal: controller.signal }, doFetch, ); if (!response.ok) @@ -89,6 +96,14 @@ export async function inspectProvider( models, }; } catch (error) { + if (controller.signal.aborted) + return { + ok: false, + message: + profile.kind === "lmstudio" || profile.kind === "ollama" + ? `Connection check timed out. Check the address and make sure ${provider.label} is running, then try again.` + : "Connection check timed out. Check the address and your network, then try again.", + }; return { ok: false, message: @@ -96,5 +111,7 @@ export async function inspectProvider( ? `Could not connect to Ollama. Start it with “ollama serve”, then try again. ${redactSensitive(error)}` : `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`, }; + } finally { + clearTimeout(timeout); } } diff --git a/apps/desktop/src/settings/ProviderSettings.test.tsx b/apps/desktop/src/settings/ProviderSettings.test.tsx new file mode 100644 index 0000000..e55df3d --- /dev/null +++ b/apps/desktop/src/settings/ProviderSettings.test.tsx @@ -0,0 +1,83 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AiSettings } from "../../electron/ipc/types"; +import { ProviderSettings } from "./ProviderSettings"; + +const settings: AiSettings = { + defaultProfileId: "local", + profiles: [ + { + id: "local", + name: "Local", + kind: "lmstudio", + baseUrl: "http://127.0.0.1:1234", + model: "local/model", + }, + ], +}; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +afterEach(() => { + if (root) + act(() => { + root?.unmount(); + }); + container?.remove(); + root = undefined; + container = undefined; +}); + +describe("ProviderSettings", () => { + it("shows a recoverable error when the provider IPC call rejects", async () => { + Object.defineProperty(window, "lucid", { + configurable: true, + value: { + credentials: { + status: vi.fn().mockResolvedValue({ + present: false, + storage: "none", + securePersistenceAvailable: true, + }), + }, + providers: { + test: vi.fn().mockRejectedValue(new Error("IPC failed")), + models: vi.fn(), + }, + }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + await Promise.resolve(); + }); + const button = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === "Test connection", + ); + expect(button).toBeDefined(); + + await act(async () => { + button?.click(); + await Promise.resolve(); + }); + + expect(container.querySelector('[role="status"]')?.textContent).toBe( + "The connection check could not finish. Try again.", + ); + expect(button?.disabled).toBe(false); + }); +}); diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx index 7c47c48..558d586 100644 --- a/apps/desktop/src/settings/ProviderSettings.tsx +++ b/apps/desktop/src/settings/ProviderSettings.tsx @@ -144,6 +144,11 @@ export function ProviderSettings({ value, lmStudioUrlLocked, onChange, onPersist setModels(result.models); if (!active.model && result.models[0]) replace({ model: result.models[0] }); } + } catch { + setConnection({ + ok: false, + message: "The connection check could not finish. Try again.", + }); } finally { setBusy(undefined); }