diff --git a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md index c0a4201b7c..d0bacb4f6a 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md @@ -96,6 +96,22 @@ MacServiceContainer 查询全部为空。不要用睡眠或反复重新注入能力掩盖这个顺序;搜索排除回归与插件页面 生命周期测试都使用同一个明确的完成边界。 +### 语言服务恢复必须等待工作区清理 + +macOS 打开项目时会异步清理上一轮模块图;清理结束前,语言能力仍可能留在缓存中。 +#1524 曾让 Java 预热拿到旧能力,接着旧清理才执行到 Java 插件,取消刚启动的会话。 +`LanguageIntelligenceModuleCoordinator` 与执行、调试协调器共用 +`awaitModuleRuntimeShutdown()`:先等待整轮清理,再检查调用方取消状态,最后读取能力缓存或激活模块。 +后台维护入口也必须经过这个顺序,不能提前捕获 session 然后在等待后返回旧对象。 +最终关闭项目会话时,`shutdownProjectSession()` 在首次挂起前标记会话已关闭,并使工作区身份失效;Settings 即使继续持有工作区 URL 和文档,延后启动也不得恢复。语言协调器等待清理后再次检查关闭状态并以取消结束,覆盖能力激活和测试扩展入口。不能用窗口是否前台作为关闭标记,因为后台项目仍可合法运行语言服务。 +同步文档入口在清理期间延后启动,恢复时重新检查工作区身份和文档是否仍打开;等待不会阻塞界面线程。 + +Java 的准备状态是应用操作的投影。当前 operation ID 对应的 `.stopped` 到达后, +取消该准备 owner 并回到 idle;旧 operation 的迟到事件不得清除较新准备状态。 +不能通过删除导航的就绪检查、增加固定延时或无限重试来掩盖清理顺序错误。 +`LanguageIntelligenceModuleCoordinatorTests` 用有截止的异步闸门暂停清理,覆盖普通激活、 +后台维护、缓存复用和等待期间取消;`JavaLanguageServerRuntimeTests` 覆盖停止后的状态收尾及迟到事件。 + ### 数据库视图在模块释放后的刷新 退出应用或休眠数据库模块时,SwiftUI(macOS 的声明式界面框架)可能先刷新 diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index 657e05ee2b..070cbfb6ce 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -63,6 +63,10 @@ JDK 也一样:`ProjectRuntimeService` 不再问“app 内置的 JDK 在哪” 新增一个集成时,只需要新增一个实现协议的类型并在注册处加一行;Rust Core 的契约校验仍然决定哪些 ID 可以被声明。 +插件身份与宿主集成必须同时传入 Core(#1523):`StdioLanguageServerSession` 的插件启动入口携带 `hostIntegrationLaunch`、平台缓存目录、工作区指纹、Maven/Gradle 上下文,以及插件配置和 host services。不能因为存在 `pluginID` 就退回只传可执行文件和静态参数的入口,否则 JDT 会把 Equinox jar 当包装脚本,缺少 `-configuration`,随后退出并在发行目录写日志。继续复用 Core 的 `prepare_jdt_configuration_area`,从包内只读配置生成宿主缓存中的可写副本;没有新增资源目录或缓存所有者,也不改变应用签名和增量更新的发行基线。 + +同一工作区的服务连续三次启动失败后,自动同步直接返回已有失败,直到显式停止/重启或切换工作区;进入 ready 后清零。这保留短暂故障后的重试,但阻止每次编辑器同步重新创建 JVM。这里选择有限尝试而非增加定时器和无限退避;代价是持续失败需要用户修复环境后重新启动服务。 + ### 4. 运行、测试、调试由插件声明后交还宿主 插件一旦声明某个语言,宿主就认为这个语言的进程类能力归插件所有:能力目录(`PluginLanguageProviderCatalogSource`)去掉运行、测试、调试标记,语言包注册表(`LanguagePackRegistry`)不再创建内置的测试提供方,运行和测试服务要求必须有插件的执行模块。这条规则是为 PHP、Go 这类整套由插件提供的语言定的,目的是插件缺失或停用时宿主不偷偷退回内置工具。 @@ -182,6 +186,8 @@ dev.lithe.plugin.java-support/ - `./scripts/verify-macos-package.sh`:打出的应用里 Java Support 的文件齐全,且不存在 `Contents/Resources/LanguageServers`。 - `./scripts/verify-runtime-bundle-immutability.sh`:启动与插件工作流不改写安装目录。 +生产启动适配的回归使用 `RustLanguageServerLaunchTests`,直接从 `RustCoreBridge.startPluginLanguageServer` 经 C ABI 启动本地标准输入输出协议夹具;不经过协议默认回退。它用函数所属插件校验、credentials 请求与响应、通用插件配置查询、Java 启动参数、缓存配置副本和工作区指纹日志验证实际接线。夹具使用 macOS 开发工具已有的 Python,不依赖真实 JVM 或联网;Core 会话负责子进程,测试始终停止并销毁会话,再移除临时资源。运行命令:`./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter RustLanguageServerLaunchTests`。 + ## 适用范围 - `shared/contracts/plugins/v1/` diff --git a/docs/assets/java-startup-navigation/get-order-definition.png b/docs/assets/java-startup-navigation/get-order-definition.png new file mode 100644 index 0000000000..569dbc12f8 Binary files /dev/null and b/docs/assets/java-startup-navigation/get-order-definition.png differ diff --git a/docs/assets/java-startup-navigation/java-ready.png b/docs/assets/java-startup-navigation/java-ready.png new file mode 100644 index 0000000000..79a6f86497 Binary files /dev/null and b/docs/assets/java-startup-navigation/java-ready.png differ diff --git a/macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift b/macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift index 85c9ff0d85..65fdb9a73e 100644 --- a/macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift +++ b/macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift @@ -251,7 +251,9 @@ final class JavaLanguageServerPreparationCoordinator { ? .timedOut(message: failure.message ?? "JDTLS failed to start.") : .failed(message: failure.message ?? "JDTLS failed to start.") ) - case .startingProcess, .initializing, .stopping, .stopped: + case .stopped: + javaFeature.cancelLanguageServerPreparation() + case .startingProcess, .initializing, .stopping: break } } diff --git a/macos/Sources/Lithe/Application/Composition/LanguageIntelligenceModuleCoordinator.swift b/macos/Sources/Lithe/Application/Composition/LanguageIntelligenceModuleCoordinator.swift index 9581f1eb4f..22f82b7b4e 100644 --- a/macos/Sources/Lithe/Application/Composition/LanguageIntelligenceModuleCoordinator.swift +++ b/macos/Sources/Lithe/Application/Composition/LanguageIntelligenceModuleCoordinator.swift @@ -9,22 +9,28 @@ import LitheModuleAPI final class LanguageIntelligenceModuleCoordinator { private let runtime: ModuleRuntime private let store: ModuleCapabilityStore + private let awaitShutdown: () async throws -> Void private let bind: (LanguageIntelligenceCapability) -> Void private let notify: @MainActor (String) -> Void init( runtime: ModuleRuntime, store: ModuleCapabilityStore, + awaitShutdown: @escaping () async throws -> Void, bind: @escaping (LanguageIntelligenceCapability) -> Void, notify: @escaping @MainActor (String) -> Void ) { self.runtime = runtime self.store = store + self.awaitShutdown = awaitShutdown self.bind = bind self.notify = notify } func activate() async throws -> LanguageIntelligenceCapability { + // A cached capability can still belong to the graph being torn down. + try await awaitShutdown() + try Task.checkCancellation() if let cached: LanguageIntelligenceCapability = store.capability(.languageIntelligence) { return cached } @@ -58,13 +64,8 @@ final class LanguageIntelligenceModuleCoordinator { resetWorkspaceState(feature) } - func sessionsForMaintenance( - current: LanguageToolingSessionManager? - ) async throws -> LanguageToolingSessionManager { - if let current { - return current - } - return try await activate().sessions + func sessionsForMaintenance() async throws -> LanguageToolingSessionManager { + try await activate().sessions } /// Activates the testing extension for every language support that claims @@ -76,6 +77,13 @@ final class LanguageIntelligenceModuleCoordinator { testService: LanguageTestService, supports: @MainActor ([URL]) -> [LanguageSupportDeclaration] ) async -> Bool { + do { + try await awaitShutdown() + try Task.checkCancellation() + } catch { + if !error.isCancellation { notify(error.localizedDescription) } + return false + } for support in supports(files) where support.testingModuleID != nil { do { let value = try await runtime.activateCapability( diff --git a/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift b/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift index 8ce5f514b4..020bdbec94 100644 --- a/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift +++ b/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift @@ -38,6 +38,8 @@ final class ModuleSessionCoordinator { onModuleReleased(moduleID) } + var isShuttingDown: Bool { shutdownTask != nil } + func beginShutdown() { guard shutdownTask == nil else { return } workbench.endLayoutSession() diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 5047c42e63..a888da5c1d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -250,7 +250,9 @@ extension RustCoreBridge: LanguageServerRuntimeCore { startPluginLanguageServer( pluginID: pluginID, providerID: providerID, executableURL: executableURL, arguments: arguments, environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, - workspaceConfiguration: nil, initializeTimeout: initializeTimeout, + workspaceConfiguration: nil, hostServices: [], hostIntegrationLaunch: nil, + cacheDirectoryURL: nil, workspaceFingerprint: nil, mavenContext: nil, javaGradle: nil, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout ) } @@ -264,6 +266,12 @@ extension RustCoreBridge: LanguageServerRuntimeCore { rootURL: URL, initializationOptions: ToolingJSONValue?, workspaceConfiguration: ToolingJSONValue?, + hostServices: [String], + hostIntegrationLaunch: LanguageServerHostIntegrationLaunch?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + javaGradle: JavaGradleImportConfiguration?, initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval @@ -278,6 +286,13 @@ extension RustCoreBridge: LanguageServerRuntimeCore { workingDirectoryURL: rootURL, initializationOptions: initializationOptions, workspaceConfiguration: workspaceConfiguration, + hostServices: hostServices, + runtimeExecutableURL: hostIntegrationLaunch?.runtimeExecutableURL, + jdtlsLaunchResources: hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, + javaGradle: javaGradle, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift index 97aeff3ab6..d88cd247f4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift @@ -66,9 +66,7 @@ extension AppModel { { guard let identity = expectedIdentity ?? currentWorkspaceIdentity, isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } - let sessions = try await languageIntelligenceModuleCoordinator.sessionsForMaintenance( - current: languageToolingSessionsIfActive - ) + let sessions = try await languageIntelligenceModuleCoordinator.sessionsForMaintenance() try Task.checkCancellation() guard isCurrentWorkspace(identity) else { throw CancellationError() } await registerPluginLanguageServers(in: sessions) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 37f91d78a7..f1d7854a7d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -165,7 +165,7 @@ extension AppModel { } var currentWorkspaceIdentity: WorkspaceIdentity? { - guard let url = workspaceURL?.standardizedFileURL else { return nil } + guard !isProjectSessionClosed, let url = workspaceURL?.standardizedFileURL else { return nil } return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index d5b695202b..50eacdf948 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -148,6 +148,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { private var documentLanguageCoordinator: DocumentLanguageCoordinator? private var sidebarRefreshTask: Task? private var moduleSessionCoordinator: ModuleSessionCoordinator? + private(set) var isProjectSessionClosed = false private var featureObservationBinder: AppModelObservationBinder? private var fileVisibilityRulesObserverID: UUID? private var searchExclusionsObservation: AnyCancellable? @@ -227,6 +228,10 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { lazy var languageIntelligenceModuleCoordinator = LanguageIntelligenceModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, + awaitShutdown: { [weak self] in + await self?.awaitModuleRuntimeShutdown() + guard self?.isProjectSessionClosed == false else { throw CancellationError() } + }, bind: { [weak self] in self?.bindLanguageIntelligenceCapability($0) }, notify: { [weak self] in self?.showNotification($0) } ) @@ -691,6 +696,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } func shutdownProjectSession() async { + isProjectSessionClosed = true // Native window close can retain this model through Settings without // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() @@ -1359,6 +1365,16 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { ) return false } + if moduleSessionCoordinator?.isShuttingDown == true { + Task { @MainActor [weak self, weak document] in + guard let self else { return } + await self.awaitModuleRuntimeShutdown() + guard let document, !Task.isCancelled, self.isCurrentWorkspace(identity), + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } + return false + } if descriptor.id == "java" { switch prepareJavaLanguageServerRuntimeIfNeeded(for: document) { case .ready: break @@ -1379,6 +1395,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { Task { [weak self, weak document] in guard let self, let document else { return } do { + await self.awaitModuleRuntimeShutdown() + guard !Task.isCancelled, self.isCurrentWorkspace(identity), + self.openDocuments.contains(where: { $0 === document }) else { return } _ = try await self.services.moduleRuntime.activateCapability(capabilityID) guard self.isCurrentWorkspace(identity), !Task.isCancelled, self.openDocuments.contains(where: { $0 === document }) else { return } @@ -1439,9 +1458,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { Task { [weak self] in guard let self else { return } do { - let value = try await self.services.moduleRuntime.activateCapability(.languageIntelligence) - guard let capability = value as? LitheLanguageIntelligenceModule.LanguageIntelligenceCapability else { return } - self.bindLanguageIntelligenceCapability(capability) + _ = try await self.languageIntelligenceModuleCoordinator.activate() guard self.isCurrentWorkspace(identity), !Task.isCancelled, self.openDocuments.contains(where: { $0 === document }) else { return } _ = self.activateLanguageServerIfAvailable(for: document) diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 6b5633a847..b24f8aad31 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -502,6 +502,12 @@ package protocol LanguageServerRuntimeCore: Sendable { rootURL: URL, initializationOptions: ToolingJSONValue?, workspaceConfiguration: ToolingJSONValue?, + hostServices: [String], + hostIntegrationLaunch: LanguageServerHostIntegrationLaunch?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + javaGradle: JavaGradleImportConfiguration?, initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval @@ -712,13 +718,33 @@ package extension LanguageServerRuntimeCore { rootURL: URL, initializationOptions: ToolingJSONValue?, workspaceConfiguration _: ToolingJSONValue?, + hostServices: [String], + hostIntegrationLaunch: LanguageServerHostIntegrationLaunch?, + cacheDirectoryURL: URL?, + workspaceFingerprint: String?, + mavenContext: MavenLaunchContext?, + javaGradle: JavaGradleImportConfiguration?, initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval ) -> Result { - startPluginLanguageServer( + if let hostIntegrationLaunch, hostServices.isEmpty { + return startLanguageServer( + providerID: providerID, executableURL: executableURL, arguments: arguments, + environment: environment, rootURL: rootURL, workingDirectoryURL: rootURL, + initializationOptions: initializationOptions, + runtimeExecutableURL: hostIntegrationLaunch.runtimeExecutableURL, + jdtlsLaunchResources: hostIntegrationLaunch.coreLaunch?.jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, javaGradle: javaGradle, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } + return startPluginLanguageServer( pluginID: pluginID, providerID: providerID, executableURL: executableURL, arguments: arguments, environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + hostServices: hostServices, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout ) } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 3f65d39748..f7d5e6d5d3 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -201,21 +201,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { ([executableURL.path] + arguments).joined(separator: " "), nil ) - let started = if let pluginID, !hostServices.isEmpty { - core.startPluginLanguageServer( - pluginID: pluginID, - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - initializationOptions: initializationOptions, - hostServices: hostServices, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) - } else if let pluginID { + let started = if let pluginID { core.startPluginLanguageServer( pluginID: pluginID, providerID: providerID, @@ -225,6 +211,12 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { rootURL: normalizedRoot, initializationOptions: initializationOptions, workspaceConfiguration: workspaceConfiguration, + hostServices: hostServices, + hostIntegrationLaunch: hostIntegrationLaunch, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, + javaGradle: javaGradle, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index cdb291db3e..4517a20756 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -65,6 +65,8 @@ package final class LanguageToolingSessionManager: ObservableObject, private let runtimeFactory: (any LanguageProviderRuntimeFactory)? private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] + private static let maximumStartupAttempts = 3 + private var languageServerStartupAttempts: [String: (root: URL, count: Int)] = [:] /// Core can clear Maven settings only for a session launched with Maven /// inputs. Keep this with the session so a pure Gradle start stays nil. private var languageServerMavenContexts: [String: MavenLaunchContext] = [:] @@ -1104,6 +1106,7 @@ package final class LanguageToolingSessionManager: ObservableObject, } package func stopLanguageServer(providerID: String) { + languageServerStartupAttempts[providerID] = nil if providerID == "java" { projectPreparation = nil } if providerID == "java" { javaDependencyChangeTask?.cancel() @@ -1166,6 +1169,7 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerSessionIdentities = [:] languageServerFeatureProviders = [:] languageServerStates = [:] + languageServerStartupAttempts = [:] languageServerOperationIDs = [:] } @@ -1594,6 +1598,15 @@ package final class LanguageToolingSessionManager: ObservableObject, return active } + let previousAttempt = languageServerStartupAttempts[descriptor.id] + let attempts = previousAttempt?.root == rootURL ? previousAttempt?.count ?? 0 : 0 + // Document synchronization must not turn a persistent startup failure + // into an unbounded process loop. Explicit stop/restart clears the budget. + if attempts >= Self.maximumStartupAttempts, + case .failed(let failure) = languageServerStates[descriptor.id] { + throw LanguageServerSessionStartError(failure: failure) + } + if extensionLifecycles[descriptor.id]?.value?.isRetired == true { // Stopped by its module before any server was started for it. unregisterLanguageServerExtension(languageID: descriptor.id) @@ -1617,6 +1630,7 @@ package final class LanguageToolingSessionManager: ObservableObject, if languageServers[descriptor.id] != nil || languageServerRoots[descriptor.id] != nil { stopLanguageServer(providerID: descriptor.id) } + languageServerStartupAttempts[descriptor.id] = (rootURL, attempts + 1) let operationID = requestedOperationID ?? UUID() languageServerOperationIDs[descriptor.id] = operationID recordLanguageServerLog( @@ -2121,6 +2135,7 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerFeatures[providerID] = nil languageServerInfos[providerID] = nil case .ready: + languageServerStartupAttempts[providerID] = nil languageServerFeatures[providerID] = session.features languageServerInfos[providerID] = session.serverInfo recordLanguageServerLog( diff --git a/macos/Tests/LitheTests/AppModelLanguageSessionShutdownTests.swift b/macos/Tests/LitheTests/AppModelLanguageSessionShutdownTests.swift new file mode 100644 index 0000000000..4de7e77322 --- /dev/null +++ b/macos/Tests/LitheTests/AppModelLanguageSessionShutdownTests.swift @@ -0,0 +1,129 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing +@testable import Lithe + +@MainActor +struct AppModelLanguageSessionShutdownTests { + @Test + func closingSessionRevokesLanguageActivationWaitingForWorkspaceCleanup() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("language-close-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("Main.startup") + try "test".write(to: file, atomically: true, encoding: .utf8) + let store = ShutdownTestStore() + let settings = AppSettings(store: store) + let processes = ManagedProcessRegistry() + let container = MacServiceContainer(store: store, settings: settings, processRegistry: processes) + defer { container.moduleLifecycleCoordinator.stop() } + let base = container.services + let runtime = base.moduleRuntime + let started = TestGate(), release = TestGate(), queued = TestGate() + let blocker = ShutdownGateModule(started: started, release: release) + try runtime.register(ModuleFactory(manifest: blocker.manifest) { blocker }) + let model = AppModel(settings: settings, services: services(base)) + var closing: Task? + var activation: Task? + try await WorkbenchTestScope.withCleanup({ + release.open() + activation?.cancel() + if let closing { await closing.value } + _ = try? await activation?.value + await model.shutdownProjectSession() + }) { scope in + defer { release.open(); activation?.cancel() } + try await scope.run("prepare workspace cleanup") { + _ = try await runtime.activate(blocker.manifest.id) + model.openProjectDirectly(root) + try #require(await started.waitUntilOpen()) + await model.documentFeature.openFileAsync(file, isReadOnly: false, displayPath: nil, activateWhenReady: true) + } + let document = try #require(model.openDocuments.first { $0.url == file }) + let identity = try #require(model.currentWorkspaceIdentity) + model.setProjectSessionActive(false) + #expect(model.isCurrentWorkspace(identity), "A background project is still a live session") + #expect(!model.activateLanguageServerIfAvailable(for: document)) + activation = Task { @MainActor in + queued.open() + return try await model.languageIntelligenceModuleCoordinator.activate() + } + try #require(await queued.waitUntilOpen()) + closing = Task { @MainActor in await model.shutdownProjectSession() } + // Observe the actual close entry, not task scheduling. The model is + // intentionally retained with its workspace and document, as in Settings. + let clock = ContinuousClock(), deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while !model.isProjectSessionClosed && clock.now < deadline { await Task.yield() } + try #require(model.isProjectSessionClosed) + #expect(!model.isCurrentWorkspace(identity)) + #expect(model.workspaceURL?.path == root.path) + #expect(model.openDocuments.contains { $0 === document }) + release.open() + try await scope.run("finish closing and pending language activation") { + await closing?.value + let activation = try #require(activation) + await #expect(throws: CancellationError.self) { try await activation.value } + #expect(!model.activateLanguageServerIfAvailable(for: document)) + #expect(runtime.capability(.languageIntelligence) == nil) + #expect(model.languageToolingSessionsIfActive == nil) + #expect(processes.processIDs(for: ManagedProcessCategory.languageServer).isEmpty) + } + } + } + + private func services(_ base: AppServices) -> AppServices { + AppServices(moduleRuntime: base.moduleRuntime, pluginManager: base.pluginManager, + pluginMarketplace: base.pluginMarketplace, pluginCatalog: base.pluginCatalog, + languageProviderCatalogSource: ShutdownTestCatalog(), + workspaceLanguageServerPreferences: base.workspaceLanguageServerPreferences, + javaTestResultServerFactory: { MacJavaTestResultServer() }, + workspaceOperations: base.workspaceOperations, documentLifecycleDecider: base.documentLifecycleDecider, + lineEditing: base.lineEditing, javaMavenOperations: base.javaMavenOperations, + markdownRenderer: base.markdownRenderer, markdownImageImporter: base.markdownImageImporter, + store: base.store, fileStorage: base.fileStorage, fileOperations: base.fileOperations, + binaryFileViewerRegistry: base.binaryFileViewerRegistry, projectRuntimeService: base.projectRuntimeService, + gitWatchContextProvider: base.gitWatchContextProvider, githubService: base.githubService, + secureStore: base.secureStore, diagnosticsExportService: base.diagnosticsExportService, + credentialResolver: base.credentialResolver, aiConfigurationSources: [], + recentProjectsStore: base.recentProjectsStore, workspaceSessionStore: base.workspaceSessionStore, + directoryMarkStore: base.directoryMarkStore, workbenchLayoutStore: base.workbenchLayoutStore, + workbenchBackgroundPlatform: base.workbenchBackgroundPlatform, directoryWatcherFactory: base.directoryWatcherFactory, + platformUI: base.platformUI, shortcutDetectorFactory: base.shortcutDetectorFactory) + } +} + +@MainActor +private final class ShutdownGateModule: LitheModule { + let manifest = ModuleManifest(id: ModuleID("zz.test-language-close"), displayName: "Test close gate", scope: .workspace) + let started: TestGate, release: TestGate + init(started: TestGate, release: TestGate) { self.started = started; self.release = release } + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async { + started.open() + #expect(await release.waitUntilOpen(timeout: .seconds(5)), "Workspace shutdown gate was not released") + } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +private struct ShutdownTestCatalog: LanguageProviderCatalogSource { + func load(workspaceURL: URL?) -> LanguageProviderCatalogSnapshot { + .init(catalog: LanguageProviderCatalog(descriptors: [ + LanguageProviderDescriptor(id: "test-startup", displayName: "Test startup", fileExtensions: ["startup"], + capabilities: [.languageServer], activationPolicy: .onDemand) + ]), schemaVersion: nil, origin: .builtin, issues: []) + } +} + +private final class ShutdownTestStore: KeyValueStore { + var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index d69db0cd98..b22de51681 100644 --- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -198,6 +198,24 @@ struct JavaLanguageServerRuntimeTests { #expect(!state.belongs(to: URL(fileURLWithPath: "/other", isDirectory: true))) } + @Test(arguments: [false, true]) + @MainActor + func stoppedSessionClearsOnlyItsOwnPreparation(stale: Bool) { + let root = URL(fileURLWithPath: "/workspace/java-stop", isDirectory: true) + let feature = JavaFeatureModel(operations: NoProjectJavaOperations()) + let owner = feature.beginLanguageServerPreparation(workspaceURL: root, operationID: UUID()) + JavaLanguageServerPreparationCoordinator().handleSessionState( + .stopped, operationID: stale ? UUID() : owner.operationID, + javaFeature: feature, workspaceURL: root, + onReady: { Issue.record("A stopped session cannot become ready") }, + onFailure: { _ in Issue.record("An intentional stop is not a startup failure") } + ) + #expect(feature.isLanguageServerPreparing == stale) + #expect(feature.languageServerOperationID == (stale ? owner.operationID : nil)) + #expect(feature.languageServerStateBelongs(to: root) == stale) + feature.cancelLanguageServerPreparation() + } + @Test @MainActor func startupRechecksImportInvalidatedDuringRuntimePreparation() async throws { diff --git a/macos/Tests/LitheTests/LanguageIntelligenceModuleCoordinatorTests.swift b/macos/Tests/LitheTests/LanguageIntelligenceModuleCoordinatorTests.swift new file mode 100644 index 0000000000..e831222e37 --- /dev/null +++ b/macos/Tests/LitheTests/LanguageIntelligenceModuleCoordinatorTests.swift @@ -0,0 +1,126 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing +@testable import Lithe + +@MainActor +struct LanguageIntelligenceModuleCoordinatorTests { + @Test(arguments: [false, true], [false, true]) + func languageActivationWaitsForWorkspaceTeardown(maintenance: Bool, cancel: Bool) async throws { + let runtime = ModuleRuntime() + let store = ModuleCapabilityStore() + let started = TestGate(), release = TestGate(), joined = TestGate(), finished = TestGate() + var graphs: [StartupLanguageGraph] = [] + let workspace = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + try runtime.register(ModuleFactory(manifest: workspace) { StartupWorkspaceModule(manifest: workspace) }) + try runtime.register(ModuleFactory(manifest: LanguageIntelligenceModule.moduleManifest, + contributions: LanguageIntelligenceModule.moduleContributions) { + LanguageIntelligenceModule(makeGraph: { + let graph = StartupLanguageGraph() + graphs.append(graph) + return graph + }) + }) + let session = ModuleSessionCoordinator( + runtime: runtime, capabilities: store, workbench: WorkbenchFeatureModel(), + onModuleReleased: { _ in }, onChange: {}, + shutdownRuntime: { + started.open() + #expect(await release.waitUntilOpen(timeout: .seconds(5)), "Workspace cleanup was not released") + await runtime.shutdownAll() + } + ) + var teardownPending = false + let coordinator = LanguageIntelligenceModuleCoordinator( + runtime: runtime, store: store, + awaitShutdown: { + if teardownPending { joined.open() } + await session.awaitShutdown() + }, + bind: { store.cache($0, id: .languageIntelligence, moduleID: .languageIntelligence) }, + notify: { Issue.record("\($0)") } + ) + var request: Task? + try await WorkbenchTestScope.withCleanup({ + release.open() + request?.cancel() + _ = try? await request?.value + await session.awaitShutdown() + await runtime.shutdownAll() + session.stopObserving() + }) { scope in + defer { release.open(); request?.cancel() } + try await scope.run("language activation across workspace teardown") { + let old = try await coordinator.activate().sessions + teardownPending = true + session.beginShutdown() + try #require(await started.waitUntilOpen()) + // Keep old capabilities cached while cleanup is suspended, as on + // startup. Neither the cache nor the maintenance path may bypass it. + request = Task { @MainActor in + defer { finished.open() } + if maintenance { return try await coordinator.sessionsForMaintenance() } + return try await coordinator.activate().sessions + } + #expect(await joined.waitUntilOpen(), "Language activation bypassed workspace cleanup") + #expect(!finished.isOpen) + #expect(graphs.count == 1) + if cancel { request?.cancel() } + release.open() + let request = try #require(request) + if cancel { + await #expect(throws: CancellationError.self) { try await request.value } + #expect(graphs.count == 1) + } else { + let current = try await request.value + #expect(current !== old) + #expect(graphs.count == 2) + #expect(graphs[0].stopped) + #expect(try #require(graphs.last).stopped == false) + #expect(store.capability(.languageIntelligence, as: LanguageIntelligenceCapability.self)?.sessions === current) + } + } + } + } +} + +@MainActor +private final class StartupWorkspaceModule: LitheModule { + let manifest: ModuleManifest + init(manifest: ModuleManifest) { self.manifest = manifest } + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +@MainActor +private final class StartupLanguageGraph: LanguageIntelligenceServiceGraph, LanguageToolRuntimePort { + let sessions = LanguageToolingSessionManager() + lazy var tools = LanguageServerToolService( + runtimeService: self, commandRunner: StartupLanguageTools(), settingsStore: StartupLanguageTools() + ) + var stopped = false + var hasActiveLanguageServers: Bool { false } + func activate(context: ModuleContext) {} + func prepareForSleep() async throws {} + func stop() async { stopped = true; sessions.stopAllLanguageServers() } + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} + +private final class StartupLanguageTools: LanguageToolCommandRunning, LanguageToolSettingsStoring, @unchecked Sendable { + func loadLanguageToolExecutablePaths() -> [String: String] { [:] } + func saveLanguageToolExecutablePaths(_: [String: String]) {} + func runLanguageToolCommand(operationID: String, executableURL: URL, arguments: [String], + environment: [String: String], timeoutMilliseconds: Int) -> LanguageToolCommandResult { + LanguageToolCommandResult(output: "", exitCode: 0) + } +} diff --git a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift index d21f55a4b9..cc6e59bbac 100644 --- a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift +++ b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift @@ -89,13 +89,16 @@ struct ModuleSessionCoordinatorTests { release.open() coordinator.stopObserving() } + #expect(!coordinator.isShuttingDown) coordinator.beginShutdown() coordinator.beginShutdown() + #expect(coordinator.isShuttingDown) #expect(await started.waitUntilOpen()) #expect(shutdowns == 1) release.open() await coordinator.awaitShutdown() + #expect(!coordinator.isShuttingDown) #expect(shutdowns == 1) // A finished shutdown no longer counts as running. diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b15f176a9a..b500259abe 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -2475,6 +2475,55 @@ struct RunConfigurationIntegrationTests { #expect(await Self.waitForMainActorCondition { core.destroyedSessionIDs.count == 1 }) } + @Test + func repeatedStartupFailuresStopUntilExplicitRestart() async throws { + let harness = makeLanguageServerHarness() + defer { harness.manager.stopAll() } + for _ in 0..<3 { + let failed = TestGate() + harness.manager.onLanguageServerStateChange = { _, state, _ in + if case .failed = state { failed.open() } + } + try harness.manager.synchronizeLanguageServer(for: harness.source, text: "struct App {}", rootURL: harness.root) + harness.core.enqueueFailure(code: "serverExited", message: "startup failed") + #expect(await failed.waitUntilOpen()) + } + #expect(harness.core.startCalls.count == 3) + for _ in 0..<5 { + #expect(throws: LanguageServerSessionStartError.self) { + try harness.manager.synchronizeLanguageServer(for: harness.source, text: "struct App {}", rootURL: harness.root) + } + } + #expect(harness.core.startCalls.count == 3) + harness.manager.stopLanguageServer(providerID: "swift") + try harness.manager.synchronizeLanguageServer(for: harness.source, text: "struct App {}", rootURL: harness.root) + #expect(harness.core.startCalls.count == 4) + } + + @Test + func pluginSessionPreservesHostIntegrationLaunchResources() throws { + let core = TestLanguageServerRuntimeCore(providerID: "java") + let resources = JDTLSLaunchResources( + launcherJarURL: URL(fileURLWithPath: "/test-plugin/equinox.jar"), + configurationDirectoryURL: URL(fileURLWithPath: "/test-plugin/configuration/arm64"), + lombokAgentURL: URL(fileURLWithPath: "/test-plugin/lombok.jar") + ) + let java = URL(fileURLWithPath: "/test-plugin/runtime/bin/java") + let cache = URL(fileURLWithPath: "/test-cache/language-servers") + let session = StdioLanguageServerSession( + providerID: "java", executableURL: java, arguments: ["-jar", resources.launcherJarURL.path], + environment: [:], hostIntegrationLaunch: .init(runtimeExecutableURL: java, coreLaunch: .jdt(resources)), + cacheDirectoryURL: cache, core: core, pluginID: "test.java-support" + ) + defer { session.stop() } + try session.start(rootURL: URL(fileURLWithPath: "/test-workspace"), workspaceFingerprint: "workspace-v1") + let launch = try #require(core.startCalls.first) + #expect(launch.runtimeExecutableURL == java) + #expect(launch.jdtlsLaunchResources == resources) + #expect(launch.cacheDirectoryURL == cache) + #expect(launch.workspaceFingerprint == "workspace-v1") + } + @Test func virtualDocumentContentProjectsFromRustEvents() async throws { let core = TestLanguageServerRuntimeCore(providerID: "java") diff --git a/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift b/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift new file mode 100644 index 0000000000..31d6be95e6 --- /dev/null +++ b/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift @@ -0,0 +1,157 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct RustLanguageServerLaunchTests { + @Test(arguments: [false, true]) + func startupMapsIdentityConfigurationServicesAndJdtContextThroughCore(useJdt: Bool) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("plugin-core-launch-\(UUID().uuidString)") + let installed = root.appendingPathComponent("installed") + let configuration = installed.appendingPathComponent("config_arm64") + let cache = root.appendingPathComponent("cache") + try FileManager.default.createDirectory(at: configuration, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let configText = "osgi.bundles.defaultStartLevel=4\n" + try configText.write(to: configuration.appendingPathComponent("config.ini"), atomically: true, encoding: .utf8) + let executable = root.appendingPathComponent("runtime.py") + try Self.server.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let launcher = installed.appendingPathComponent("plugins/equinox.jar") + let lombok = installed.appendingPathComponent("lombok.jar") + let extensionJar = installed.appendingPathComponent("extension.jar") + let core = RustCoreBridge() + let plugin = "dev.lithe.fixture.launch" + let started = try core.startPluginLanguageServer( + pluginID: plugin, providerID: useJdt ? "java" : "test-plugin", + executableURL: useJdt ? root.appendingPathComponent("must-not-be-launched") : executable, + arguments: useJdt ? ["-jar", launcher.path] : [], environment: [:], rootURL: root, + initializationOptions: .object(["fixtureOption": .string("preserved")]), + workspaceConfiguration: .object(["fixture": .object(["value": .string("configured")])]), + hostServices: ["credentials"], + hostIntegrationLaunch: useJdt ? .init(runtimeExecutableURL: executable, coreLaunch: .jdt(.init( + launcherJarURL: launcher, configurationDirectoryURL: configuration, lombokAgentURL: lombok, + javaExtensionBundleURLs: [extensionJar]))) : nil, + cacheDirectoryURL: cache, workspaceFingerprint: "workspace-v1", mavenContext: nil, javaGradle: nil, + initializeTimeout: 5, requestTimeout: 5, shutdownTimeout: 0.1 + ).get() + defer { core.stopLanguageServer(sessionID: started.sessionID); core.destroyLanguageServer(sessionID: started.sessionID) } + var ready = false, hostRequest = false, fingerprintObserved = false + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + // Core exposes a poll boundary; observe real process replies under a + // monotonic deadline rather than guessing when the backend has started. + while ContinuousClock.now < deadline { + for event in core.pollLanguageServerEvents(sessionID: started.sessionID) { + if event.type == "stateChanged", event.state == "ready" { ready = true } + if event.message == "Java language service process started", let detail = event.detail?.data(using: .utf8), + let fields = try? JSONSerialization.jsonObject(with: detail) as? [String: Any] { + fingerprintObserved = fields["workspaceFingerprintPresent"] as? Bool == true + } + if event.type == "hostRequest" { + guard case .object(let value)? = event.result else { + Issue.record("Host request was not an object") + continue + } + #expect(value["service"] == .string("credentials")) + #expect(event.method == "lithe/host/credentials/get") + guard case .string(let id)? = value["hostRequestID"] else { + Issue.record("Host request had no ID") + continue + } + try core.respondToPluginHostRequest(sessionID: started.sessionID, hostRequestID: id, + outcome: .success(.object(["value": .string("fixture-only")]))).get() + hostRequest = true + } + } + if ready && hostRequest && read("configuration", root) != nil && read("credential", root) != nil { break } + await Task.yield() + } + try #require(ready && hostRequest, "Backend did not reach ready and receive the granted service") + let arguments = try #require(read("argv", root) as? [String]) + if useJdt { + #expect(fingerprintObserved) + #expect(arguments.contains("-javaagent:" + lombok.path)) + #expect(arguments.contains(launcher.path)) + let configIndex = try #require(arguments.firstIndex(of: "-configuration")) + let writable = URL(fileURLWithPath: arguments[configIndex + 1]) + #expect(writable.path.hasPrefix(cache.path + "/jdtls-configuration/")) + #expect(try String(contentsOf: writable.appendingPathComponent("config.ini"), encoding: .utf8) == configText) + let dataIndex = try #require(arguments.firstIndex(of: "-data")) + #expect(arguments[dataIndex + 1].hasPrefix(cache.path + "/jdtls/")) + } + let initialize = try #require(read("initialize", root) as? [String: Any]) + let params = try #require(initialize["params"] as? [String: Any]) + let options = try #require(params["initializationOptions"] as? [String: Any]) + #expect(options["fixtureOption"] as? String == "preserved") + if useJdt { #expect((options["bundles"] as? [String])?.contains(extensionJar.path) == true) } + let configured = try #require(read("configuration", root) as? [String: Any]) + if useJdt { + // JDT owns workspace/configuration sections; an unrelated section + // returns null. Generic plugins read the forwarded plugin config. + #expect((configured["result"] as? [Any])?.first is NSNull) + } else { + #expect(((configured["result"] as? [[String: String]])?.first)?["value"] == "configured") + } + let credential = try #require(read("credential", root) as? [String: Any]) + #expect((credential["result"] as? [String: String])?["value"] == "fixture-only") + // A function call with the exact plugin owner must be accepted, while + // another plugin cannot use the same backend's negotiated function. + _ = try core.lspFunctionRequest(sessionID: started.sessionID, pluginID: plugin, + functionID: "snapshot.refresh", params: .object([:]), timeout: 1).get() + if case .success = core.lspFunctionRequest(sessionID: started.sessionID, pluginID: "dev.lithe.other", + functionID: "snapshot.refresh", params: .object([:]), timeout: 1) { + Issue.record("Core accepted another plugin's function request") + } + #expect(try String(contentsOf: configuration.appendingPathComponent("config.ini"), encoding: .utf8) == configText) + #expect(try FileManager.default.contentsOfDirectory(atPath: configuration.path) == ["config.ini"]) + } + + private func read(_ name: String, _ root: URL) -> Any? { + guard let data = try? Data(contentsOf: root.appendingPathComponent(name + ".json")) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + + // macOS developer tools already provide Python; this explicit integration + // fixture speaks framed JSON-RPC and accepts the JVM argv without a JDK. + private static let server = #""" +#!/usr/bin/python3 +import json, pathlib, sys +root = pathlib.Path(__file__).parent + +def record(name, value): + tmp = root / (name + '.tmp') + tmp.write_text(json.dumps(value)) + tmp.replace(root / (name + '.json')) + +def send(value): + body = json.dumps(value).encode() + sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n' % len(body)).encode() + body) + sys.stdout.buffer.flush() + +record('argv', sys.argv[1:]) +while True: + headers = {} + while True: + line = sys.stdin.buffer.readline() + if not line: sys.exit(0) + if line == b'\r\n': break + key, value = line.decode().split(':', 1) + headers[key.lower()] = value.strip() + message = json.loads(sys.stdin.buffer.read(int(headers['content-length']))) + method = message.get('method') + if method == 'initialize': + record('initialize', message) + send({'jsonrpc':'2.0','id':message['id'],'result':{'capabilities':{'experimental':{'litheFunctions':{'version':2,'functions':[{'id':'snapshot.refresh'}]}}}}}) + elif method == 'initialized': + send({'jsonrpc':'2.0','method':'language/status','params':{'type':'ServiceReady'}}) + send({'jsonrpc':'2.0','id':'configuration','method':'workspace/configuration','params':{'items':[{'section':'fixture'}]}}) + send({'jsonrpc':'2.0','id':'credential','method':'lithe/host/credentials/get','params':{'key':'fixture'}}) + elif method == 'exit': + break + elif method and 'id' in message: + send({'jsonrpc':'2.0','id':message['id'],'result':None}) + elif message.get('id') in ('configuration','credential'): + record(message['id'], message) +"""# +} diff --git a/macos/Tests/LitheTests/StableRollbackDiskImageIntegrationTests.swift b/macos/Tests/LitheTests/StableRollbackDiskImageIntegrationTests.swift index 729db75016..813a814109 100644 --- a/macos/Tests/LitheTests/StableRollbackDiskImageIntegrationTests.swift +++ b/macos/Tests/LitheTests/StableRollbackDiskImageIntegrationTests.swift @@ -33,8 +33,10 @@ struct StableRollbackDiskImageIntegrationTests { // signed payload. This avoids a second test-only mount/copy/unmount // cycle; StableRollbackPackage.prepare still exercises the production // hdiutil mount and validates the complete disk-image contract. + // CI reached 98% before the 20s creation deadline; allow margin while + // reserving 15s of the isolated 45s test budget for mount and validation. try await runFixtureTool("/usr/sbin/diskutil", ["image", "create", "from", "--format", "UDRW", - root.appendingPathComponent("payload").path, dmg.path], timeoutMilliseconds: 20_000) + root.appendingPathComponent("payload").path, dmg.path], timeoutMilliseconds: 30_000) let archiveData = try Data(contentsOf: dmg) let checksum = SHA256.hash(data: archiveData).map { String(format: "%02x", $0) }.joined() let publisher = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 1, count: 32)) diff --git a/shared/platform-feature-matrix/features/lsp-lifecycle.json b/shared/platform-feature-matrix/features/lsp-lifecycle.json index efe80b0ba1..9a456644b0 100644 --- a/shared/platform-feature-matrix/features/lsp-lifecycle.json +++ b/shared/platform-feature-matrix/features/lsp-lifecycle.json @@ -11,7 +11,18 @@ "rust/lithe-core/src/lsp/languages/jdt_configuration.rs", "rust/lithe-core/src/lsp/languages/jdt_progress.rs", "shared/fixtures/lsp/jdt-readiness-v1.json", - ".agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md" + ".agents/notes/implemented/bug-fix/2026-10-09-jdtls-heap-ceiling-and-duplicate-maven-coordinates.md", + "macos/Sources/Lithe/Application/Composition/LanguageIntelligenceModuleCoordinator.swift", + "macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift", + "macos/Tests/LitheTests/LanguageIntelligenceModuleCoordinatorTests.swift", + "macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", + "macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift", + "macos/Tests/LitheTests/AppModelLanguageSessionShutdownTests.swift", + "macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift", + "docs/assets/java-startup-navigation/java-ready.png", + "docs/assets/java-startup-navigation/get-order-definition.png" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -31,6 +42,6 @@ "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。", + "verification": "在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 验证 Maven 导入连续 45 秒无进度只警告、之后 ServiceReady 可正常就绪,且 10 分钟总期限、显式失败和取消仍有效。 检查 JDT LS 启动参数的最大堆高于 1.5 GB,并在待导入模块超过 50 个、且子模块 Maven 坐标重复的聚合工程中确认工作区准备不再以 serviceReadyFailed 终止整个 Java 服务。 macOS #1524:暂停旧模块图清理后请求普通语言能力、后台 Java 维护及文档激活,确认等待结束后才读取缓存或启动新会话;等待期间取消不得重新激活;匹配 operation 的停止应清除 preparing,旧 operation 的停止不得影响新准备。 macOS #1523:通过插件入口启动 Java,确认宿主集成、Maven/Gradle 与缓存参数不丢失,连续失败最多自动启动三次,显式重启可恢复,且包内文件哈希保持不变。 macOS 最终关闭场景:暂停旧模块清理、排队文档和维护激活、关闭项目会话、释放清理;即使 AppModel 保留工作区 URL 与打开文档,也不能重新发布语言能力或启动 LSP。 macOS 生产 RustCoreBridge 与本地协议进程验证插件身份、host services、配置值和 JDT 缓存启动上下文;修复后的 Java ready 与 getOrder 定义导航截图见 evidence。", "notes": "JDT LS 统一以 -Xmx2048m 启动(#1156):打包的 1.61.0 在待导入模块超过 50 个且最大堆不超过 1.5 GB 时会走带缺陷的分批导入,重复 Maven 坐标会让 null 工程进入 IProject.open 并终止整个 Java 服务;上游修复进入下个里程碑前用这条堆上限规避。代价是语言服务 JVM 的堆上限从 1 GB 提到 2 GB,-Xms 仍为 256m。坐标完全相同的模块仍只有第一个成为 Java 工程,其余由 m2e 跳过。" }