From 7f310d26ec33d9dc720cfde2a995965890531355 Mon Sep 17 00:00:00 2001 From: Owen Voorhees Date: Thu, 7 Aug 2025 15:21:00 -0700 Subject: [PATCH] bsp --- .../VersionSpecificManifest/Foo.swift | 3 + .../VersionSpecificManifest/Package.swift | 12 + .../Package@swift-5.0.swift | 12 + Package.swift | 26 + .../PackageCommands/BuildServer.swift | 76 ++ .../PackageCommands/SwiftPackageCommand.swift | 1 + .../SwiftBuildSupport/SwiftBuildSystem.swift | 41 +- .../SwiftPMBuildServer/DisableSigpipe.swift | 47 ++ .../JSONRPCConnection.swift | 679 ++++++++++++++++++ .../SwiftPMBuildServer/MessageCoding.swift | 165 +++++ .../SwiftPMBuildServer/MessageSplitting.swift | 179 +++++ .../SwiftPMBuildServer.swift | 297 ++++++++ .../BuildServerTests.swift | 171 +++++ 13 files changed, 1701 insertions(+), 8 deletions(-) create mode 100644 Fixtures/Miscellaneous/VersionSpecificManifest/Foo.swift create mode 100644 Fixtures/Miscellaneous/VersionSpecificManifest/Package.swift create mode 100644 Fixtures/Miscellaneous/VersionSpecificManifest/Package@swift-5.0.swift create mode 100644 Sources/Commands/PackageCommands/BuildServer.swift create mode 100644 Sources/SwiftPMBuildServer/DisableSigpipe.swift create mode 100644 Sources/SwiftPMBuildServer/JSONRPCConnection.swift create mode 100644 Sources/SwiftPMBuildServer/MessageCoding.swift create mode 100644 Sources/SwiftPMBuildServer/MessageSplitting.swift create mode 100644 Sources/SwiftPMBuildServer/SwiftPMBuildServer.swift create mode 100644 Tests/SwiftPMBuildServerTests/BuildServerTests.swift diff --git a/Fixtures/Miscellaneous/VersionSpecificManifest/Foo.swift b/Fixtures/Miscellaneous/VersionSpecificManifest/Foo.swift new file mode 100644 index 00000000000..ad5e70131a1 --- /dev/null +++ b/Fixtures/Miscellaneous/VersionSpecificManifest/Foo.swift @@ -0,0 +1,3 @@ +public func foo() { + {}() +} diff --git a/Fixtures/Miscellaneous/VersionSpecificManifest/Package.swift b/Fixtures/Miscellaneous/VersionSpecificManifest/Package.swift new file mode 100644 index 00000000000..31f579f1b85 --- /dev/null +++ b/Fixtures/Miscellaneous/VersionSpecificManifest/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "Foo", + products: [ + .library(name: "Foo", targets: ["Foo"]), + ], + targets: [ + .target(name: "Foo", path: "./"), + ] +) diff --git a/Fixtures/Miscellaneous/VersionSpecificManifest/Package@swift-5.0.swift b/Fixtures/Miscellaneous/VersionSpecificManifest/Package@swift-5.0.swift new file mode 100644 index 00000000000..23f67814920 --- /dev/null +++ b/Fixtures/Miscellaneous/VersionSpecificManifest/Package@swift-5.0.swift @@ -0,0 +1,12 @@ +// swift-tools-version:5.2 +import PackageDescription + +let package = Package( + name: "Foo", + products: [ + .library(name: "Foo", targets: ["Foo"]), + ], + targets: [ + .target(name: "Foo", path: "./"), + ] +) diff --git a/Package.swift b/Package.swift index 0b565527562..1d6633b983e 100644 --- a/Package.swift +++ b/Package.swift @@ -575,6 +575,23 @@ let package = Package( ] ), + // MARK: BSP + .target( + name: "SwiftPMBuildServer", + dependencies: [ + "Basics", + "Build", + "PackageGraph", + "PackageLoading", + "PackageModel", + "SPMBuildCore", + "SourceControl", + "SourceKitLSPAPI", + "SwiftBuildSupport", + .product(name: "SWBBuildServerProtocol", package: "swift-build"), + ] + swiftTSCBasicsDeps + ), + // MARK: Commands .target( @@ -613,6 +630,7 @@ let package = Package( "XCBuildSupport", "SwiftBuildSupport", "SwiftFixIt", + "SwiftPMBuildServer", ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftRefactor"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ @@ -1053,6 +1071,14 @@ if ProcessInfo.processInfo.environment["SWIFTCI_DISABLE_SDK_DEPENDENT_TESTS"] == "dummy-swiftc", ] ), + .testTarget( + name: "SwiftPMBuildServerTests", + dependencies: [ + "SwiftPMBuildServer", + "_InternalTestSupport", + .product(name: "SWBBuildServerProtocol", package: "swift-build"), + ] + ), ]) } diff --git a/Sources/Commands/PackageCommands/BuildServer.swift b/Sources/Commands/PackageCommands/BuildServer.swift new file mode 100644 index 00000000000..f65034028d4 --- /dev/null +++ b/Sources/Commands/PackageCommands/BuildServer.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import TSCBasic +import SWBBuildServerProtocol +import CoreCommands +import Foundation +import PackageGraph +import SwiftPMBuildServer +import SPMBuildCore +import SwiftBuildSupport + +struct BuildServer: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + commandName: "build-server", + abstract: "Launch a build server for Swift Packages" + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + func run(_ swiftCommandState: SwiftCommandState) async throws { + // Dup stdout and redirect the fd to stderr so that a careless print() + // will not break our connection stream. + let realStdout = dup(STDOUT_FILENO) + if realStdout == -1 { + fatalError("failed to dup stdout: \(strerror(errno)!)") + } + if dup2(STDERR_FILENO, STDOUT_FILENO) == -1 { + fatalError("failed to redirect stdout -> stderr: \(strerror(errno)!)") + } + + let realStdoutHandle = FileHandle(fileDescriptor: realStdout, closeOnDealloc: false) + + let clientConnection = JSONRPCConnection( + name: "client", + protocol: bspRegistry, + inFD: FileHandle.standardInput, + outFD: realStdoutHandle, + inputMirrorFile: nil, + outputMirrorFile: nil + ) + + guard let buildSystem = try await swiftCommandState.createBuildSystem() as? SwiftBuildSystem else { + print("Build server requires --build-system swiftbuild") + Self.exit() + } + + guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { + throw StringError("unknown package") + } + + let server = try await SwiftPMBuildServer(packageRoot: packagePath, buildSystem: buildSystem, workspace: swiftCommandState.getActiveWorkspace(), connectionToClient: clientConnection, exitHandler: {_ in Self.exit() }) + clientConnection.start( + receiveHandler: server, + closeHandler: { + Self.exit() + } + ) + + // Park the main function by sleeping for 10 years. + while true { + try? await Task.sleep(for: .seconds(60 * 60 * 24 * 365 * 10)) + } + } +} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index da253850404..d7fa2c99009 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -39,6 +39,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { AddTargetDependency.self, AddSetting.self, AuditBinaryArtifact.self, + BuildServer.self, Clean.self, PurgeCache.self, Reset.self, diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index 51686990a33..8761b92116a 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -109,7 +109,9 @@ func withSession( } } -private final class PlanningOperationDelegate: SWBPlanningOperationDelegate, Sendable { +package final class SwiftBuildSystemPlanningOperationDelegate: SWBPlanningOperationDelegate, SWBIndexingDelegate, Sendable { + package init() {} + public func provisioningTaskInputs( targetGUID: String, provisioningSourceData: SWBProvisioningTaskInputsSourceData @@ -194,7 +196,7 @@ public struct PluginConfiguration { } public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { - private let buildParameters: BuildParameters + package let buildParameters: BuildParameters private let packageGraphLoader: () async throws -> ModulesGraph private let packageManagerResourcesDirectory: Basics.AbsolutePath? private let logLevel: Basics.Diagnostic.Severity @@ -349,7 +351,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { ) } - try await writePIF(buildParameters: buildParameters) + try await writePIF() return try await startSWBuildOperation( pifTargetName: subset.pifTargetName, @@ -545,7 +547,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { let operation = try await session.createBuildOperation( request: request, - delegate: PlanningOperationDelegate() + delegate: SwiftBuildSystemPlanningOperationDelegate() ) var buildState = BuildState() @@ -631,7 +633,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { ) } - private func makeBuildParameters(session: SWBBuildServiceSession, genSymbolGraph: Bool) async throws -> SwiftBuild.SWBBuildParameters { + package func makeBuildParameters(session: SWBBuildServiceSession, genSymbolGraph: Bool) async throws -> SwiftBuild.SWBBuildParameters { // Generate the run destination parameters. let runDestination = makeRunDestination() @@ -767,8 +769,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { buildProductsPath: ddPathPrefix + "/Products", buildIntermediatesPath: ddPathPrefix + "/Intermediates.noindex", pchPath: ddPathPrefix + "/PCH", - indexRegularBuildProductsPath: nil, - indexRegularBuildIntermediatesPath: nil, + indexRegularBuildProductsPath: ddPathPrefix + "/Index/Products", + indexRegularBuildIntermediatesPath: ddPathPrefix + "/Index/Intermediates.noindex", indexPCHPath: ddPathPrefix, indexDataStoreFolderPath: ddPathPrefix, indexEnableDataStore: request.parameters.arenaInfo?.indexEnableDataStore ?? false @@ -902,7 +904,9 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { } } - public func writePIF(buildParameters: BuildParameters) async throws { + public func writePIF() async throws { + pifBuilder = .init() + packageGraph = .init() let pifBuilder = try await getPIFBuilder() let pif = try await pifBuilder.generatePIF( printPIFManifestGraphviz: buildParameters.printPIFManifestGraphviz, @@ -912,6 +916,27 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { try self.fileSystem.writeIfChanged(path: buildParameters.pifManifest, string: pif) } + package struct LongLivedBuildServiceSession { + package var session: SWBBuildServiceSession + package var diagnostics: [SwiftBuildMessage.DiagnosticInfo] + package var teardownHandler: () async throws -> Void + } + + package func createLongLivedSession(name: String) async throws -> LongLivedBuildServiceSession { + let service = try await SWBBuildService(connectionMode: .inProcessStatic(swiftbuildServiceEntryPoint)) + do { + let (session, diagnostics) = try await createSession(service: service, name: name, toolchainPath: buildParameters.toolchain.toolchainDir, packageManagerResourcesDirectory: packageManagerResourcesDirectory) + let teardownHandler = { + try await session.close() + await service.close() + } + return LongLivedBuildServiceSession(session: session, diagnostics: diagnostics, teardownHandler: teardownHandler) + } catch { + await service.close() + throw error + } + } + public func cancel(deadline: DispatchTime) throws {} /// Returns the package graph using the graph loader closure. diff --git a/Sources/SwiftPMBuildServer/DisableSigpipe.swift b/Sources/SwiftPMBuildServer/DisableSigpipe.swift new file mode 100644 index 00000000000..3035104a9fa --- /dev/null +++ b/Sources/SwiftPMBuildServer/DisableSigpipe.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif + +#if canImport(Glibc) || canImport(Musl) || canImport(Android) +// This is a lazily initialised global variable that when read for the first time, will ignore SIGPIPE. +private let globallyIgnoredSIGPIPE: Bool = { + /* no F_SETNOSIGPIPE on Linux :( */ + _ = signal(SIGPIPE, SIG_IGN) + return true +}() +#endif + +/// We receive a `SIGPIPE` if we write to a pipe that points to a crashed process. This in particular happens if the +/// target of a `JSONRPCConnection` has crashed and we try to send it a message or if swift-format crashes and we try +/// to send the source file to it. +/// +/// On Darwin, `DispatchIO` ignores `SIGPIPE` for the pipes handled by it and swift-tools-support-core offers +/// `LocalFileOutputByteStream.disableSigpipe`, but that features is not available on Linux. +/// +/// Instead, globally ignore `SIGPIPE` on Linux to prevent us from crashing if the `JSONRPCConnection`'s target crashes. +/// +/// On Darwin platforms and on Windows this is a no-op. +package func globallyDisableSigpipeIfNeeded() { + #if !canImport(Darwin) && !os(Windows) + let haveWeIgnoredSIGPIEThisIsHereToTriggerIgnoringIt = globallyIgnoredSIGPIPE + guard haveWeIgnoredSIGPIEThisIsHereToTriggerIgnoringIt else { + fatalError("globallyIgnoredSIGPIPE should always be true") + } + #endif +} diff --git a/Sources/SwiftPMBuildServer/JSONRPCConnection.swift b/Sources/SwiftPMBuildServer/JSONRPCConnection.swift new file mode 100644 index 00000000000..6786fbc1644 --- /dev/null +++ b/Sources/SwiftPMBuildServer/JSONRPCConnection.swift @@ -0,0 +1,679 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 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 +// +//===----------------------------------------------------------------------===// + +public import Dispatch +public import Foundation +public import SWBBuildServerProtocol + +#if canImport(Android) +import Android +#endif + +#if canImport(CDispatch) +import struct CDispatch.dispatch_fd_t +#endif + +/// A connection between a message handler (e.g. language server) in the same process as the connection object and a +/// remote message handler (e.g. language client) that may run in another process using JSON RPC messages sent over a +// pair of in/out file descriptors. +/// +/// For example, inside a language server, the `JSONRPCConnection` takes the language service implementation as its +// `receiveHandler` and itself provides the client connection for sending notifications and callbacks. +public final class JSONRPCConnection: Connection { + public enum TerminationReason: Sendable, Equatable { + /// The process on the other end of the `JSONRPCConnection` terminated with the given exit code. + case exited(exitCode: Int32) + + /// The process on the other end of the `JSONRPCConnection` terminated with a signal. The signal that it terminated + /// with is not known. + case uncaughtSignal + } + + /// A name of the endpoint for this connection, used for logging, e.g. `clangd`. + private let name: String + + /// The message handler that handles requests and notifications sent through this connection. + /// + /// Access to this must be be guaranteed to be sequential to avoid data races. Currently, all access are + /// - `init`: Reference to `JSONRPCConnection` trivially can't have escaped to other isolation domains yet. + /// - `start`: Is required to be call in the same serial code region as the initializer, so + /// `JSONRPCConnection` can't have escaped to other isolation domains yet. + /// - `deinit`: Can also only trivially be called once. + nonisolated(unsafe) private var receiveHandler: MessageHandler? + + /// The queue on which we read the data + private let queue: DispatchQueue = DispatchQueue(label: "jsonrpc-queue", qos: .userInitiated) + + /// The queue on which we send data. + private let sendQueue: DispatchQueue = DispatchQueue(label: "jsonrpc-send-queue", qos: .userInitiated) + + private let receiveIO: DispatchIO + private let sendIO: DispatchIO + private let messageRegistry: MessageRegistry + + /// If non-nil, all input received by this `JSONRPCConnection` will be written to the file handle + let inputMirrorFile: FileHandle? + + /// If non-nil, all output created by this `JSONRPCConnection` will be written to the file handle + let outputMirrorFile: FileHandle? + + enum State { + case created, running, closed + } + + /// Current state of the connection, used to ensure correct usage. + /// + /// Access to this must be be guaranteed to be sequential to avoid data races. Currently, all access are + /// - `init`: Reference to `JSONRPCConnection` trivially can't have escaped to other isolation domains yet. + /// - `start`: Is required to be called in the same serial region as the initializer, so + /// `JSONRPCConnection` can't have escaped to other isolation domains yet. + /// - `closeAssumingOnQueue`: Synchronized on `queue`. + /// - `readyToSend`: Synchronized on `queue`. + /// - `deinit`: Can also only trivially be called once. + private nonisolated(unsafe) var state: State + + /// Buffer of received bytes that haven't been parsed. + /// + /// Access to this must be be guaranteed to be sequential to avoid data races. Currently, all access are + /// - The `receiveIO` handler: This is synchronized on `queue`. + /// - `requestBufferIsEmpty`: Also synchronized on `queue`. + private nonisolated(unsafe) var requestBuffer: [UInt8] = [] + + @_spi(Testing) + public var requestBufferIsEmpty: Bool { + queue.sync { + requestBuffer.isEmpty + } + } + + struct OutstandingRequest: Sendable { + var responseType: ResponseType.Type + var replyHandler: @Sendable (LSPResult) -> Void + } + + /// The set of currently outstanding outgoing requests along with information about how to decode and handle their + /// responses. + /// + /// All accesses to `outstandingRequests` must be on `queue` to avoid race conditions. + private nonisolated(unsafe) var outstandingRequests: [RequestID: OutstandingRequest] = [:] + + /// A handler that will be called asynchronously when the connection is being + /// closed. + /// + /// There are no race conditions to `closeHandler` because it is only set from `start`, which is required to be called + /// in the same serial code region domain as the initializer, so it's serial and the `JSONRPCConnection` can't + /// have escaped to other isolation domains yet. + private nonisolated(unsafe) var closeHandler: (@Sendable () async -> Void)? = nil + + /// - Important: `start` must be called before sending any data over the `JSONRPCConnection`. + public init( + name: String, + protocol messageRegistry: MessageRegistry, + inFD: FileHandle, + outFD: FileHandle, + inputMirrorFile: FileHandle? = nil, + outputMirrorFile: FileHandle? = nil + ) { + self.name = name + self.inputMirrorFile = inputMirrorFile + self.outputMirrorFile = outputMirrorFile + self.receiveHandler = nil + globallyDisableSigpipeIfNeeded() + state = .created + self.messageRegistry = messageRegistry + + let ioGroup = DispatchGroup() + + #if os(Windows) + let rawInFD = dispatch_fd_t(bitPattern: inFD._handle) + #else + let rawInFD = inFD.fileDescriptor + #endif + + ioGroup.enter() + receiveIO = DispatchIO( + type: .stream, + fileDescriptor: rawInFD, + queue: queue, + cleanupHandler: { (error: Int32) in + if error != 0 { + //logger.fault("IO error \(error)") + } + ioGroup.leave() + } + ) + + #if os(Windows) + let rawOutFD = dispatch_fd_t(bitPattern: outFD._handle) + #else + let rawOutFD = outFD.fileDescriptor + #endif + + ioGroup.enter() + sendIO = DispatchIO( + type: .stream, + fileDescriptor: rawOutFD, + queue: sendQueue, + cleanupHandler: { (error: Int32) in + if error != 0 { + //logger.fault("IO error \(error)") + } + ioGroup.leave() + } + ) + + ioGroup.notify(queue: queue) { [weak self] in + guard let self else { return } + for outstandingRequest in self.outstandingRequests.values { + outstandingRequest.replyHandler(LSPResult.failure(ResponseError.internalError("JSON-RPC Connection closed"))) + } + self.outstandingRequests = [:] + self.receiveHandler = nil // break retain cycle + Task { + await self.closeHandler?() + } + } + + // We cannot assume the client will send us bytes in packets of any particular size, so set the lower limit to 1. + receiveIO.setLimit(lowWater: 1) + receiveIO.setLimit(highWater: Int.max) + + sendIO.setLimit(lowWater: 1) + sendIO.setLimit(highWater: Int.max) + } + + /// Creates and starts a `JSONRPCConnection` that connects to a subprocess launched with the specified arguments. + /// + /// `client` is the message handler that handles the messages sent from the subprocess to SourceKit-LSP. + public static func start( + executable: URL, + arguments: [String], + name: StaticString, + protocol messageRegistry: MessageRegistry, + stderrLoggingCategory: String, + client: MessageHandler, + terminationHandler: @Sendable @escaping (_ terminationReason: TerminationReason) -> Void + ) throws -> (connection: JSONRPCConnection, process: Process) { + let clientToServer = Pipe() + let serverToClient = Pipe() + + let connection = JSONRPCConnection( + name: "\(name)", + protocol: messageRegistry, + inFD: serverToClient.fileHandleForReading, + outFD: clientToServer.fileHandleForWriting + ) + + connection.start(receiveHandler: client) { + // Keep the pipes alive until we close the connection. + withExtendedLifetime((clientToServer, serverToClient)) {} + } + let process = Foundation.Process() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = serverToClient + process.standardInput = clientToServer + let stderrHandler = Pipe() + stderrHandler.fileHandleForReading.readabilityHandler = { fileHandle in + let newData = fileHandle.availableData + if newData.count == 0 { + stderrHandler.fileHandleForReading.readabilityHandler = nil + } else { + } + } + process.standardError = stderrHandler + process.terminationHandler = { process in + connection.close() + let terminationReason: TerminationReason + switch process.terminationReason { + case .exit: + terminationReason = .exited(exitCode: process.terminationStatus) + case .uncaughtSignal: + terminationReason = .uncaughtSignal + @unknown default: + terminationReason = .exited(exitCode: 0) + } + terminationHandler(terminationReason) + } + try process.run() + + return (connection, process) + } + + deinit { + assert(state == .closed) + } + + /// Change the handler that handles messages from the JSON-RPC connection to a new handler. + public func changeReceiveHandler(_ receiveHandler: MessageHandler) { + queue.sync { + self.receiveHandler = receiveHandler + } + } + + /// Start processing `inFD` and send messages to `receiveHandler`. + /// + /// - parameter receiveHandler: The message handler to invoke for requests received on the `inFD`. + /// + /// - Important: `start` must be called before sending any data over the `JSONRPCConnection`. + public func start( + receiveHandler: MessageHandler, + closeHandler: @escaping @Sendable () async -> Void = {} + ) { + queue.sync { + precondition(state == .created) + state = .running + self.receiveHandler = receiveHandler + self.closeHandler = closeHandler + + receiveIO.read(offset: 0, length: Int.max, queue: queue) { done, data, errorCode in + guard errorCode == 0 else { + #if !os(Windows) + if errorCode != POSIXError.ECANCELED.rawValue { + } + #endif + if done { self.closeAssumingOnQueue() } + return + } + + if done { + self.closeAssumingOnQueue() + return + } + + guard let data = data, !data.isEmpty else { + return + } + + try? self.inputMirrorFile?.write(contentsOf: data) + + // Parse and handle any messages in `buffer + data`, leaving any remaining unparsed bytes in `buffer`. + if self.requestBuffer.isEmpty { + data.withUnsafeBytes { (pointer: UnsafePointer) in + let rest = self.parseAndHandleMessages(from: UnsafeBufferPointer(start: pointer, count: data.count)) + self.requestBuffer.append(contentsOf: rest) + } + } else { + self.requestBuffer.append(contentsOf: data) + var unused = 0 + self.requestBuffer.withUnsafeBufferPointer { buffer in + let rest = self.parseAndHandleMessages(from: buffer) + unused = rest.count + } + self.requestBuffer.removeFirst(self.requestBuffer.count - unused) + } + } + } + } + + /// Send a notification to the client that informs the user about a message encoding or decoding error and asks them + /// to file an issue. + /// + /// `message` describes what has gone wrong to the user. + /// + /// - Important: Must be called on `queue` + private func sendMessageCodingErrorNotificationToClient(message: String) { + /* + dispatchPrecondition(condition: .onQueue(queue)) + let showMessage = ShowMessageNotification( + type: .error, + message: """ + \(message). Please run 'sourcekit-lsp diagnose' to file an issue. + """ + ) + self.send(.notification(showMessage)) + */ + } + + /// Decode a single JSONRPC message from the given `messageBytes`. + /// + /// `messageBytes` should be valid JSON, ie. this is the message sent from the client without the `Content-Length` + /// header. + /// + /// If an error occurs during message parsing, this tries to recover as gracefully as possible and returns `nil`. + /// Callers should consider the message handled and ignore it when this function returns `nil`. + /// + /// - Important: Must be called on `queue` + private func decodeJSONRPCMessage(messageBytes: Slice>) -> JSONRPCMessage? { + dispatchPrecondition(condition: .onQueue(queue)) + let decoder = JSONDecoder() + + // Set message registry to use for model decoding. + decoder.userInfo[.messageRegistryKey] = messageRegistry + + // Setup callback for response type. + decoder.userInfo[.responseTypeCallbackKey] = { @Sendable (id: RequestID) -> ResponseType.Type? in + // `outstandingRequests` should never be mutated in this closure. Reading is fine as all of our other writes are + // guarded by `queue`, but `JSONDecoder` could (since this is sendable) invoke this concurrently. + guard let outstanding = self.outstandingRequests[id] else { + return nil + } + return outstanding.responseType + } + + do { + let pointer = UnsafeMutableRawPointer(mutating: UnsafeBufferPointer(rebasing: messageBytes).baseAddress!) + return try decoder.decode( + JSONRPCMessage.self, + from: Data(bytesNoCopy: pointer, count: messageBytes.count, deallocator: .none) + ) + } catch let error as MessageDecodingError { + + // We failed to decode the message. Under those circumstances try to behave as LSP-conforming as possible. + // Always log at the fault level so that we know something is going wrong from the logs. + // + // The pattern below is to handle the message in the best possible way and then `return nil` to acknowledge the + // handling. That way the compiler enforces that we handle all code paths. + switch error.messageKind { + case .request: + if let id = error.id { + // If we know it was a request and we have the request ID, simply reply to the request and tell the client + // that we couldn't parse it. That complies with LSP that all requests should eventually get a response. + self.send(.errorResponse(ResponseError(error), id: id)) + return nil + } + // If we don't know the ID of the request, ignore it and show a notification to the user. + // That way the user at least knows that something is going wrong even if the client never gets a response + // for the request. + sendMessageCodingErrorNotificationToClient(message: "sourcekit-lsp failed to decode a request") + return nil + case .response: + if let id = error.id { + if let outstanding = self.outstandingRequests.removeValue(forKey: id) { + // If we received a response to a request we sent to the client, assume that the client responded with an + // error. That complies with LSP that all requests should eventually get a response. + outstanding.replyHandler(.failure(ResponseError(error))) + return nil + } + // If there's an error in the response but we don't even know about the request, we can ignore it. + return nil + } + // And if we can't even recover the ID the response is for, we drop it. This means that whichever code in + // sourcekit-lsp sent the request will probably never get a reply but there's nothing we can do about that. + // Ideally requests sent from sourcekit-lsp to the client would have some kind of timeout anyway. + return nil + case .notification: + if error.code == .methodNotFound { + // If we receive a notification we don't know about, this might be a client sending a new LSP notification + // that we don't know about. It can't be very critical so we ignore it without bothering the user with an + // error notification. + return nil + } + // Ignoring any other notification might result in corrupted behavior. For example, ignoring a + // `textDocument/didChange` will result in an out-of-sync state between the editor and sourcekit-lsp. + // Warn the user about the error. + sendMessageCodingErrorNotificationToClient(message: "sourcekit-lsp failed to decode a notification") + return nil + case .unknown: + // We don't know what has gone wrong. This could be any level of badness. Inform the user about it. + sendMessageCodingErrorNotificationToClient(message: "sourcekit-lsp failed to decode a message") + return nil + } + } catch { + // We don't know what has gone wrong. This could be any level of badness. Inform the user about it and ignore the + // message. + sendMessageCodingErrorNotificationToClient(message: "sourcekit-lsp failed to decode an unknown message") + return nil + } + } + + /// Whether we can send messages in the current state. + /// + /// - parameter shouldLog: Whether to log an info message if not ready. + /// + /// - Important: Must be called on `queue`. Note that the state might change as soon as execution leaves `queue`. + func readyToSend(shouldLog: Bool = true) -> Bool { + dispatchPrecondition(condition: .onQueue(queue)) + precondition(state != .created, "tried to send message before calling start(messageHandler:)") + let ready = state == .running + if shouldLog && !ready { + } + return ready + } + + /// Parse and handle all messages in `bytes`, returning a slice containing any remaining incomplete data. + /// + /// - Important: Must be called on `queue` + func parseAndHandleMessages(from bytes: UnsafeBufferPointer) -> UnsafeBufferPointer.SubSequence { + dispatchPrecondition(condition: .onQueue(queue)) + + var bytes = bytes[...] + + MESSAGE_LOOP: while true { + // Split the messages based on the Content-Length header. + let messageBytes: Slice> + do { + guard let (header: _, message: message, rest: rest) = try bytes.jsonrpcSplitMessage() else { + return bytes + } + messageBytes = message + bytes = rest + } catch { + // We failed to parse the message header. There isn't really much we can do to recover because we lost our + // anchor in the stream where new messages start. Crashing and letting ourselves be restarted by the client is + // probably the best option. + sendMessageCodingErrorNotificationToClient(message: "Failed to find next message in connection to editor") + fatalError("fatal error encountered while splitting JSON RPC messages \(error)") + } + + guard let message = decodeJSONRPCMessage(messageBytes: messageBytes) else { + continue + } + handle(message) + } + } + + /// Handle a single message by dispatching it to `receiveHandler` or an appropriate reply handler. + /// + /// - Important: Must be called on `queue` + func handle(_ message: JSONRPCMessage) { + dispatchPrecondition(condition: .onQueue(queue)) + switch message { + case .notification(let notification): + notification._handle(self.receiveHandler!) + case .request(let request, id: let id): + request._handle(self.receiveHandler!, id: id) { (response, id) in + self.sendReply(response, id: id) + } + case .response(let response, id: let id): + guard let outstanding = outstandingRequests.removeValue(forKey: id) else { + return + } + outstanding.replyHandler(.success(response)) + case .errorResponse(let error, id: let id): + guard let id = id else { + return + } + guard let outstanding = outstandingRequests.removeValue(forKey: id) else { + return + } + outstanding.replyHandler(.failure(error)) + } + } + + /// Send the raw data to the receiving end of this connection. + /// + /// If an unrecoverable error occurred on the channel's file descriptor, the connection gets closed. + /// + /// - Important: Must be called on `queue` + private func send(data dispatchData: DispatchData) { + dispatchPrecondition(condition: .onQueue(queue)) + guard readyToSend() else { return } + + try? outputMirrorFile?.write(contentsOf: dispatchData) + sendIO.write(offset: 0, data: dispatchData, queue: sendQueue) { [weak self] done, _, errorCode in + if errorCode != 0 { + if done, let self { + // An unrecoverable error occurs on the channel’s file descriptor. + // Close the connection. + self.queue.async { + self.closeAssumingOnQueue() + } + } + } + } + } + + /// Wrapper of `send(data:)` that automatically switches to `queue`. + /// + /// This should only be used to test that the client decodes messages correctly if data is delivered to it + /// byte-by-byte instead of in larger chunks that contain entire messages. + @_spi(Testing) + public func send(_rawData dispatchData: DispatchData) { + queue.sync { + self.send(data: dispatchData) + } + } + + /// Send the given message to the receiving end of the connection. + /// + /// If an unrecoverable error occurred on the channel's file descriptor, the connection gets closed. + /// + /// - Important: Must be called on `queue` + func send(_ message: JSONRPCMessage) { + dispatchPrecondition(condition: .onQueue(queue)) + + let encoder = JSONEncoder() + + let data: Data + do { + data = try encoder.encode(message) + } catch { + switch message { + case .notification(_): + // We want to send a notification to the editor but failed to encode it. Since dropping the notification might + // result in getting out-of-sync state-wise with the editor (eg. for work progress notifications), inform the + // user about it. + sendMessageCodingErrorNotificationToClient( + message: "sourcekit-lsp failed to encode a notification to the editor" + ) + return + case .request(_, _): + // We want to send a notification to the editor but failed to encode it. We don't know the `reply` handle for + // the request at this point so we can't synthesize an errorResponse for the request. This means that the + // request will never receive a reply. Inform the user about it. + sendMessageCodingErrorNotificationToClient( + message: "sourcekit-lsp failed to encode a request to the editor" + ) + return + case .response(_, _): + // The editor sent a request to sourcekit-lsp, which failed but we can't serialize the result back to the + // client. This means that the request will never receive a reply. Inform the user about it and accept that + // we'll never send a reply. + sendMessageCodingErrorNotificationToClient( + message: "sourcekit-lsp failed to encode a response to the editor" + ) + return + case .errorResponse(_, _): + // Same as `.response`. Has an optional `id`, so can't share the case. + sendMessageCodingErrorNotificationToClient( + message: "sourcekit-lsp failed to encode an error response to the editor" + ) + return + } + } + + var dispatchData = DispatchData.empty + let header = "Content-Length: \(data.count)\r\n\r\n" + header.utf8.map { $0 }.withUnsafeBytes { buffer in + dispatchData.append(buffer) + } + data.withUnsafeBytes { rawBufferPointer in + dispatchData.append(rawBufferPointer) + } + + send(data: dispatchData) + } + + /// Close the connection. + /// + /// The user-provided close handler will be called *asynchronously* when all outstanding I/O + /// operations have completed. No new I/O will be accepted after `close` returns. + public func close() { + queue.sync { closeAssumingOnQueue() } + } + + /// Close the connection, assuming that the code is already executing on `queue`. + /// + /// - Important: Must be called on `queue`. + private func closeAssumingOnQueue() { + dispatchPrecondition(condition: .onQueue(queue)) + sendQueue.sync { + guard state == .running else { return } + state = .closed + + // Attempt to close the reader immediately; we do not need to accept remaining inputs. + receiveIO.close(flags: .stop) + // Close the writer after it finishes outstanding work. + sendIO.close() + } + } + + /// Request id for the next outgoing request. + public func nextRequestID() -> RequestID { + return .string("sk-\(UUID().uuidString)") + } + + // MARK: Connection interface + + /// Send the notification to the remote side of the notification. + public func send(_ notification: some NotificationType) { + queue.async { + self.send(.notification(notification)) + } + } + + /// Send the given request to the remote side of the connection. + /// + /// When the receiving end replies to the request, execute `reply` with the response. + public func send( + _ request: Request, + id: RequestID, + reply: @escaping @Sendable (LSPResult) -> Void + ) { + self.queue.sync { + guard readyToSend() else { + reply(.failure(.serverCancelled)) + return + } + + outstandingRequests[id] = OutstandingRequest( + responseType: Request.Response.self, + replyHandler: { anyResult in + let result = anyResult.map { $0 as! Request.Response } + switch result { + case .success(let response): + break + case .failure(let error): + break + } + reply(result) + } + ) + + send(.request(request, id: id)) + return + } + } + + /// After the remote side of the connection sent a request to us, return a reply to the remote side. + public func sendReply(_ response: LSPResult, id: RequestID) { + queue.async { + switch response { + case .success(let result): + self.send(.response(result, id: id)) + case .failure(let error): + self.send(.errorResponse(error, id: id)) + } + } + } +} diff --git a/Sources/SwiftPMBuildServer/MessageCoding.swift b/Sources/SwiftPMBuildServer/MessageCoding.swift new file mode 100644 index 00000000000..7c594ef5358 --- /dev/null +++ b/Sources/SwiftPMBuildServer/MessageCoding.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 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 +// +//===----------------------------------------------------------------------===// + +import Foundation +public import SWBBuildServerProtocol + +@_spi(Testing) public enum JSONRPCMessage { + case notification(NotificationType) + case request(_RequestType, id: RequestID) + case response(ResponseType, id: RequestID) + case errorResponse(ResponseError, id: RequestID?) +} + +extension CodingUserInfoKey { + public static let responseTypeCallbackKey: CodingUserInfoKey = CodingUserInfoKey( + rawValue: "lsp.jsonrpc.responseTypeCallback" + )! + public static let messageRegistryKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "lsp.jsonrpc.messageRegistry")! +} + +extension JSONRPCMessage: Codable { + + public typealias ResponseTypeCallback = @Sendable (RequestID) -> ResponseType.Type? + + private enum CodingKeys: String, CodingKey { + case jsonrpc + case method + case id + case params + case result + case error + } + + public init(from decoder: Decoder) throws { + + guard let messageRegistry = decoder.userInfo[.messageRegistryKey] as? MessageRegistry else { + fatalError("missing or invalid messageRegistryKey on decoder") + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let jsonrpc = try container.decodeIfPresent(String.self, forKey: .jsonrpc) + let id = try container.decodeIfPresent(RequestID.self, forKey: .id) + var msgKind = MessageDecodingError.MessageKind.unknown + + if jsonrpc != "2.0" { + throw MessageDecodingError.invalidRequest("jsonrpc version must be 2.0") + } + + do { + let method = try container.decodeIfPresent(String.self, forKey: .method) + let error = try container.decodeIfPresent(ResponseError.self, forKey: .error) + + let hasResult = container.contains(.result) + + switch (id, method, hasResult, error) { + case (nil, let method?, _, nil): + msgKind = .notification + + guard let messageType = messageRegistry.notificationType(for: method) else { + throw MessageDecodingError.methodNotFound(method) + } + + let params = try messageType.init(from: container.superDecoder(forKey: .params)) + + self = .notification(params) + + case (let id?, let method?, _, nil): + msgKind = .request + + guard let messageType = messageRegistry.requestType(for: method) else { + throw MessageDecodingError.methodNotFound(method) + } + + let params = try messageType.init(from: container.superDecoder(forKey: .params)) + + self = .request(params, id: id) + + case (let id?, nil, true, nil): + msgKind = .response + + guard let responseTypeCallback = decoder.userInfo[.responseTypeCallbackKey] as? ResponseTypeCallback else { + fatalError("missing or invalid responseTypeCallbackKey on decoder") + } + + guard let responseType = responseTypeCallback(id) else { + throw MessageDecodingError.invalidParams("response to unknown request \(id)") + } + + let result = try responseType.init(from: container.superDecoder(forKey: .result)) + + self = .response(result, id: id) + + case (let id, nil, _, let error?): + msgKind = .response + self = .errorResponse(error, id: id) + + default: + throw MessageDecodingError.invalidRequest("message not recognized as request, response or notification") + } + + } catch var error as MessageDecodingError { + assert(error.id == nil || error.id == id) + error.id = id + error.messageKind = msgKind + throw error + + } catch DecodingError.keyNotFound(let key, _) { + throw MessageDecodingError.invalidParams( + "missing expected parameter: \(key.stringValue)", + id: id, + messageKind: msgKind + ) + + } catch DecodingError.valueNotFound(_, let context) { + throw MessageDecodingError.invalidParams( + "missing expected parameter: \(context.codingPath.last?.stringValue ?? "unknown")", + id: id, + messageKind: msgKind + ) + + } catch DecodingError.typeMismatch(_, let context) { + let path = context.codingPath.map { $0.stringValue }.joined(separator: ".") + throw MessageDecodingError.invalidParams( + "type mismatch at \(path) : \(context.debugDescription)", + id: id, + messageKind: msgKind + ) + + } catch { + throw MessageDecodingError.parseError(error.localizedDescription, id: id, messageKind: msgKind) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("2.0", forKey: .jsonrpc) + + switch self { + case .notification(let params): + try container.encode(type(of: params).method, forKey: .method) + try params.encode(to: container.superEncoder(forKey: .params)) + + case .request(let params, let id): + try container.encode(type(of: params).method, forKey: .method) + try container.encode(id, forKey: .id) + try params.encode(to: container.superEncoder(forKey: .params)) + + case .response(let result, let id): + try container.encode(id, forKey: .id) + try result.encode(to: container.superEncoder(forKey: .result)) + + case .errorResponse(let error, let id): + try container.encode(id, forKey: .id) + try container.encode(error, forKey: .error) + } + } +} diff --git a/Sources/SwiftPMBuildServer/MessageSplitting.swift b/Sources/SwiftPMBuildServer/MessageSplitting.swift new file mode 100644 index 00000000000..376ac142e79 --- /dev/null +++ b/Sources/SwiftPMBuildServer/MessageSplitting.swift @@ -0,0 +1,179 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 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 +// +//===----------------------------------------------------------------------===// + +import Foundation +import SWBBuildServerProtocol + +public struct JSONRPCMessageHeader: Hashable { + static let contentLengthKey: [UInt8] = [UInt8]("Content-Length".utf8) + static let separator: [UInt8] = [UInt8]("\r\n".utf8) + static let colon: UInt8 = UInt8(ascii: ":") + static let invalidKeyBytes: [UInt8] = [colon] + separator + + public var contentLength: Int? = nil + + public init(contentLength: Int? = nil) { + self.contentLength = contentLength + } +} + +extension RandomAccessCollection where Index == Int { + /// Tries to parse a single message from this collection of bytes. + /// + /// If an entire message could be found, returns + /// - header (representing `Content-Length:\r\n\r\n`) + /// - message: The data that represents the actual message as JSON + /// - rest: The remaining bytes that haven't weren't part of the first message in this collection + /// + /// If a `Content-Length` header could be found but the collection doesn't have enough bytes for the entire message + /// (eg. because the `Content-Length` header has been transmitted yet but not the entire message), returns `nil`. + /// Callers should call this method again once more data is available. + @_spi(Testing) + public func jsonrpcSplitMessage() throws -> (header: JSONRPCMessageHeader, message: SubSequence, rest: SubSequence)? { + guard let (header, rest) = try jsonrcpParseHeader() else { return nil } + guard let contentLength = header.contentLength else { + throw MessageDecodingError.parseError("missing Content-Length header") + } + if contentLength > rest.count { return nil } + return (header: header, message: rest.prefix(contentLength), rest: rest.dropFirst(contentLength)) + } + + @_spi(Testing) + public func jsonrcpParseHeader() throws -> (header: JSONRPCMessageHeader, rest: SubSequence)? { + var header = JSONRPCMessageHeader() + var slice = self[...] + while let (kv, rest) = try slice.jsonrpcParseHeaderField() { + guard let (key, value) = kv else { + return (header, rest) + } + slice = rest + + if key.elementsEqual(JSONRPCMessageHeader.contentLengthKey) { + guard let count = Int(ascii: value) else { + throw MessageDecodingError.parseError( + "expected integer value in \(String(bytes: value, encoding: .utf8) ?? "")" + ) + } + header.contentLength = count + } + + // Unknown field, continue. + } + return nil + } + + @_spi(Testing) + public func jsonrpcParseHeaderField() throws -> ((key: SubSequence, value: SubSequence)?, SubSequence)? { + if starts(with: JSONRPCMessageHeader.separator) { + return (nil, dropFirst(JSONRPCMessageHeader.separator.count)) + } else if first == JSONRPCMessageHeader.separator.first { + return nil + } + + guard let keyEnd = firstIndex(where: { JSONRPCMessageHeader.invalidKeyBytes.contains($0) }) else { + return nil + } + if self[keyEnd] != JSONRPCMessageHeader.colon { + throw MessageDecodingError.parseError("expected ':' in message header") + } + let valueStart = index(after: keyEnd) + guard let valueEnd = self[valueStart...].firstRange(of: JSONRPCMessageHeader.separator)?.startIndex else { + return nil + } + + return ((key: self[..(ascii buffer: C) where C: Collection, C.Element == UInt8 { + guard !buffer.isEmpty else { return nil } + + // Trim leading whitespace. + var i = buffer.startIndex + while i != buffer.endIndex, buffer[i].isSpace { + i = buffer.index(after: i) + } + + guard i != buffer.endIndex else { return nil } + + // Check sign if any. + var sign = 1 + if buffer[i] == UInt8(ascii: "+") { + i = buffer.index(after: i) + } else if buffer[i] == UInt8(ascii: "-") { + i = buffer.index(after: i) + sign = -1 + } + + guard i != buffer.endIndex, buffer[i].isDigit else { return nil } + + // Accumulate the result. + var result = 0 + while i != buffer.endIndex, buffer[i].isDigit { + result = result * 10 + sign * buffer[i].asciiDigit + i = buffer.index(after: i) + } + + // Trim trailing whitespace. + while i != buffer.endIndex { + if !buffer[i].isSpace { return nil } + i = buffer.index(after: i) + } + self = result + } + + // Constructs an integer from a buffer of base-10 ascii digits, ignoring any surrounding whitespace. + /// + /// This is similar to `atol` but with several advantages: + /// - no need to construct a null-terminated C string + /// - overflow will trap instead of being undefined + /// - does not allow non-whitespace characters at the end + @inlinable + public init?(ascii buffer: S) where S: StringProtocol { + self.init(ascii: buffer.utf8) + } +} diff --git a/Sources/SwiftPMBuildServer/SwiftPMBuildServer.swift b/Sources/SwiftPMBuildServer/SwiftPMBuildServer.swift new file mode 100644 index 00000000000..9f1a2701cff --- /dev/null +++ b/Sources/SwiftPMBuildServer/SwiftPMBuildServer.swift @@ -0,0 +1,297 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import SWBBuildServerProtocol +import Foundation +import SPMBuildCore +import SwiftBuildSupport +import SwiftBuild +import SWBBuildService +import Workspace + +public actor SwiftPMBuildServer: QueueBasedMessageHandler { + private let underlyingBuildServer: SWBBuildServer + private let connectionToUnderlyingBuildServer: LocalConnection + private let packageRoot: Basics.AbsolutePath + private let buildSystem: SwiftBuildSystem + private let workspace: Workspace + + public let messageHandlingHelper = QueueBasedMessageHandlerHelper( + signpostLoggingCategory: "build-server-message-handling", + createLoggingScope: false + ) + public let messageHandlingQueue = AsyncQueue() + /// Serializes package loading + private let packageLoadingQueue = AsyncQueue() + /// Connection used to send messages to the client of the build server. + private let connectionToClient: any Connection + + /// Represents the lifetime of the build server implementation.. + enum ServerState: CustomStringConvertible { + case waitingForInitializeRequest + case waitingForInitializedNotification + case running + case shutdown + + var description: String { + switch self { + case .waitingForInitializeRequest: + "waiting for initialization request" + case .waitingForInitializedNotification: + "waiting for initialization notification" + case .running: + "running" + case .shutdown: + "shutdown" + } + } + } + var state: ServerState = .waitingForInitializeRequest + /// Allows customization of server exit behavior. + var exitHandler: (Int) -> Void + + public init(packageRoot: Basics.AbsolutePath, buildSystem: SwiftBuildSystem, workspace: Workspace, connectionToClient: any Connection, exitHandler: @escaping (Int) -> Void) async throws { + self.packageRoot = packageRoot + self.buildSystem = buildSystem + self.workspace = workspace + self.connectionToClient = connectionToClient + self.exitHandler = exitHandler + // TODO: Log session creation diagnostics + let session = try await buildSystem.createLongLivedSession(name: "swiftpm-build-server") + let connectionToUnderlyingBuildServer = LocalConnection(receiverName: "underlying-swift-build-server") + self.connectionToUnderlyingBuildServer = connectionToUnderlyingBuildServer + let connectionFromUnderlyingBuildServer = LocalConnection(receiverName: "swiftpm-build-server") + // TODO: fix derived data path, cleanup configured targets list computation + let buildrequest = try await self.buildSystem.makeBuildRequest(session: session.session, configuredTargets: [.init(rawValue: "ALL-INCLUDING-TESTS")], derivedDataPath: self.buildSystem.buildParameters.buildPath, genSymbolGraph: false) + self.underlyingBuildServer = SWBBuildServer(session: session.session, containerPath: buildSystem.buildParameters.pifManifest.pathString, buildRequest: buildrequest, connectionToClient: connectionFromUnderlyingBuildServer, exitHandler: { _ in + connectionToUnderlyingBuildServer.close() + try? await session.teardownHandler() + }) + connectionToUnderlyingBuildServer.start(handler: underlyingBuildServer) + connectionFromUnderlyingBuildServer.start(handler: self) + } + + public func handle(notification: some NotificationType) async { + switch notification { + case is OnBuildExitNotification: + connectionToUnderlyingBuildServer.send(notification) + if state == .shutdown { + exitHandler(0) + } else { + exitHandler(1) + } + case is OnBuildInitializedNotification: + connectionToUnderlyingBuildServer.send(notification) + state = .running + case let notification as OnWatchedFilesDidChangeNotification: + // The underlying build server only receives updates via new PIF, so don't forward this notification. + for change in notification.changes { + if self.fileEventShouldTriggerPackageReload(event: change) { + scheduleRegeneratingBuildDescription() + return + } + } + case is OnBuildLogMessageNotification: + // If we receive a build log message notification, forward it on to the client + connectionToClient.send(notification) + default: + break + } + } + + public func handle( + request: Request, + id: RequestID, + reply: @Sendable @escaping (LSPResult) -> Void + ) async { + let request = RequestAndReply(request, reply: reply) + switch request { + case let request as RequestAndReply: + await request.reply { + _ = try await connectionToUnderlyingBuildServer.send(request.params) + return await shutdown() + } + case let request as RequestAndReply: + await request.reply { + for target in request.params.targets.filter({ $0.isSwiftPMBuildServerTargetID }) { + // Error + } + var underlyingRequest = request.params + underlyingRequest.targets.removeAll(where: { $0.isSwiftPMBuildServerTargetID }) + return try await connectionToUnderlyingBuildServer.send(underlyingRequest) + } + case let request as RequestAndReply: + await request.reply { + var underlyingRequest = request.params + underlyingRequest.targets.removeAll(where: { $0.isSwiftPMBuildServerTargetID }) + var sourcesResponse = try await connectionToUnderlyingBuildServer.send(underlyingRequest) + for target in request.params.targets.filter({ $0.isSwiftPMBuildServerTargetID }) { + if target == .forPackageManifest { + sourcesResponse.items.append(await manifestSourcesItem()) + } else { + // Unexpected target + } + } + return sourcesResponse + } + case let request as RequestAndReply: + await request.reply { try await self.initialize(request: request.params) } + case let request as RequestAndReply: + await request.reply { + if request.params.target.isSwiftPMBuildServerTargetID { + return try await manifestSourceKitOptions(request: request.params) + } else { + return try await connectionToUnderlyingBuildServer.send(request.params) + } + } + case let request as RequestAndReply: + await request.reply { + var targetsResponse = try await connectionToUnderlyingBuildServer.send(request.params) + targetsResponse.targets.append(await manifestTarget()) + return targetsResponse + } + case let request as RequestAndReply: + await request.reply { + await waitForBuildSystemUpdates(request: request.params) + } + default: + await request.reply { throw ResponseError.methodNotFound(Request.method) } + } + } + + private func initialize(request: InitializeBuildRequest) async throws -> InitializeBuildResponse { + guard state == .waitingForInitializeRequest else { + throw ResponseError.unknown("Received initialization request while the build server is \(state)") + } + let underlyingInitializationResponse = try await connectionToUnderlyingBuildServer.send(request) + let underlyingSourceKitData = SourceKitInitializeBuildResponseData(fromLSPAny: underlyingInitializationResponse.data) + // TODO: Check the underlying build server didn't register any file watchers we didn't expect + state = .waitingForInitializedNotification + scheduleRegeneratingBuildDescription() + return InitializeBuildResponse( + displayName: "SwiftPM Build Server", + version: "", + bspVersion: "2.2.0", + capabilities: BuildServerCapabilities(), + dataKind: .sourceKit, + data: SourceKitInitializeBuildResponseData( + indexDatabasePath: underlyingSourceKitData?.indexDatabasePath, + indexStorePath: underlyingSourceKitData?.indexStorePath, + outputPathsProvider: true, + prepareProvider: true, + sourceKitOptionsProvider: true, + watchers: [] // TODO: add watchers + ).encodeToLSPAny() + ) + } + + private func manifestTarget() -> BuildTarget { + // TODO: there should be a target to represent plugin scripts + return BuildTarget( + id: .forPackageManifest, + displayName: "Package Manifest", + tags: [.notBuildable], + languageIds: [.swift], + dependencies: [] + ) + } + + private func manifestSourcesItem() -> SourcesItem { + // TODO: share the code for discovering version-specific manifests with regular manifest loading, then potentially remove the packageRoot property + let packageManifestName = #/^Package@swift-(\d+)(?:\.(\d+))?(?:\.(\d+))?.swift$/# + let versionSpecificManifests = try? FileManager.default.contentsOfDirectory( + at: packageRoot.asURL, + includingPropertiesForKeys: nil + ).compactMap { (url) -> SourceItem? in + guard (try? packageManifestName.wholeMatch(in: url.lastPathComponent)) != nil else { + return nil + } + return SourceItem( + uri: DocumentURI(url), + kind: .file, + generated: false + ) + } + return SourcesItem(target: .forPackageManifest, sources: [ + SourceItem( + uri: DocumentURI(packageRoot.appending(component: "Package.swift").asURL), + kind: .file, + generated: false + ) + ] + (versionSpecificManifests ?? [])) + } + + private func manifestSourceKitOptions(request: TextDocumentSourceKitOptionsRequest) async throws -> TextDocumentSourceKitOptionsResponse? { + guard request.target == .forPackageManifest else { + throw ResponseError.unknown("Unknown target \(request.target)") + } + guard let path = try request.textDocument.uri.fileURL?.filePath else { + throw ResponseError.unknown("Unknown manifest path for \(request.textDocument.uri.pseudoPath)") + } + let compilerArgs = try workspace.interpreterFlags(for: path) + [path.pathString] + return TextDocumentSourceKitOptionsResponse(compilerArguments: compilerArgs) + } + + private func shutdown() -> SWBBuildServerProtocol.VoidResponse { + state = .shutdown + return VoidResponse() + } + + private func waitForBuildSystemUpdates(request: WorkspaceWaitForBuildSystemUpdatesRequest) async -> SWBBuildServerProtocol.VoidResponse { + await packageLoadingQueue.async {}.valuePropagatingCancellation + return VoidResponse() + } + + /// An event is relevant if it modifies a file that matches one of the file rules used by the SwiftPM workspace. + private func fileEventShouldTriggerPackageReload(event: FileEvent) -> Bool { + guard let fileURL = event.uri.fileURL else { + return false + } + switch event.type { + case .created, .deleted: + // TODO: this is overly conservative + return true + case .changed: + // TODO: check for changes to version specific manifests too + return fileURL.lastPathComponent == "Package.swift" || fileURL.lastPathComponent == "Package.resolved" + default: + // TODO: log unknown change type + return false + } + } + + public func scheduleRegeneratingBuildDescription() { + packageLoadingQueue.async { [buildSystem] in + do { + // TODO: should the PIF update API be exposed as API on SWBBuildServer somehow so we don't need to talk to the session directly? + try await buildSystem.writePIF() + self.connectionToUnderlyingBuildServer.send(OnWatchedFilesDidChangeNotification(changes: [ + .init(uri: .init(buildSystem.buildParameters.pifManifest.asURL), type: .changed) + ])) + _ = try await self.connectionToUnderlyingBuildServer.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + } catch { + + } + } + } +} + +extension BuildTargetIdentifier { + static let swiftPMBuildServerTargetScheme = "swiftpm" + + static let forPackageManifest = BuildTargetIdentifier(uri: try! URI(string: "\(swiftPMBuildServerTargetScheme)://package-manifest")) + + var isSwiftPMBuildServerTargetID: Bool { + uri.scheme == Self.swiftPMBuildServerTargetScheme + } +} diff --git a/Tests/SwiftPMBuildServerTests/BuildServerTests.swift b/Tests/SwiftPMBuildServerTests/BuildServerTests.swift new file mode 100644 index 00000000000..aa9b8cb3b62 --- /dev/null +++ b/Tests/SwiftPMBuildServerTests/BuildServerTests.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import Foundation +import PackageModel +import SWBBuildServerProtocol +import SwiftPMBuildServer +import _InternalTestSupport +import Testing + +final fileprivate class NotificationCollectingMessageHandler: MessageHandler { + var notifications: [any NotificationType] = [] + + func handle(_ notification: some SWBBuildServerProtocol.NotificationType) { + notifications.append(notification) + } + func handle(_ request: Request, id: SWBBuildServerProtocol.RequestID, reply: @escaping @Sendable (SWBBuildServerProtocol.LSPResult) -> Void) where Request : SWBBuildServerProtocol.RequestType {} +} + +fileprivate func withSwiftPMBSP(fixtureName: String, body: (Connection, NotificationCollectingMessageHandler, AbsolutePath) async throws -> Void) async throws { + try await fixture(name: fixtureName) { fixture in + let inPipe = Pipe() + let outPipe = Pipe() + let connection = JSONRPCConnection( + name: "bsp-connection", + protocol: SWBBuildServerProtocol.bspRegistry, + inFD: inPipe.fileHandleForReading, + outFD: outPipe.fileHandleForWriting + ) + let bspProcess = Process() + bspProcess.standardOutputPipe = inPipe + bspProcess.standardInput = outPipe + let execPath = SwiftPM.xctestBinaryPath(for: "swift-package").pathString + bspProcess.executableURL = URL(filePath: execPath) + bspProcess.arguments = ["build-server", "--build-system", "swiftbuild"] + bspProcess.currentDirectoryURL = URL(filePath: fixture.pathString) + bspProcess.launch() as Void + let notificationCollector = NotificationCollectingMessageHandler() + connection.start(receiveHandler: notificationCollector) + _ = try await connection.send( + InitializeBuildRequest( + displayName: "test-bsp-client", + version: "1.0.0", + bspVersion: "2.2.0", + rootUri: URI(URL(filePath: fixture.pathString)), + capabilities: .init(languageIds: [.swift, .c, .objective_c, .cpp, .objective_cpp]) + ) + ) + connection.send(OnBuildInitializedNotification()) + _ = try await connection.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + try await body(connection, notificationCollector, fixture) + _ = try await connection.send(BuildShutdownRequest()) + connection.send(OnBuildExitNotification()) + connection.close() + bspProcess.waitUntilExit() + } +} + +@Suite +struct SwiftPMBuildServerTests { + @Test + func lifecycleBasics() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/Simple") { connection, _, _ in + // Do nothing, but ensure the surrounding initialization and shutdown complete successfully. + } + } + + @Test + func buildTargetsListBasics() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/Simple") { connection, _, _ in + let response = try await connection.send(WorkspaceBuildTargetsRequest()) + #expect(response.targets.count == 2) + #expect(response.targets.map(\.displayName).sorted() == ["Foo", "Package Manifest"]) + } + } + + @Test + func sourcesItemsBasics() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/Simple") { connection, _, _ in + let targetResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + #expect(targetResponse.targets.count == 2) + #expect(targetResponse.targets.map(\.displayName).sorted() == ["Foo", "Package Manifest"]) + + let fooID = try #require(targetResponse.targets.first(where: { $0.displayName == "Foo" })).id + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [fooID])) + let item = try #require(sourcesResponse.items.only?.sources.only) + #expect(item.kind == .file) + #expect(item.uri.fileURL?.lastPathComponent == "Foo.swift") + } + } + + @Test + func compilerArgsBasics() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/Simple") { connection, _, _ in + let targetResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + #expect(targetResponse.targets.count == 2) + #expect(targetResponse.targets.map(\.displayName).sorted() == ["Foo", "Package Manifest"]) + + let fooID = try #require(targetResponse.targets.first(where: { $0.displayName == "Foo" })).id + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [fooID])) + let item = try #require(sourcesResponse.items.only?.sources.only) + #expect(item.kind == .file) + #expect(item.uri.fileURL?.lastPathComponent == "Foo.swift") + + _ = try await connection.send(BuildTargetPrepareRequest(targets: [fooID])) + + let settingsResponse = try #require(try await connection.send(TextDocumentSourceKitOptionsRequest(textDocument: TextDocumentIdentifier(item.uri), target: fooID, language: .swift))) + #expect(settingsResponse.compilerArguments.contains(["-module-name", "Foo"])) + try await AsyncProcess.checkNonZeroExit(arguments: [UserToolchain.default.swiftCompilerPath.pathString, "-typecheck"] + settingsResponse.compilerArguments) + } + } + + @Test + func packageReloadBasics() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/Simple") { connection, _, fixturePath in + let targetResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + #expect(targetResponse.targets.count == 2) + #expect(targetResponse.targets.map(\.displayName).sorted() == ["Foo", "Package Manifest"]) + + let fooID = try #require(targetResponse.targets.first(where: { $0.displayName == "Foo" })).id + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [fooID])) + let sourcesItem = try #require(sourcesResponse.items.only) + #expect(sourcesItem.sources.count == 1) + #expect(sourcesItem.sources.map(\.uri.fileURL?.lastPathComponent).sorted() == ["Foo.swift"]) + + try localFileSystem.writeFileContents(fixturePath.appending(component: "Bar.swift"), body: { + $0.write("public let baz = \"hello\"") + }) + + connection.send(OnWatchedFilesDidChangeNotification(changes: [ + .init(uri: .init(.init(filePath: fixturePath.appending(component: "Bar.swift").pathString)), type: .created) + ])) + _ = try await connection.send(WorkspaceWaitForBuildSystemUpdatesRequest()) + + let updatedSourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [fooID])) + let updatedSourcesItem = try #require(updatedSourcesResponse.items.only) + #expect(updatedSourcesItem.sources.count == 2) + #expect(updatedSourcesItem.sources.map(\.uri.fileURL?.lastPathComponent).sorted() == ["Bar.swift", "Foo.swift"]) + } + } + + @Test + func manifestArgs() async throws { + try await withSwiftPMBSP(fixtureName: "Miscellaneous/VersionSpecificManifest") { connection, _, _ in + let targetResponse = try await connection.send(WorkspaceBuildTargetsRequest()) + #expect(targetResponse.targets.count == 2) + #expect(targetResponse.targets.map(\.displayName).sorted() == ["Foo", "Package Manifest"]) + + let manifestTarget = try #require(targetResponse.targets.first(where: { $0.displayName == "Package Manifest" })) + #expect(manifestTarget.tags.contains(.notBuildable)) + let manifestID = manifestTarget.id + let sourcesResponse = try await connection.send(BuildTargetSourcesRequest(targets: [manifestID])) + let manifestItems = try #require(sourcesResponse.items.only?.sources) + #expect(manifestItems.map(\.uri.fileURL?.lastPathComponent).sorted() == ["Package.swift", "Package@swift-5.0.swift"]) + for item in manifestItems { + let settingsResponse = try #require(try await connection.send(TextDocumentSourceKitOptionsRequest(textDocument: TextDocumentIdentifier(item.uri), target: manifestID, language: .swift))) + try await AsyncProcess.checkNonZeroExit(arguments: [UserToolchain.default.swiftCompilerPath.pathString, "-typecheck"] + settingsResponse.compilerArguments) + } + } + } +}