From aa4a3fda6005127e4de2fd79f5bd98af318f103b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:33:12 -0500 Subject: [PATCH 1/5] Ensure LSPs Exit In Reasonable Time On Quit --- CodeEdit/AppDelegate.swift | 52 ++++++++++++-- .../TaskNotificationHandler.swift | 68 ++++++++++++++---- .../WorkspaceDocument/WorkspaceDocument.swift | 1 + .../DataChannel+localProcess.swift | 69 +++++++++++++++++++ .../LSP/LanguageServer/LanguageServer.swift | 20 ++++-- .../Features/LSP/Service/LSPService.swift | 24 ++++--- CodeEdit/Utils/withTimeout.swift | 46 +++++++++++++ 7 files changed, 246 insertions(+), 34 deletions(-) create mode 100644 CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift create mode 100644 CodeEdit/Utils/withTimeout.swift diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 7945d1e715..2f8120e86a 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -121,6 +121,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } + // MARK: - Should Terminate + /// Defers the application terminate message until we've finished cleanup. /// /// All paths _must_ call `NSApplication.shared.reply(toApplicationShouldTerminate: true)` as soon as possible. @@ -255,20 +257,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { /// Terminates running language servers. Used during app termination to ensure resources are freed. private func terminateLanguageServers() { - Task { - await lspService.stopAllServers() - await MainActor.run { - NSApplication.shared.reply(toApplicationShouldTerminate: true) + Task { @MainActor in + let task = TaskNotificationModel( + id: "appdelegate.terminate_language_servers", + title: "Stopping Language Servers", + message: "Stopping running language server processes...", + isLoading: true + ) + + if !lspService.languageClients.isEmpty { + TaskNotificationHandler.postTask(action: .create, model: task) } + + try await withTimeout( + duration: .seconds(5.0), + onTimeout: { + // Stop-gap measure to ensure we don't hang on CMD-Q + await self.lspService.killAllServers() + }, + operation: { + await self.lspService.stopAllServers() + } + ) + + TaskNotificationHandler.postTask(action: .delete, model: task) + NSApplication.shared.reply(toApplicationShouldTerminate: true) } } /// Terminates all running tasks. Used during app termination to ensure resources are freed. private func terminateTasks() { - let documents = CodeEditDocumentController.shared.documents.compactMap({ $0 as? WorkspaceDocument }) - documents.forEach { workspace in - workspace.taskManager?.stopAllTasks() + let task = TaskNotificationModel( + id: "appdelegate.terminate_tasks", + title: "Terminating Tasks", + message: "Interrupting all running tasks before quitting...", + isLoading: true + ) + + let taskManagers = CodeEditDocumentController.shared.documents + .compactMap({ $0 as? WorkspaceDocument }) + .compactMap({ $0.taskManager }) + + if taskManagers.reduce(0, { $0 + $1.activeTasks.count }) > 0 { + TaskNotificationHandler.postTask(action: .create, model: task) } + + taskManagers.forEach { manager in + manager.stopAllTasks() + } + + TaskNotificationHandler.postTask(action: .delete, model: task) } } diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift index ae0fdc645a..256844e7eb 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift @@ -25,28 +25,35 @@ import Combine /// Remember to manage your task notifications appropriately. You should either delete task /// notifications manually or schedule their deletion in advance using the `deleteWithDelay` method. /// +/// Some tasks should be restricted to a specific workspace. To do this, specify the `workspace` attribute in the +/// notification's `userInfo` dictionary as a `URL`, or use the `toWorkspace` parameter on +/// ``TaskNotificationHandler/postTask(toWorkspace:action:model:)``. +/// /// ## Available Methods /// - `create`: /// Creates a new Task Notification. /// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). /// - `createWithPriority`: /// Creates a new Task Notification and inserts it at the start of the array. /// This ensures it appears in the activity viewer even if there are other task notifications before it. /// **Note:** This should only be used for important notifications! /// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). /// - `update`: /// Updates an existing task notification. It's important to pass the same `id` to update the correct task. /// Required fields: `id` (String), `action` (String). -/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool), +/// `workspace` (URL). /// - `delete`: /// Deletes an existing task notification. /// Required fields: `id` (String), `action` (String). +/// Optional field: `workspace` (URL). /// - `deleteWithDelay`: /// Deletes an existing task notification after a certain `TimeInterval`. /// Required fields: `id` (String), `action` (String), `delay` (Double). -/// **Important:** When specifying the delay, ensure it's a double. +/// Optional field: `workspace` (URL). +/// **Important:** When specifying the delay, ensure it's a double. /// For example, '2' would be invalid because it would count as an integer, use '2.0' instead. /// /// ## Example Usage: @@ -101,13 +108,46 @@ import Combine /// } /// ``` /// +/// You can also use the static helper method instead of creating dictionaries manually: +/// ```swift +/// TaskNotificationHandler.postTask(action: .create, model: .init(id: "task_id", "title": "New Task")) +/// ``` +/// /// - Important: Please refer to ``CodeEdit/TaskNotificationModel`` and ensure you pass the correct values. final class TaskNotificationHandler: ObservableObject { @Published private(set) var notifications: [TaskNotificationModel] = [] + var workspaceURL: URL? var cancellables: Set = [] + enum Action: String { + case create + case createWithPriority + case update + case delete + case deleteWithDelay + } + + /// Post a new task. + /// - Parameters: + /// - toWorkspace: The workspace to restrict the task to. Defaults to `nil`, which is received by all workspaces. + /// - action: The action being taken on the task. + /// - model: The task contents. + static func postTask(toWorkspace: URL? = nil, action: Action, model: TaskNotificationModel) { + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: [ + "id": model.id, + "title": model.title, + "message": model.message as Any, + "percentage": model.percentage as Any, + "isLoading": model.isLoading, + "action": action.rawValue, + "workspace": toWorkspace as Any + ]) + } + /// Initialises a new `TaskNotificationHandler` and starts observing for task notifications. - init() { + init(workspaceURL: URL? = nil) { + self.workspaceURL = workspaceURL + NotificationCenter.default .publisher(for: .taskNotification) .receive(on: DispatchQueue.main) @@ -127,21 +167,25 @@ final class TaskNotificationHandler: ObservableObject { private func handleNotification(_ notification: Notification) { guard let userInfo = notification.userInfo, let taskID = userInfo["id"] as? String, - let action = userInfo["action"] as? String else { return } + let actionRaw = userInfo["action"] as? String, + let action = Action(rawValue: actionRaw) else { return } + + /// If a workspace is specified, don't do anything with this task. + if let workspaceURL = userInfo["workspace"] as? URL, workspaceURL != self.workspaceURL { + return + } switch action { - case "create", "createWithPriority": + case .create, .createWithPriority: createTask(task: userInfo) - case "update": + case .update: updateTask(task: userInfo) - case "delete": + case .delete: deleteTask(taskID: taskID) - case "deleteWithDelay": + case .deleteWithDelay: if let delay = userInfo["delay"] as? Double { deleteTaskAfterDelay(taskID: taskID, delay: delay) } - default: - break } } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index a47fdbba83..eb38b79f22 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -161,6 +161,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { workspaceURL: url ) } + self.taskNotificationHandler.workspaceURL = url editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) diff --git a/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift b/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift new file mode 100644 index 0000000000..3f3d49e5ac --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift @@ -0,0 +1,69 @@ +// +// DataChannel+localProcess.swift +// CodeEdit +// +// Created by Khan Winter on 7/8/25. +// + +import Foundation +import LanguageServerProtocol +import JSONRPC +import ProcessEnv + +// This is almost exactly the same as the extension provided by `LanguageServerProtocol`, except it returns the +// process ID as well as the channel, which we need. + +extension DataChannel { + @available(macOS 12.0, *) + public static func localProcessChannel( + parameters: Process.ExecutionParameters, + terminationHandler: @escaping @Sendable () -> Void + ) throws -> (pid: pid_t, channel: DataChannel) { + let process = Process() + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.parameters = parameters + + let (stream, continuation) = DataSequence.makeStream() + + process.terminationHandler = { _ in + continuation.finish() + terminationHandler() + } + + Task { + let dataStream = stdoutPipe.fileHandleForReading.dataStream + + for try await data in dataStream { + continuation.yield(data) + } + + continuation.finish() + } + + Task { + for try await line in stderrPipe.fileHandleForReading.bytes.lines { + print("stderr: ", line) + } + } + + try process.run() + + let handler: DataChannel.WriteHandler = { + // this is wacky, but we need the channel to hold a strong reference to the process + // to prevent it from being deallocated + _ = process + + try stdinPipe.fileHandleForWriting.write(contentsOf: $0) + } + + return (process.processIdentifier, DataChannel(writeHandler: handler, dataSequence: stream)) + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index a7c48bb251..5c9a5ed0af 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -40,17 +40,21 @@ class LanguageServer { private(set) var lspInstance: InitializingServer /// The path to the root of the project private(set) var rootPath: URL + /// The PID of the running language server process. + private(set) var pid: pid_t init( languageId: LanguageIdentifier, binary: LanguageServerBinary, lspInstance: InitializingServer, + lspPid: pid_t, serverCapabilities: ServerCapabilities, rootPath: URL ) { self.languageId = languageId self.binary = binary self.lspInstance = lspInstance + self.pid = lspPid self.serverCapabilities = serverCapabilities self.rootPath = rootPath self.openFiles = LanguageServerFileMap() @@ -82,8 +86,9 @@ class LanguageServer { environment: binary.env ) + let (pid, connection) = try makeLocalServerConnection(languageId: languageId, executionParams: executionParams) let server = InitializingServer( - server: try makeLocalServerConnection(languageId: languageId, executionParams: executionParams), + server: connection, initializeParamsProvider: getInitParams(workspacePath: workspacePath) ) let capabilities = try await server.initializeIfNeeded() @@ -91,6 +96,7 @@ class LanguageServer { languageId: languageId, binary: binary, lspInstance: server, + lspPid: pid, serverCapabilities: capabilities, rootPath: URL(filePath: workspacePath) ) @@ -106,15 +112,15 @@ class LanguageServer { static func makeLocalServerConnection( languageId: LanguageIdentifier, executionParams: Process.ExecutionParameters - ) throws -> JSONRPCServerConnection { + ) throws -> (pid: pid_t, connection: JSONRPCServerConnection) { do { - let channel = try DataChannel.localProcessChannel( + let (pid, channel) = try DataChannel.localProcessChannel( parameters: executionParams, terminationHandler: { logger.debug("Terminated data channel for \(languageId.rawValue)") } ) - return JSONRPCServerConnection(dataChannel: channel) + return (pid, JSONRPCServerConnection(dataChannel: channel)) } catch { logger.warning("Failed to initialize data channel for \(languageId.rawValue)") throw error @@ -232,10 +238,14 @@ class LanguageServer { // swiftlint:enable function_body_length } + // MARK: - Shutdown + /// Shuts down the language server and exits it. public func shutdown() async throws { self.logger.info("Shutting down language server") - try await lspInstance.shutdownAndExit() + try await withTimeout(duration: .seconds(1.0)) { + try await self.lspInstance.shutdownAndExit() + } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index df74fb1399..7fbb8f9f27 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -300,16 +300,13 @@ final class LSPService: ObservableObject { /// Goes through all active language servers and attempts to shut them down. func stopAllServers() async { - await withThrowingTaskGroup(of: Void.self) { group in - for (key, server) in languageClients { - group.addTask { - do { - try await server.shutdown() - } catch { - self.logger.error("Shutting down \(key.languageId.rawValue): Error \(error)") - throw error - } - } + // Note: This is no longer a task group for a *REASON* + // The task group for some reason would never return from the `await` suspension point. + for (key, server) in languageClients { + do { + try await server.shutdown() + } catch { + self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") } } languageClients.removeAll() @@ -318,4 +315,11 @@ final class LSPService: ObservableObject { } eventListeningTasks.removeAll() } + + /// Call this when a server is refusing to terminate itself. Sends the `SIGKILL` signal to all lsp processes. + func killAllServers() { + for (_, server) in languageClients { + kill(server.pid, SIGKILL) + } + } } diff --git a/CodeEdit/Utils/withTimeout.swift b/CodeEdit/Utils/withTimeout.swift new file mode 100644 index 0000000000..2ebd9b406e --- /dev/null +++ b/CodeEdit/Utils/withTimeout.swift @@ -0,0 +1,46 @@ +// +// TimedOutError.swift +// CodeEdit +// +// Created by Khan Winter on 7/8/25. +// + +struct TimedOutError: Error, Equatable {} + +/// Execute an operation in the current task subject to a timeout. +/// - Warning: This still requires cooperative task cancellation to work correctly. Ensure tasks opt +/// into cooperative cancellation. +/// - Parameters: +/// - duration: The duration to wait until timing out. Uses a continuous clock. +/// - operation: The async operation to perform. +/// - Returns: Returns the result of `operation` if it completed in time. +/// - Throws: Throws ``TimedOutError`` if the timeout expires before `operation` completes. +/// If `operation` throws an error before the timeout expires, that error is propagated to the caller. +public func withTimeout( + duration: Duration, + onTimeout: @escaping @Sendable () async throws -> Void = { }, + operation: @escaping @Sendable () async throws -> R +) async throws -> R { + return try await withThrowingTaskGroup(of: R.self) { group in + let deadline: ContinuousClock.Instant = .now + duration + + // Start actual work. + group.addTask { + return try await operation() + } + // Start timeout child task. + group.addTask { + if .now > deadline { + try await Task.sleep(until: deadline) // sleep until the deadline + } + try Task.checkCancellation() + // We’ve reached the timeout. + try await onTimeout() + throw TimedOutError() + } + // First finished child task wins, cancel the other task. + let result = try await group.next()! + group.cancelAll() + return result + } +} From 042d6b2fb12bd75869c84ac0fa012dce8d21b8c8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:44:45 -0500 Subject: [PATCH 2/5] Use correct task group --- CodeEdit/Features/LSP/Service/LSPService.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 7fbb8f9f27..7b18aad8ac 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -300,13 +300,15 @@ final class LSPService: ObservableObject { /// Goes through all active language servers and attempts to shut them down. func stopAllServers() async { - // Note: This is no longer a task group for a *REASON* - // The task group for some reason would never return from the `await` suspension point. - for (key, server) in languageClients { - do { - try await server.shutdown() - } catch { - self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") + await withTaskGroup(of: Void.self) { group in + for (key, server) in languageClients { + group.addTask { + do { + try await server.shutdown() + } catch { + self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") + } + } } } languageClients.removeAll() From 0393ce70b3a0f9c8197bcd3e4ed09297b625c7f7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:59:57 -0500 Subject: [PATCH 3/5] Limit tasks to workspaces --- .../TaskNotificationHandler.swift | 2 +- .../Features/Tasks/Models/CEActiveTask.swift | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift index 256844e7eb..77ecc77fa8 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift @@ -170,7 +170,7 @@ final class TaskNotificationHandler: ObservableObject { let actionRaw = userInfo["action"] as? String, let action = Action(rawValue: actionRaw) else { return } - /// If a workspace is specified, don't do anything with this task. + // If a workspace is specified and doesn't match, don't do anything with this task. if let workspaceURL = userInfo["workspace"] as? URL, workspaceURL != self.workspaceURL { return } diff --git a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift index 3b6d94cdb8..548436575c 100644 --- a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift +++ b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift @@ -19,8 +19,18 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { /// The name of the associated task. @ObservedObject var task: CETask + /// Prevents tasks overwriting each other. + /// Say a user cancels one task, then runs it immediately, the cancel message should show and then the + /// starting message should show. If we don't add this modifier the starting message will be deleted. + var activeTaskID: String = UUID().uuidString + + var taskId: String { + task.id.uuidString + "-" + activeTaskID + } + var process: Process? var outputPipe: Pipe? + var workspaceURL: URL? private var cancellables = Set() @@ -35,6 +45,8 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { } func run(workspaceURL: URL? = nil) { + self.workspaceURL = workspaceURL + self.activeTaskID = UUID().uuidString // generate a new ID for this run Task { // Reconstruct the full command to ensure it executes in the correct directory. // Because: CETask only contains information about the relative path. @@ -146,11 +158,12 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { private func createStatusTaskNotification() { let userInfo: [String: Any] = [ - "id": self.task.id.uuidString, + "id": taskId, "action": "createWithPriority", "title": "Running \(self.task.name)", "message": "Running your task: \(self.task.name).", - "isLoading": true + "isLoading": true, + "workspace": workspaceURL as Any ] NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) @@ -158,9 +171,10 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { private func deleteStatusTaskNotification() { let deleteInfo: [String: Any] = [ - "id": "\(task.id.uuidString)", + "id": taskId, "action": "deleteWithDelay", - "delay": 3.0 + "delay": 3.0, + "workspace": workspaceURL as Any ] NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) @@ -168,8 +182,9 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { private func updateTaskNotification(title: String? = nil, message: String? = nil, isLoading: Bool? = nil) { var userInfo: [String: Any] = [ - "id": task.id.uuidString, - "action": "update" + "id": taskId, + "action": "update", + "workspace": workspaceURL as Any ] if let title { userInfo["title"] = title From 2e4d68d162bda1358efde904e36c3cd52088b5a0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:15:01 -0500 Subject: [PATCH 4/5] Update `LanguageClient` to `0.8.2` --- CodeEdit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 26 +++---- .../DataChannel+localProcess.swift | 69 ------------------- .../LSP/LanguageServer/LanguageServer.swift | 18 +++-- 4 files changed, 25 insertions(+), 90 deletions(-) delete mode 100644 CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0511094f90..f68d8e5a0b 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -1656,7 +1656,7 @@ repositoryURL = "https://github.com/ChimeHQ/LanguageClient"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.8.0; + minimumVersion = 0.8.2; }; }; 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 49cc5d067d..3382f8b528 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -91,15 +91,6 @@ "version" : "2.1.0" } }, - { - "identity" : "globpattern", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/GlobPattern", - "state" : { - "revision" : "4ebb9e89e07cc475efa74f87dc6d21f4a9e060f8", - "version" : "0.1.1" - } - }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -123,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageClient", "state" : { - "revision" : "f8fdeaed850fbc3e542cd038e952758887f6be5d", - "version" : "0.8.0" + "revision" : "4f28cc3cad7512470275f65ca2048359553a86f5", + "version" : "0.8.2" } }, { @@ -132,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", "state" : { - "revision" : "ac76fccf0e981c8e30c5ee4de1b15adc1decd697", - "version" : "0.13.2" + "revision" : "f7879c782c0845af9c576de7b8baedd946237286", + "version" : "0.14.0" } }, { @@ -208,6 +199,15 @@ "version" : "1.1.3" } }, + { + "identity" : "swift-glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/davbeck/swift-glob", + "state" : { + "revision" : "07ba6f47d903a0b1b59f12ca70d6de9949b975d6", + "version" : "0.2.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift b/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift deleted file mode 100644 index 3f3d49e5ac..0000000000 --- a/CodeEdit/Features/LSP/LanguageServer/DataChannel+localProcess.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// DataChannel+localProcess.swift -// CodeEdit -// -// Created by Khan Winter on 7/8/25. -// - -import Foundation -import LanguageServerProtocol -import JSONRPC -import ProcessEnv - -// This is almost exactly the same as the extension provided by `LanguageServerProtocol`, except it returns the -// process ID as well as the channel, which we need. - -extension DataChannel { - @available(macOS 12.0, *) - public static func localProcessChannel( - parameters: Process.ExecutionParameters, - terminationHandler: @escaping @Sendable () -> Void - ) throws -> (pid: pid_t, channel: DataChannel) { - let process = Process() - - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.standardInput = stdinPipe - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.parameters = parameters - - let (stream, continuation) = DataSequence.makeStream() - - process.terminationHandler = { _ in - continuation.finish() - terminationHandler() - } - - Task { - let dataStream = stdoutPipe.fileHandleForReading.dataStream - - for try await data in dataStream { - continuation.yield(data) - } - - continuation.finish() - } - - Task { - for try await line in stderrPipe.fileHandleForReading.bytes.lines { - print("stderr: ", line) - } - } - - try process.run() - - let handler: DataChannel.WriteHandler = { - // this is wacky, but we need the channel to hold a strong reference to the process - // to prevent it from being deallocated - _ = process - - try stdinPipe.fileHandleForWriting.write(contentsOf: $0) - } - - return (process.processIdentifier, DataChannel(writeHandler: handler, dataSequence: stream)) - } -} diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index 5c9a5ed0af..004d6ffc50 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -86,18 +86,22 @@ class LanguageServer { environment: binary.env ) - let (pid, connection) = try makeLocalServerConnection(languageId: languageId, executionParams: executionParams) + let (connection, process) = try makeLocalServerConnection( + languageId: languageId, + executionParams: executionParams + ) let server = InitializingServer( server: connection, initializeParamsProvider: getInitParams(workspacePath: workspacePath) ) - let capabilities = try await server.initializeIfNeeded() + let initializationResponse = try await server.initializeIfNeeded() + return LanguageServer( languageId: languageId, binary: binary, lspInstance: server, - lspPid: pid, - serverCapabilities: capabilities, + lspPid: process.processIdentifier, + serverCapabilities: initializationResponse.capabilities, rootPath: URL(filePath: workspacePath) ) } @@ -112,15 +116,15 @@ class LanguageServer { static func makeLocalServerConnection( languageId: LanguageIdentifier, executionParams: Process.ExecutionParameters - ) throws -> (pid: pid_t, connection: JSONRPCServerConnection) { + ) throws -> (connection: JSONRPCServerConnection, process: Process) { do { - let (pid, channel) = try DataChannel.localProcessChannel( + let (channel, process) = try DataChannel.localProcessChannel( parameters: executionParams, terminationHandler: { logger.debug("Terminated data channel for \(languageId.rawValue)") } ) - return (pid, JSONRPCServerConnection(dataChannel: channel)) + return (JSONRPCServerConnection(dataChannel: channel), process) } catch { logger.warning("Failed to initialize data channel for \(languageId.rawValue)") throw error From 9afbd603aa32836856b37e01687ea1c15123e26d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:18:14 -0500 Subject: [PATCH 5/5] Fix Tests! --- CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift | 1 + CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index 236f2a7215..4e632f2993 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -69,6 +69,7 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { server: bufferingConnection, initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: tempTestDir.path()) ), + lspPid: -1, serverCapabilities: capabilities, rootPath: tempTestDir ) diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift index 76b2e8cf3c..9e405087c9 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -49,6 +49,7 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { server: BufferingServerConnection(), initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: "/") ), + lspPid: -1, serverCapabilities: capabilities, rootPath: URL(fileURLWithPath: "") )