From 92c440fba5cf6b378b1138a6f5b6ae854eb13647 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Fri, 22 Aug 2025 11:32:03 -0400 Subject: [PATCH 1/3] Improve handling misconfigured Swift toolchain This patch makes a few improvements around handling a misconfigured toolchain. - Register the Select Toolchain command before prompting the user with an error dialog during extension activation. There was a button in the dialog to Select Toolchain, but the command wasn't registered yet so they'd get an error. - Prompt the user with this same error dialog if they have a misconfigured toolchain for a specific folder. Typically this happens when the user adds a folder to an existing workspace, and that folder has a misconfigured toolchain in the `.vscode/settings.json` file. --- src/FolderContext.ts | 36 +++- src/extension.ts | 27 ++- src/ui/ToolchainSelection.ts | 10 +- test/integration-tests/FolderContext.test.ts | 184 +++++++++++++++++++ 4 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 test/integration-tests/FolderContext.test.ts diff --git a/src/FolderContext.ts b/src/FolderContext.ts index 3a0d465c8..ca6df30fd 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -26,6 +26,7 @@ import { isPathInsidePath } from "./utilities/filesystem"; import { SwiftToolchain } from "./toolchain/toolchain"; import { SwiftLogger } from "./logging/SwiftLogger"; import { TestRunProxy } from "./TestExplorer/TestRunner"; +import { showToolchainError } from "./ui/ToolchainSelection"; export class FolderContext implements vscode.Disposable { public backgroundCompilation: BackgroundCompilation; @@ -77,7 +78,40 @@ export class FolderContext implements vscode.Disposable { const statusItemText = `Loading Package (${FolderContext.uriName(folder)})`; workspaceContext.statusItem.start(statusItemText); - const toolchain = await SwiftToolchain.create(folder); + let toolchain: SwiftToolchain; + try { + toolchain = await SwiftToolchain.create(folder); + } catch (error) { + // This error case is quite hard for the user to get in to, but possible. + // Typically on startup the toolchain creation failure is going to happen in + // the extension activation in extension.ts. However if they incorrectly configure + // their path post activation, and add a new folder to the workspace, this failure can occur. + workspaceContext.logger.error( + `Failed to discover Swift toolchain for ${FolderContext.uriName(folder)}: ${error}`, + FolderContext.uriName(folder) + ); + const userMadeSelection = await showToolchainError(folder); + if (userMadeSelection) { + // User updated toolchain settings, retry once + try { + toolchain = await SwiftToolchain.create(folder); + workspaceContext.logger.info( + `Successfully created toolchain for ${FolderContext.uriName(folder)} after user selection`, + FolderContext.uriName(folder) + ); + } catch (retryError) { + workspaceContext.logger.error( + `Failed to create toolchain for ${FolderContext.uriName(folder)} even after user selection: ${retryError}`, + FolderContext.uriName(folder) + ); + // Fall back to global toolchain + toolchain = workspaceContext.globalToolchain; + } + } else { + toolchain = workspaceContext.globalToolchain; + } + } + const { linuxMain, swiftPackage } = await workspaceContext.statusItem.showStatusWhileRunning(statusItemText, async () => { const linuxMain = await LinuxMain.create(folder); diff --git a/src/extension.ts b/src/extension.ts index 6c1f35a2f..c50b3c721 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -71,15 +71,24 @@ export async function activate(context: vscode.ExtensionContext): Promise { // This can happen if the user has not installed Swift or if the toolchain is not // properly configured. if (!toolchain) { - void showToolchainError(); - return { - workspaceContext: undefined, - logger, - activate: () => activate(context), - deactivate: async () => { - await deactivate(context); - }, - }; + // In order to select a toolchain we need to register the command first. + const subscriptions = commands.registerToolchainCommands(undefined, logger, undefined); + const choseRemediation = await showToolchainError(); + subscriptions.forEach(sub => sub.dispose()); + + // If they tried to fix the improperly configured toolchain, re-initialize the extension. + if (choseRemediation) { + return activate(context); + } else { + return { + workspaceContext: undefined, + logger, + activate: () => activate(context), + deactivate: async () => { + await deactivate(context); + }, + }; + } } const workspaceContext = new WorkspaceContext(context, logger, toolchain); diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index e42b4f410..8b3cdf414 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -71,27 +71,31 @@ export async function selectToolchainFolder() { /** * Displays an error notification to the user that toolchain discovery failed. + * @returns true if the user made a selection (and potentially updated toolchain settings), false if they dismissed the dialog */ -export async function showToolchainError(): Promise { +export async function showToolchainError(folder?: vscode.Uri): Promise { let selected: "Remove From Settings" | "Select Toolchain" | undefined; if (configuration.path) { selected = await vscode.window.showErrorMessage( - `The Swift executable at "${configuration.path}" either could not be found or failed to launch. Please select a new toolchain.`, + `${folder ? `${path.basename(folder.path)}: ` : ""}The Swift executable at "${configuration.path}" either could not be found or failed to launch. Please select a new toolchain.`, "Remove From Settings", "Select Toolchain" ); } else { selected = await vscode.window.showErrorMessage( - "Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.", + `${folder ? `${path.basename(folder.path)}: ` : ""}Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.`, "Select Toolchain" ); } if (selected === "Remove From Settings") { await removeToolchainPath(); + return true; } else if (selected === "Select Toolchain") { await selectToolchain(); + return true; } + return false; } export async function selectToolchain() { diff --git a/test/integration-tests/FolderContext.test.ts b/test/integration-tests/FolderContext.test.ts new file mode 100644 index 000000000..8e46e118a --- /dev/null +++ b/test/integration-tests/FolderContext.test.ts @@ -0,0 +1,184 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2021-2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as assert from "assert"; +import * as toolchain from "../../src/ui/ToolchainSelection"; +import { afterEach } from "mocha"; +import { stub, restore } from "sinon"; +import { testAssetUri } from "../fixtures"; +import { WorkspaceContext } from "../../src/WorkspaceContext"; +import { FolderContext } from "../../src/FolderContext"; +import { SwiftToolchain } from "../../src/toolchain/toolchain"; +import { activateExtensionForSuite, getRootWorkspaceFolder } from "./utilities/testutilities"; +import { MockedFunction, mockGlobalValue } from "../MockUtils"; + +suite("FolderContext Error Handling Test Suite", () => { + let workspaceContext: WorkspaceContext; + let swiftToolchainCreateStub: MockedFunction; + const showToolchainError = mockGlobalValue(toolchain, "showToolchainError"); + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + this.timeout(60000); + }, + testAssets: ["defaultPackage"], + }); + + afterEach(() => { + restore(); + }); + + test("handles SwiftToolchain.create failure gracefully with user dismissal", async () => { + const mockError = new Error("Mock toolchain failure"); + swiftToolchainCreateStub = stub(SwiftToolchain, "create").throws(mockError); + + // Mock showToolchainError to return false (user dismissed dialog) + const showToolchainErrorStub = stub().resolves(false); + showToolchainError.setValue(showToolchainErrorStub); + + const workspaceFolder = getRootWorkspaceFolder(); + const testFolder = testAssetUri("package2"); + + const folderContext = await FolderContext.create( + testFolder, + workspaceFolder, + workspaceContext + ); + + assert.ok(folderContext, "FolderContext should be created despite toolchain failure"); + assert.strictEqual( + folderContext.toolchain, + workspaceContext.globalToolchain, + "Should fallback to global toolchain when user dismisses dialog" + ); + + const errorLogs = workspaceContext.logger.logs.filter( + log => + log.includes("Failed to discover Swift toolchain") && + log.includes("package2") && + log.includes("Mock toolchain failure") + ); + assert.ok(errorLogs.length > 0, "Should log error message with folder context"); + + assert.ok( + swiftToolchainCreateStub.calledWith(testFolder), + "Should attempt to create toolchain for specific folder" + ); + assert.strictEqual( + swiftToolchainCreateStub.callCount, + 1, + "Should only call SwiftToolchain.create once when user dismisses" + ); + }); + + test("retries toolchain creation when user makes selection and succeeds", async () => { + const workspaceFolder = getRootWorkspaceFolder(); + const testFolder = testAssetUri("package2"); + + // Arrange: Mock SwiftToolchain.create to fail first time, succeed second time + swiftToolchainCreateStub = stub(SwiftToolchain, "create"); + swiftToolchainCreateStub.onFirstCall().throws(new Error("Initial toolchain failure")); + swiftToolchainCreateStub + .onSecondCall() + .returns(Promise.resolve(workspaceContext.globalToolchain)); + + // Mock showToolchainError to return true (user made selection) + const showToolchainErrorStub = stub().resolves(true); + showToolchainError.setValue(showToolchainErrorStub); + + const folderContext = await FolderContext.create( + testFolder, + workspaceFolder, + workspaceContext + ); + + // Assert: FolderContext should be created successfully + assert.ok(folderContext, "FolderContext should be created after retry"); + assert.strictEqual( + folderContext.toolchain, + workspaceContext.globalToolchain, + "Should use successfully created toolchain after retry" + ); + + // Assert: SwiftToolchain.create should be called twice (initial + retry) + assert.strictEqual( + swiftToolchainCreateStub.callCount, + 2, + "Should retry toolchain creation after user selection" + ); + + // Assert: Should log both failure and success + const failureLogs = workspaceContext.logger.logs.filter(log => + log.includes("Failed to discover Swift toolchain for package2") + ); + const successLogs = workspaceContext.logger.logs.filter(log => + log.includes("Successfully created toolchain for package2 after user selection") + ); + + assert.ok(failureLogs.length > 0, "Should log initial failure"); + assert.ok(successLogs.length > 0, "Should log success after retry"); + }); + + test("retries toolchain creation when user makes selection but still fails", async () => { + const workspaceFolder = getRootWorkspaceFolder(); + const testFolder = testAssetUri("package2"); + + const initialError = new Error("Initial toolchain failure"); + const retryError = new Error("Retry toolchain failure"); + swiftToolchainCreateStub = stub(SwiftToolchain, "create"); + swiftToolchainCreateStub.onFirstCall().throws(initialError); + swiftToolchainCreateStub.onSecondCall().throws(retryError); + + // Mock showToolchainError to return true (user made selection) + const showToolchainErrorStub = stub().resolves(true); + showToolchainError.setValue(showToolchainErrorStub); + + const folderContext = await FolderContext.create( + testFolder, + workspaceFolder, + workspaceContext + ); + + assert.ok( + folderContext, + "FolderContext should be created with fallback after retry failure" + ); + assert.strictEqual( + folderContext.toolchain, + workspaceContext.globalToolchain, + "Should fallback to global toolchain when retry also fails" + ); + + assert.strictEqual( + swiftToolchainCreateStub.callCount, + 2, + "Should retry toolchain creation after user selection" + ); + + const initialFailureLogs = workspaceContext.logger.logs.filter(log => + log.includes( + "Failed to discover Swift toolchain for package2: Error: Initial toolchain failure" + ) + ); + const retryFailureLogs = workspaceContext.logger.logs.filter(log => + log.includes( + "Failed to create toolchain for package2 even after user selection: Error: Retry toolchain failure" + ) + ); + + assert.ok(initialFailureLogs.length > 0, "Should log initial failure"); + assert.ok(retryFailureLogs.length > 0, "Should log retry failure"); + }); +}); From 65870dd1da4d1edf633d8df03025eccd4e5280ee Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Fri, 22 Aug 2025 11:36:20 -0400 Subject: [PATCH 2/3] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 678531df1..de31cca48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Don't start debugging XCTest cases if the swift-testing debug session was stopped ([#1797](https://github.com/swiftlang/vscode-swift/pull/1797)) +- Improve error handling when the swift path is misconfigured ([#1801](https://github.com/swiftlang/vscode-swift/pull/1801)) ## 2.11.20250806 - 2025-08-06 From 4993e54679fa0ee5616faec7684bd2508f6055a7 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Fri, 22 Aug 2025 14:02:50 -0400 Subject: [PATCH 3/3] Cleanup --- src/extension.ts | 4 ++-- src/ui/ToolchainSelection.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index c50b3c721..c091446b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -73,11 +73,11 @@ export async function activate(context: vscode.ExtensionContext): Promise { if (!toolchain) { // In order to select a toolchain we need to register the command first. const subscriptions = commands.registerToolchainCommands(undefined, logger, undefined); - const choseRemediation = await showToolchainError(); + const chosenRemediation = await showToolchainError(); subscriptions.forEach(sub => sub.dispose()); // If they tried to fix the improperly configured toolchain, re-initialize the extension. - if (choseRemediation) { + if (chosenRemediation) { return activate(context); } else { return { diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 8b3cdf414..c3ab56350 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -20,6 +20,7 @@ import configuration from "../configuration"; import { Commands } from "../commands"; import { Swiftly } from "../toolchain/swiftly"; import { SwiftLogger } from "../logging/SwiftLogger"; +import { FolderContext } from "../FolderContext"; /** * Open the installation page on Swift.org @@ -75,15 +76,16 @@ export async function selectToolchainFolder() { */ export async function showToolchainError(folder?: vscode.Uri): Promise { let selected: "Remove From Settings" | "Select Toolchain" | undefined; + const folderName = folder ? `${FolderContext.uriName(folder)}: ` : ""; if (configuration.path) { selected = await vscode.window.showErrorMessage( - `${folder ? `${path.basename(folder.path)}: ` : ""}The Swift executable at "${configuration.path}" either could not be found or failed to launch. Please select a new toolchain.`, + `${folderName}The Swift executable at "${configuration.path}" either could not be found or failed to launch. Please select a new toolchain.`, "Remove From Settings", "Select Toolchain" ); } else { selected = await vscode.window.showErrorMessage( - `${folder ? `${path.basename(folder.path)}: ` : ""}Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.`, + `${folderName}Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.`, "Select Toolchain" ); }