Files
sourcekit-lsp/Sources/BuildServerIntegration/ExternalBuildServerAdapter.swift
2025-10-31 14:11:11 -07:00

323 lines
12 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
@_spi(SourceKitLSP) import BuildServerProtocol
import Foundation
@_spi(SourceKitLSP) import LanguageServerProtocol
@_spi(SourceKitLSP) import LanguageServerProtocolExtensions
@_spi(SourceKitLSP) import LanguageServerProtocolTransport
@_spi(SourceKitLSP) import SKLogging
import SKOptions
import SwiftExtensions
import TSCExtensions
import func TSCBasic.getEnvSearchPaths
import var TSCBasic.localFileSystem
import func TSCBasic.lookupExecutablePath
private func executable(_ name: String) -> String {
#if os(Windows)
guard !name.hasSuffix(".exe") else { return name }
return "\(name).exe"
#else
return name
#endif
}
private let python3ExecutablePath: URL? = {
let pathVariable: String
#if os(Windows)
pathVariable = "Path"
#else
pathVariable = "PATH"
#endif
let searchPaths =
getEnvSearchPaths(
pathString: ProcessInfo.processInfo.environment[pathVariable],
currentWorkingDirectory: localFileSystem.currentWorkingDirectory
)
return lookupExecutablePath(filename: executable("python3"), searchPaths: searchPaths)?.asURL
?? lookupExecutablePath(filename: executable("python"), searchPaths: searchPaths)?.asURL
}()
struct ExecutableNotFoundError: Error {
let executableName: String
}
enum BuildServerNotFoundError: Error {
case fileNotFound
}
/// BSP configuration
///
/// See https://build-server-protocol.github.io/docs/overview/server-discovery#the-bsp-connection-details
private struct BuildServerConfig: Codable {
/// The name of the build tool.
let name: String
/// The version of the build tool.
let version: String
/// The bsp version of the build tool.
let bspVersion: String
/// A collection of languages supported by this BSP server.
let languages: [String]
/// Command arguments runnable via server processes to start a BSP server.
let argv: [String]
static func load(from path: URL) throws -> BuildServerConfig {
let decoder = JSONDecoder()
let fileData = try Data(contentsOf: path)
return try decoder.decode(BuildServerConfig.self, from: fileData)
}
}
/// Launches a subprocess that is a BSP server and manages the process's lifetime.
actor ExternalBuildServerAdapter {
/// The root folder of the project. Used to resolve relative server paths.
private let projectRoot: URL
/// The file that specifies the configuration for this build server.
private let configPath: URL
/// The `BuildServerManager` that handles messages from the BSP server to SourceKit-LSP.
var messagesToSourceKitLSPHandler: MessageHandler
/// The JSON-RPC connection between SourceKit-LSP and the BSP server.
private(set) var connectionToBuildServer: JSONRPCConnection?
/// After a `build/initialize` request has been sent to the BSP server, that request, so we can replay it in case the
/// server crashes.
private var initializeRequest: InitializeBuildRequest?
/// The process that runs the external BSP server.
private var process: Process?
/// The date at which `clangd` was last restarted.
/// Used to delay restarting in case of a crash loop.
private var lastRestart: Date?
static package func searchForConfig(
in workspaceFolder: URL,
onlyConsiderRoot: Bool,
options: SourceKitLSPOptions
) -> BuildServerSpec? {
guard let configPath = getConfigPath(for: workspaceFolder, onlyConsiderRoot: onlyConsiderRoot) else {
return nil
}
return BuildServerSpec(kind: .externalBuildServer, projectRoot: workspaceFolder, configPath: configPath)
}
init(
projectRoot: URL,
configPath: URL,
messagesToSourceKitLSPHandler: MessageHandler
) async throws {
self.projectRoot = projectRoot
self.configPath = configPath
self.messagesToSourceKitLSPHandler = messagesToSourceKitLSPHandler
self.connectionToBuildServer = try await self.createConnectionToBspServer()
}
/// Change the handler that handles messages from the build server.
///
/// The intended use of this is to intercept messages from the build server by `LegacyBuildServer`.
func changeMessageToSourceKitLSPHandler(to newHandler: MessageHandler) {
messagesToSourceKitLSPHandler = newHandler
connectionToBuildServer?.changeReceiveHandler(messagesToSourceKitLSPHandler)
}
/// Send a notification to the build server.
func send(_ notification: some NotificationType) {
guard let connectionToBuildServer else {
logger.error("Dropping notification because BSP server has crashed: \(notification.forLogging)")
return
}
connectionToBuildServer.send(notification)
}
/// Send a request to the build server.
func send<Request: RequestType>(_ request: Request) async throws -> Request.Response {
guard let connectionToBuildServer else {
throw ResponseError.internalError("BSP server has crashed")
}
if let request = request as? InitializeBuildRequest {
if initializeRequest != nil {
logger.error("BSP server was initialized multiple times")
}
self.initializeRequest = request
}
return try await connectionToBuildServer.send(request)
}
/// Create a new JSONRPCConnection to the build server.
private func createConnectionToBspServer() async throws -> JSONRPCConnection {
let serverConfig = try BuildServerConfig.load(from: configPath)
var serverPath = URL(fileURLWithPath: serverConfig.argv[0], relativeTo: projectRoot.ensuringCorrectTrailingSlash)
var serverArgs = Array(serverConfig.argv[1...])
if serverPath.pathExtension == "py" {
serverArgs = [try serverPath.filePath] + serverArgs
guard let interpreterPath = python3ExecutablePath else {
throw ExecutableNotFoundError(executableName: "python3")
}
serverPath = interpreterPath
}
let (connection, process) = try JSONRPCConnection.start(
executable: serverPath,
arguments: serverArgs,
name: "BSP-Server",
protocol: MessageRegistry.bspProtocol,
stderrLoggingCategory: "bsp-server-stderr",
client: messagesToSourceKitLSPHandler,
terminationHandler: { [weak self] terminationReason in
guard let self else {
return
}
if terminationReason != .exited(exitCode: 0) {
Task {
await orLog("Restarting BSP server") {
try await self.handleBspServerCrash()
}
}
}
}
)
self.process = process
return connection
}
private static func getConfigPath(for workspaceFolder: URL? = nil, onlyConsiderRoot: Bool = false) -> URL? {
var buildServerConfigLocations: [URL?] = []
if let workspaceFolder = workspaceFolder {
buildServerConfigLocations.append(workspaceFolder.appending(component: ".bsp"))
}
if !onlyConsiderRoot {
#if os(Windows)
if let localAppData = ProcessInfo.processInfo.environment["LOCALAPPDATA"] {
buildServerConfigLocations.append(URL(fileURLWithPath: localAppData).appending(component: "bsp"))
}
if let programData = ProcessInfo.processInfo.environment["PROGRAMDATA"] {
buildServerConfigLocations.append(URL(fileURLWithPath: programData).appending(component: "bsp"))
}
#else
if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] {
buildServerConfigLocations.append(URL(fileURLWithPath: xdgDataHome).appending(component: "bsp"))
}
if let libraryUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
buildServerConfigLocations.append(libraryUrl.appending(component: "bsp"))
}
if let xdgDataDirs = ProcessInfo.processInfo.environment["XDG_DATA_DIRS"] {
buildServerConfigLocations += xdgDataDirs.split(separator: ":").map { xdgDataDir in
URL(fileURLWithPath: String(xdgDataDir)).appending(component: "bsp")
}
}
if let libraryUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .systemDomainMask).first {
buildServerConfigLocations.append(libraryUrl.appending(component: "bsp"))
}
#endif
}
for case let buildServerConfigLocation? in buildServerConfigLocations {
let jsonFiles =
try? FileManager.default.contentsOfDirectory(at: buildServerConfigLocation, includingPropertiesForKeys: nil)
.filter {
guard let config = try? BuildServerConfig.load(from: $0) else {
return false
}
return !Set([Language.c, .cpp, .objective_c, .objective_cpp, .swift].map(\.rawValue))
.intersection(config.languages).isEmpty
}
if let configFileURL = jsonFiles?.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }).first {
return configFileURL
}
}
// Pre Swift 6.1 SourceKit-LSP looked for `buildServer.json` in the project root. Maintain this search location for
// compatibility even though it's not a standard BSP search location.
if let buildServerPath = workspaceFolder?.appending(component: "buildServer.json"),
FileManager.default.isFile(at: buildServerPath)
{
return buildServerPath
}
return nil
}
/// Restart the BSP server after it has crashed.
private func handleBspServerCrash() async throws {
// Set `connectionToBuildServer` to `nil` to indicate that there is currently no BSP server running.
connectionToBuildServer = nil
guard let initializeRequest else {
logger.error("BSP server crashed before it was sent an initialize request. Not restarting.")
return
}
logger.error("The BSP server has crashed. Restarting.")
let restartDelay: Duration
if let lastClangdRestart = self.lastRestart, Date().timeIntervalSince(lastClangdRestart) < 30 {
logger.log("BSP server has been restarted in the last 30 seconds. Delaying another restart by 10 seconds.")
restartDelay = .seconds(10)
} else {
restartDelay = .zero
}
self.lastRestart = Date()
try await Task.sleep(for: restartDelay)
let restartedConnection = try await self.createConnectionToBspServer()
// We assume that the server returns the same initialize response after being restarted.
// BSP does not set any state from the client to the server, so there are no other requests we need to replay
// (other than `textDocument/registerForChanges`, which is only used by the legacy BSP protocol, which didn't have
// crash recovery and doesn't need to gain it because it is deprecated).
_ = try await restartedConnection.send(initializeRequest)
restartedConnection.send(OnBuildInitializedNotification())
self.connectionToBuildServer = restartedConnection
// The build targets might have changed after the restart. Send a `buildTarget/didChange` notification to
// SourceKit-LSP to discard cached information.
self.messagesToSourceKitLSPHandler.handle(OnBuildTargetDidChangeNotification(changes: nil))
}
/// If the build server is still running after `duration`, terminate it. Otherwise a no-op.
package func terminateIfRunning(after duration: Duration) async throws {
try await process?.terminateIfRunning(after: duration)
}
}
fileprivate extension URL {
/// If the path of this URL represents a directory, ensure that it has a trailing slash.
///
/// This is important because if we form a file URL relative to eg. file:///tmp/a would assumes that `a` is a file
/// and use `/tmp` as the base, not `/tmp/a`.
var ensuringCorrectTrailingSlash: URL {
guard self.isFileURL else {
return self
}
// `URL(fileURLWithPath:)` checks the file system to decide whether a directory exists at the path.
return URL(fileURLWithPath: self.path)
}
}