From 80341ce19ebdcc19e352d405488c4cef19c95337 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 16:13:20 +0800 Subject: [PATCH 01/11] fix(macos): load run configurations without waiting for the Spring index Opening a large multi-module Maven workspace left Run and Debug unusable for as long as the Spring index took, because loadProjectServices awaited it before loading build-system and run state. Schedule the index instead so run configurations, test discovery, and the Git refresh that follows no longer depend on its duration. Identifying the project during that window was dropped silently, which is indistinguishable from a broken button. Report the unloaded workspace as an explicit state, and let the Run and Debug entry points load the project on demand the way the tool-window entry points already do. Refs #300 --- .../zh-Hans.lproj/Localizable.strings | 2 + .../Features/SpringFeatureModel.swift | 20 ++++ .../AppModel/AppModel+Development.swift | 11 +++ .../Lithe/Models/AppModel/AppModel.swift | 6 +- macos/Sources/Lithe/Views/Run/RunView.swift | 6 ++ .../Execution/RunConfigurationContracts.swift | 3 + .../Application/ExecutionFeatureModels.swift | 2 + .../Services/RunService.swift | 12 ++- .../ExecutionModuleTests.swift | 89 ++++++++++++++++++ .../LitheTests/SpringFeatureModelTests.swift | 92 ++++++++++++++++++- 10 files changed, 239 insertions(+), 4 deletions(-) diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index db44d392d..6df051995 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -929,6 +929,8 @@ "Run configurations may be out of date" = "运行配置可能已过期"; "Project toolchain needs attention" = "项目工具链需要处理"; "Different JDK vendor selected" = "选择了不同的 JDK 发行版"; +"Project is still loading" = "项目仍在加载中"; +"Wait for the project to finish loading, then identify it again." = "请等待项目加载完成,然后重新识别。"; "Project identification complete" = "项目识别完成"; "Generated 1 runnable project entry." = "已生成 1 个可运行的项目入口。"; "Generated %lld runnable project entries." = "已生成 %lld 个可运行的项目入口。"; diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift index 2bd9a0d6c..37e64af56 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -51,6 +51,26 @@ final class SpringFeatureModel: ObservableObject { isIndexing = false } + /// Starts a workspace index without making the caller wait for it. Opening a + /// project must not block build-system and run state behind Spring indexing, + /// which scales with the number of Java sources in the workspace. + func scheduleLoad( + workspaceURL: URL, + files: [URL], + textOverrides: [URL: String] = [:], + refreshDependencyMetadata: Bool = true + ) { + reloadTask?.cancel() + reloadTask = Task { @MainActor [weak self] in + await self?.load( + workspaceURL: workspaceURL, + files: files, + textOverrides: textOverrides, + refreshDependencyMetadata: refreshDependencyMetadata + ) + } + } + func reset() { reloadTask?.cancel() reloadTask = nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index d04d89c46..0d8b32c96 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -153,8 +153,18 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } + /// Run and Debug can activate the execution module before the workspace + /// snapshot has loaded it, which leaves the run feature without a project. + /// The tool-window entry points already load on demand; these do the same so + /// every entry point observes the same state. + private func loadProjectServicesIfRunProjectIsUnbound(_ runFeature: RunFeatureModel) async { + guard !runFeature.isProjectLoaded, let workspaceURL else { return } + await loadProjectServices(at: workspaceURL, files: projectFiles) + } + private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } + await loadProjectServicesIfRunProjectIsUnbound(runFeature) guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -325,6 +335,7 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature + await loadProjectServicesIfRunProjectIsUnbound(runFeature) let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4581e8996..0d876604d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -747,12 +747,16 @@ final class AppModel: ObservableObject, Identifiable { /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. + /// + /// Spring indexing is scheduled rather than awaited. It scales with the + /// number of Java sources, and run configurations, test discovery, and the + /// Git refresh that follows this call must not wait for it. func loadProjectServices(at workspaceURL: URL, files: [URL]) async { prepareJavaLanguageServerForWorkspaceIfNeeded( at: workspaceURL, files: files ) - await springFeature.load( + springFeature.scheduleLoad( workspaceURL: workspaceURL, files: files, textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index e5aedac77..393b12b5a 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -180,6 +180,12 @@ struct RunView: View { ) } switch feature.generationState { + case .projectNotLoaded: + return ( + String(localized: "Project is still loading"), + String(localized: "Wait for the project to finish loading, then identify it again."), + "clock.fill" + ) case .succeeded(let entryCount): return ( String(localized: "Project identification complete"), diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 06a6e030f..11c6cb6ad 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -49,6 +49,9 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { package enum RunConfigurationGenerationState: Equatable, Sendable { case idle + /// The request arrived before the workspace finished loading, so there was + /// no project to identify. Nothing failed and nothing was written. + case projectNotLoaded case succeeded(entryCount: Int) case noEntries case failed(String) diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index a37f68216..62131d72b 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -159,6 +159,8 @@ package final class RunFeatureModel: ObservableObject { service.clearOutput() } + package var isProjectLoaded: Bool { service.isProjectLoaded } + package func loadProject( at workspaceURL: URL, files: [URL], diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 050c8c890..d41cb4246 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -105,6 +105,11 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } + /// Reports whether `loadProject` has bound this service to a workspace. + /// Entry points that activate the execution module on demand use this to + /// avoid acting on a service that has no project yet. + package var isProjectLoaded: Bool { projectURL != nil } + @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, @@ -199,7 +204,12 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - guard let projectURL else { return } + // Dropping the request silently is indistinguishable from a broken + // button, so report that the workspace is not loaded yet instead. + guard let projectURL else { + generationState = .projectNotLoaded + return + } let loadID = projectLoadID isLoadingProject = true defer { diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index fd089fc86..062b3519f 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -47,6 +47,63 @@ struct ExecutionModuleTests { #expect(recorder.graphCalls == 2) } + /// Run and Debug can reach identification before the workspace snapshot has + /// bound a project. Reporting nothing at all made the confirmed dialog look + /// like a dead button, so the unloaded project must become visible state. + @Test + func identificationBeforeProjectLoadReportsUnloadedProjectWithoutGenerating() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + + #expect(!service.isProjectLoaded) + await service.generateRunConfigurations() + + #expect(service.generationState == .projectNotLoaded) + #expect(operations.generateCallCount == 0) + #expect(service.configurationStatus == .missing) + } + + /// Once the project is bound, identification must behave exactly as before. + @Test + func identificationAfterProjectLoadGeneratesAndClearsTheUnloadedState() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotLoaded) + + await service.loadProject(at: root, files: [], mavenProject: nil) + #expect(service.isProjectLoaded) + await service.generateRunConfigurations() + + #expect(operations.generateCallCount == 1) + #expect(service.generationState == .succeeded(entryCount: 1)) + #expect(service.configurationStatus == .ready) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -326,6 +383,38 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Counts generation attempts so a test can prove that an unloaded project +/// never reaches the store. +private final class RecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private(set) var generateCallCount = 0 + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection( + status: generateCallCount == 0 ? .missing : .ready, + diagnostics: [] + ) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + generateCallCount += 1 + return RunConfigurationGenerationResult(entryCount: 1) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: .currentFile, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: RunConfiguration.currentFileID + ) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Unavailable in identification test") + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index 48d557610..676e5f035 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -99,17 +99,105 @@ struct SpringFeatureModelTests { let locations = feature.navigationLocations(for: injectionURL, line: 7) #expect(locations.map(\.url) == [firstURL, secondURL]) } + + /// Opening a workspace must not wait for Spring indexing, which scales with + /// the number of Java sources. `scheduleLoad` returns immediately and the + /// index arrives later. + @Test + func scheduleLoadReturnsBeforeTheIndexIsPublished() async throws { + let root = URL(fileURLWithPath: "/workspace") + let beanURL = root.appendingPathComponent("Service.java") + let gate = SpringIndexGate() + let result = SpringIndexResult( + properties: [], values: [], propertyReferences: [], diagnostics: [], + beans: [SpringBean( + id: "service", name: "service", typeName: "Service", + url: beanURL, line: 3, column: 7, kind: "component" + )], + injections: [], endpoints: [] + ) + let feature = SpringFeatureModel( + operations: SpringTestOperations(result: result, gate: gate) + ) + + feature.scheduleLoad(workspaceURL: root, files: [beanURL]) + + #expect(feature.beans.isEmpty) + gate.open() + try await pollUntil { !feature.beans.isEmpty } + #expect(feature.beans.map(\.id) == ["service"]) + #expect(!feature.isIndexing) + } + + /// A newer schedule supersedes the previous one so a burst of reloads cannot + /// publish a stale index. + @Test + func scheduleLoadCancelsThePreviousSchedule() async throws { + let root = URL(fileURLWithPath: "/workspace") + let gate = SpringIndexGate() + let operations = SpringTestOperations(result: .empty, gate: gate) + let feature = SpringFeatureModel(operations: operations) + + feature.scheduleLoad(workspaceURL: root, files: []) + feature.scheduleLoad(workspaceURL: root, files: []) + gate.open() + try await pollUntil { !feature.isIndexing } + + #expect(operations.indexCallCount <= 2) + } } -private struct SpringTestOperations: JavaMavenOperations { +/// Polls the MainActor state instead of sleeping for a fixed interval so the +/// test does not depend on scheduler timing. +@MainActor +private func pollUntil( + attempts: Int = 200, + _ condition: () -> Bool +) async throws { + for _ in 0.. SpringIndexResult? { result } + ) -> SpringIndexResult? { + lock.lock() + indexCalls += 1 + lock.unlock() + gate?.wait() + return result + } func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } From 7749d988c6f1953c798482b76f93d94486336e57 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 17:10:25 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(macos):=20=E8=AE=A9=E8=A2=AB=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E7=9A=84=20Spring=20=E7=B4=A2=E5=BC=95=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E7=9C=9F=E6=AD=A3=E6=8F=90=E5=89=8D=E9=80=80=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=B9=E7=94=A8=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的测试稳定性门禁拒绝了原先的两个 scheduleLoad 测试:轮询里用了真实 Task.sleep,测试替身里用了无界的 DispatchSemaphore.wait()。改成由 actor 模型 保证的同步断言,加上以 objectWillChange 为事件源、带本地超时的等待。 重写后的测试暴露出一个真实缺陷:Swift 的取消是协作式的,reloadTask.cancel() 并不会阻止任务体运行,而 load 里没有取消检查点,所以被取代的那次调度仍然会跑 完一次全量工作区索引,只是结果被 generation 令牌丢弃。为 scheduleLoad 补上 Task.isCancelled 检查,与既有的 scheduleReload 保持一致。 Refs #300 --- .../Features/SpringFeatureModel.swift | 6 +- .../LitheTests/SpringFeatureModelTests.swift | 170 ++++++++++++------ 2 files changed, 120 insertions(+), 56 deletions(-) diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift index 37e64af56..98d976945 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -62,7 +62,11 @@ final class SpringFeatureModel: ObservableObject { ) { reloadTask?.cancel() reloadTask = Task { @MainActor [weak self] in - await self?.load( + // Cancellation is cooperative, so a schedule that was superseded + // before it started must return here instead of running a second + // full workspace index whose result the generation token discards. + guard !Task.isCancelled, let self else { return } + await self.load( workspaceURL: workspaceURL, files: files, textOverrides: textOverrides, diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index 676e5f035..bdb664c89 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import Testing @testable import Lithe @@ -101,89 +102,149 @@ struct SpringFeatureModelTests { } /// Opening a workspace must not wait for Spring indexing, which scales with - /// the number of Java sources. `scheduleLoad` returns immediately and the - /// index arrives later. + /// the number of Java sources. @Test - func scheduleLoadReturnsBeforeTheIndexIsPublished() async throws { + func scheduleLoadDefersIndexingAndPublishesTheResult() async throws { let root = URL(fileURLWithPath: "/workspace") let beanURL = root.appendingPathComponent("Service.java") - let gate = SpringIndexGate() - let result = SpringIndexResult( - properties: [], values: [], propertyReferences: [], diagnostics: [], - beans: [SpringBean( - id: "service", name: "service", typeName: "Service", - url: beanURL, line: 3, column: 7, kind: "component" - )], - injections: [], endpoints: [] - ) - let feature = SpringFeatureModel( - operations: SpringTestOperations(result: result, gate: gate) - ) + let operations = SpringTestOperations(result: componentIndex(at: beanURL)) + let feature = SpringFeatureModel(operations: operations) + defer { feature.reset() } feature.scheduleLoad(workspaceURL: root, files: [beanURL]) + // The schedule owns a MainActor task, which cannot run before this test + // suspends. Reaching these assertions proves the caller was not blocked. #expect(feature.beans.isEmpty) - gate.open() - try await pollUntil { !feature.beans.isEmpty } - #expect(feature.beans.map(\.id) == ["service"]) - #expect(!feature.isIndexing) + #expect(operations.requestedFiles.isEmpty) + + let published = await awaitChange(on: feature) { + !feature.isIndexing && !feature.beans.isEmpty + } + #expect(published, "the scheduled index never published a result") + #expect(feature.beans.map(\.id) == ["Service.java"]) + #expect(operations.requestedFiles == [[beanURL]]) } - /// A newer schedule supersedes the previous one so a burst of reloads cannot + /// A newer schedule supersedes the pending one so a burst of reloads cannot /// publish a stale index. @Test - func scheduleLoadCancelsThePreviousSchedule() async throws { + func scheduleLoadReplacesAPendingSchedule() async throws { let root = URL(fileURLWithPath: "/workspace") - let gate = SpringIndexGate() - let operations = SpringTestOperations(result: .empty, gate: gate) + let staleURL = root.appendingPathComponent("Stale.java") + let freshURL = root.appendingPathComponent("Fresh.java") + let operations = SpringTestOperations { files in + files.first.map(componentIndex(at:)) ?? .empty + } let feature = SpringFeatureModel(operations: operations) + defer { feature.reset() } - feature.scheduleLoad(workspaceURL: root, files: []) - feature.scheduleLoad(workspaceURL: root, files: []) - gate.open() - try await pollUntil { !feature.isIndexing } + feature.scheduleLoad(workspaceURL: root, files: [staleURL]) + feature.scheduleLoad(workspaceURL: root, files: [freshURL]) - #expect(operations.indexCallCount <= 2) + let published = await awaitChange(on: feature) { + !feature.isIndexing && !feature.beans.isEmpty + } + #expect(published, "the replacement schedule never published a result") + // A schedule superseded before it started must not run a second full + // workspace index, which the generation token would only discard. + #expect(operations.requestedFiles == [[freshURL]]) + #expect(feature.beans.map(\.id) == ["Fresh.java"]) } } -/// Polls the MainActor state instead of sleeping for a fixed interval so the -/// test does not depend on scheduler timing. +private func componentIndex(at url: URL) -> SpringIndexResult { + SpringIndexResult( + properties: [], + values: [], + propertyReferences: [], + diagnostics: [], + beans: [SpringBean( + id: url.lastPathComponent, + name: "service", + typeName: "Service", + url: url, + line: 3, + column: 7, + kind: "component" + )], + injections: [], + endpoints: [] + ) +} + +/// Awaits an observable publication with a local deadline. The feature publishes +/// from a task it owns, so the test cannot observe completion synchronously, and +/// a poll loop would depend on machine speed. @MainActor -private func pollUntil( - attempts: Int = 200, - _ condition: () -> Bool -) async throws { - for _ in 0.. Bool +) async -> Bool { + if isSatisfied() { return true } + return await withCheckedContinuation { continuation in + let resumption = SingleResumption(continuation) + // objectWillChange fires before each assignment, so the predicate runs on + // the following main-actor turn, once the publication has completed. + resumption.observe(feature.objectWillChange.sink { _ in + Task { @MainActor in + guard isSatisfied() else { return } + resumption.finish(with: true) + } + }) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + resumption.finish(with: false) + } } - Issue.record("The awaited condition never became true") } -/// Blocks the detached index call until the test decides the schedule has had a -/// chance to return. -private final class SpringIndexGate: @unchecked Sendable { - private let semaphore = DispatchSemaphore(value: 0) - func open() { semaphore.signal() } - func wait() { semaphore.wait(); semaphore.signal() } +/// The observation and the deadline race for a continuation that may only be +/// resumed once. Both arms run on the main thread. +private final class SingleResumption: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var observation: AnyCancellable? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func observe(_ observation: AnyCancellable) { + guard continuation != nil else { + observation.cancel() + return + } + self.observation = observation + } + + func finish(with value: Bool) { + guard let pending = continuation else { return } + continuation = nil + observation?.cancel() + observation = nil + pending.resume(returning: value) + } } private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable { - let result: SpringIndexResult - private let gate: SpringIndexGate? + private let makeResult: @Sendable ([URL]) -> SpringIndexResult private let lock = NSLock() - private var indexCalls = 0 + private var requested: [[URL]] = [] - var indexCallCount: Int { + /// Every set of files handed to the index, in call order. An empty value + /// proves the double was never reached. + var requestedFiles: [[URL]] { lock.lock() defer { lock.unlock() } - return indexCalls + return requested + } + + init(result: SpringIndexResult) { + makeResult = { _ in result } } - init(result: SpringIndexResult, gate: SpringIndexGate? = nil) { - self.result = result - self.gate = gate + init(resultForFiles: @escaping @Sendable ([URL]) -> SpringIndexResult) { + makeResult = resultForFiles } func springIndex( @@ -193,10 +254,9 @@ private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendab refreshDependencyMetadata: Bool ) -> SpringIndexResult? { lock.lock() - indexCalls += 1 + requested.append(files) lock.unlock() - gate?.wait() - return result + return makeResult(files) } func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } From e4b49c12d91b7f1f38e5d304549714cd6559f319 Mon Sep 17 00:00:00 2001 From: fenghp Date: Fri, 28 Aug 2026 18:12:16 +0800 Subject: [PATCH 03/11] =?UTF-8?q?test(macos):=20=E8=A6=86=E7=9B=96=20Run?= =?UTF-8?q?=20=E4=B8=8E=20Debug=20=E5=85=A5=E5=8F=A3=E5=9C=A8=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E7=BB=91=E5=AE=9A=E9=A1=B9=E7=9B=AE=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E5=8A=A0=E8=BD=BD=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前只靠编译期类型检查和手动验证,没有测试覆盖这两个入口。 新增的三个测试用一个不可读的工作区让快照返回 unavailable,从而跳过 onSnapshotLoaded,把入口变成唯一能绑定项目的路径。把 AppModel+Development 里 两处 loadProjectServicesIfRunProjectIsUnbound 调用去掉后,Run 与 Debug 两个 测试会失败,而覆盖既有行为的工具窗口测试仍然通过,说明它们确实能抓到回归。 把 objectWillChange 加本地超时的等待器提成 ObservableChangeWaiter,供 SpringFeatureModelTests 与新测试共用,超时收到 5 秒以留出计时预算。 Refs #300 --- .../LitheTests/ObservableChangeWaiter.swift | 60 ++++++++++++++ .../Tests/LitheTests/RunEntryPointTests.swift | 81 +++++++++++++++++++ .../LitheTests/SpringFeatureModelTests.swift | 54 ------------- 3 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 macos/Tests/LitheTests/ObservableChangeWaiter.swift create mode 100644 macos/Tests/LitheTests/RunEntryPointTests.swift diff --git a/macos/Tests/LitheTests/ObservableChangeWaiter.swift b/macos/Tests/LitheTests/ObservableChangeWaiter.swift new file mode 100644 index 000000000..62eda5c91 --- /dev/null +++ b/macos/Tests/LitheTests/ObservableChangeWaiter.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation + +/// Awaits an observable publication with a local deadline. Feature models +/// publish from tasks they own, so a test cannot observe completion +/// synchronously, and a poll loop would depend on machine speed. +/// +/// Returns `false` when the deadline elapses first so the caller can turn the +/// timeout into an assertion instead of hanging until the CI job is killed. The +/// deadline is only paid by a failing test, so it stays well inside the timing +/// harness budget. +@MainActor +func awaitChange( + on model: Model, + timeout: DispatchTimeInterval = .seconds(5), + until isSatisfied: @escaping @MainActor @Sendable () -> Bool +) async -> Bool where Model.ObjectWillChangePublisher == ObservableObjectPublisher { + if isSatisfied() { return true } + return await withCheckedContinuation { continuation in + let resumption = SingleResumption(continuation) + // objectWillChange fires before each assignment, so the predicate runs + // on the following main-actor turn, once the publication has completed. + resumption.observe(model.objectWillChange.sink { _ in + Task { @MainActor in + guard isSatisfied() else { return } + resumption.finish(with: true) + } + }) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + resumption.finish(with: false) + } + } +} + +/// The observation and the deadline race for a continuation that may only be +/// resumed once. Both arms run on the main thread. +private final class SingleResumption: @unchecked Sendable { + private var continuation: CheckedContinuation? + private var observation: AnyCancellable? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func observe(_ observation: AnyCancellable) { + guard continuation != nil else { + observation.cancel() + return + } + self.observation = observation + } + + func finish(with value: Bool) { + guard let pending = continuation else { return } + continuation = nil + observation?.cancel() + observation = nil + pending.resume(returning: value) + } +} diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift new file mode 100644 index 000000000..e6d654d1b --- /dev/null +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Run entry points") +@MainActor +struct RunEntryPointTests { + /// Run and Debug activate the execution module on demand, so they can reach + /// a run feature that no workspace snapshot has bound to a project yet. The + /// entry point has to load the project itself; otherwise identification hits + /// an unbound service and the confirmed dialog appears to do nothing. + /// + /// An unreadable workspace makes the snapshot unavailable, which keeps + /// `onSnapshotLoaded` from running and leaves the entry point as the only + /// path that can bind the project. + @Test + func runningBeforeTheSnapshotLoadsBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + #expect(model.runFeatureIfActive == nil) + + model.runSelectedConfiguration() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "the Run entry point did not bind the workspace to the run feature") + } + + /// Debugging reaches the same run feature through its own entry point. + @Test + func debuggingBeforeTheSnapshotLoadsBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + #expect(model.runFeatureIfActive == nil) + + model.startDebugging() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "the Debug entry point did not bind the workspace to the run feature") + } + + /// The tool-window entry point already loaded the project on demand. It must + /// keep doing so, because the other two now rely on the same contract. + @Test + func openingTheRunToolWindowBindsTheProject() async { + let model = makeAppModel() + model.openProjectDirectly(unreadableWorkspaceURL()) + + model.toggleRun() + + let bound = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectLoaded == true + } + #expect(bound, "opening the Run tool window did not bind the workspace") + } + + private func makeAppModel() -> AppModel { + let store = RunEntryPointTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer(store: store, settings: settings).services + return AppModel(settings: settings, services: services) + } + + private func unreadableWorkspaceURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-unreadable-workspace-\(UUID().uuidString)") + } +} + +private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { + private 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/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index bdb664c89..ca8f5a828 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Testing @testable import Lithe @@ -173,59 +172,6 @@ private func componentIndex(at url: URL) -> SpringIndexResult { ) } -/// Awaits an observable publication with a local deadline. The feature publishes -/// from a task it owns, so the test cannot observe completion synchronously, and -/// a poll loop would depend on machine speed. -@MainActor -private func awaitChange( - on feature: SpringFeatureModel, - timeout: DispatchTimeInterval = .seconds(10), - until isSatisfied: @escaping @MainActor @Sendable () -> Bool -) async -> Bool { - if isSatisfied() { return true } - return await withCheckedContinuation { continuation in - let resumption = SingleResumption(continuation) - // objectWillChange fires before each assignment, so the predicate runs on - // the following main-actor turn, once the publication has completed. - resumption.observe(feature.objectWillChange.sink { _ in - Task { @MainActor in - guard isSatisfied() else { return } - resumption.finish(with: true) - } - }) - DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { - resumption.finish(with: false) - } - } -} - -/// The observation and the deadline race for a continuation that may only be -/// resumed once. Both arms run on the main thread. -private final class SingleResumption: @unchecked Sendable { - private var continuation: CheckedContinuation? - private var observation: AnyCancellable? - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func observe(_ observation: AnyCancellable) { - guard continuation != nil else { - observation.cancel() - return - } - self.observation = observation - } - - func finish(with value: Bool) { - guard let pending = continuation else { return } - continuation = nil - observation?.cancel() - observation = nil - pending.resume(returning: value) - } -} - private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable { private let makeResult: @Sendable ([URL]) -> SpringIndexResult private let lock = NSLock() From a53ee9412615245f3528ee891813e1a86bcc25b5 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 09:54:59 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix(macos):=20=E7=94=9F=E6=88=90=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=85=8D=E7=BD=AE=E5=89=8D=E8=A6=81=E6=B1=82=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=BF=AB=E7=85=A7=E5=B7=B2=E5=B0=B1=E7=BB=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出冷启动入口把"URL 已绑定"当成了"工作区快照已就绪"。快照未完成时 projectFiles 为空,入口仍会绑定 projectURL 并允许确认"识别并生成",而 runConfig.generate 只扫描传入的 paths,于是可能写出缺少 Java 入口的 .lithe/run/generated.json。 把两件事显式分开。新增 ProjectLoadState(idle / loading / bound / ready / failed):bound 表示已绑定工作区但清单是临时的,只允许读取既有配置;ready 携带 工作区与快照标识,才允许生成。快照标识由 WorkspaceFeatureModel 拥有——它是唯一 应用快照的地方——经 AppModel 与 ProjectDevelopmentFeatureModel 传到 RunService。 snapshotID 缺省为 nil,因此忘记传递的调用方会退到 bound 而不是误判为就绪。 generateRunConfigurations 现在要求当前工作区处于 ready,否则报 projectNotReady(原 projectNotLoaded,改名以覆盖"已绑定但清单不完整")。 MacServiceContainer 增加 workspaceOperations 注入缝,与既有的 moduleStore 等 可选覆盖一致,使 AppModel 层能用可控快照做测试。Search 与本地历史仍使用具体的 RustWorkspaceOperations,它们依赖 WorkspaceOperations 之外的能力。 测试改为断言数据完整性而非仅仅"已绑定":入口测试用受控快照,先阻塞、按下 Run、 断言未就绪且生成被拒且未写出配置,再放行含真实 Java 文件的快照并断言就绪; ExecutionModuleTests 断言生成实际扫描的清单恰好是快照上报的那份。去掉 RunService 的就绪守卫后,后者会直接暴露 generatedInventories == [[]]。 Refs #300 --- .../AppModel/AppModel+Development.swift | 16 +- .../Lithe/Models/AppModel/AppModel.swift | 9 +- .../Platform/MacOS/MacServiceContainer.swift | 12 +- macos/Sources/Lithe/Views/Run/RunView.swift | 2 +- .../Execution/RunConfigurationContracts.swift | 43 ++++- .../Application/ExecutionFeatureModels.swift | 25 ++- .../Services/RunService.swift | 38 +++- .../Application/WorkspaceFeatureModel.swift | 6 + .../ExecutionModuleTests.swift | 72 ++++++- .../RunConfigurationIntegrationTests.swift | 9 +- .../Tests/LitheTests/RunEntryPointTests.swift | 176 ++++++++++++++---- 11 files changed, 334 insertions(+), 74 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 0d8b32c96..a3ef15da6 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -154,17 +154,21 @@ extension AppModel { } /// Run and Debug can activate the execution module before the workspace - /// snapshot has loaded it, which leaves the run feature without a project. - /// The tool-window entry points already load on demand; these do the same so + /// snapshot reaches the run feature, which leaves it without a project. The + /// tool-window entry points already load on demand; these do the same so /// every entry point observes the same state. - private func loadProjectServicesIfRunProjectIsUnbound(_ runFeature: RunFeatureModel) async { - guard !runFeature.isProjectLoaded, let workspaceURL else { return } + /// + /// This does not make an unfinished snapshot ready. When the snapshot is + /// still pending, the load binds the workspace so existing configuration is + /// readable and generation keeps reporting `projectNotReady`. + private func loadProjectServicesIfRunProjectIsNotReady(_ runFeature: RunFeatureModel) async { + guard let workspaceURL, !runFeature.isProjectReady(for: workspaceURL) else { return } await loadProjectServices(at: workspaceURL, files: projectFiles) } private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } - await loadProjectServicesIfRunProjectIsUnbound(runFeature) + await loadProjectServicesIfRunProjectIsNotReady(runFeature) guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -335,7 +339,7 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature - await loadProjectServicesIfRunProjectIsUnbound(runFeature) + await loadProjectServicesIfRunProjectIsNotReady(runFeature) let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0d876604d..0126e247b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -765,7 +765,14 @@ final class AppModel: ObservableObject, Identifiable { ) guard let execution = await activateExecutionModule() else { return } execution.tests.discover(workspaceURL: workspaceURL, files: files) - await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) + // The workspace feature owns snapshot identity. Passing it through lets + // the run service tell a complete file inventory from a provisional one, + // so generation never scans a partial workspace. + await execution.projectDevelopment.loadProject( + at: workspaceURL, + files: files, + snapshotID: workspaceFeature.snapshotID + ) } var projectName: String { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 90c6ba81c..6c4aaf532 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -52,6 +52,7 @@ final class MacServiceContainer { processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(), moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, + workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -405,7 +406,12 @@ final class MacServiceContainer { preconditionFailure("Invalid execution/debug module graph: \(error.localizedDescription)") } let gitOperations = RustGitOperations(core: rustCore) - let workspaceOperations = RustWorkspaceOperations(core: rustCore) + let rustWorkspaceOperations = RustWorkspaceOperations(core: rustCore) + // Only the workspace snapshot boundary is overridable. Search and local + // history need the concrete Rust operations, which carry their own + // capabilities beyond WorkspaceOperations. + let workspaceOperations: any WorkspaceOperations = + providedWorkspaceOperations ?? rustWorkspaceOperations let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) let markdownRenderer = RustMarkdownRendering(core: rustCore) let markdownImageImporter = MarkdownImageImportService(storage: fileStorage) @@ -417,11 +423,11 @@ final class MacServiceContainer { ) }) try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { - SearchModule(operations: workspaceOperations) + SearchModule(operations: rustWorkspaceOperations) }) try moduleRegistry.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) { HistoryModule( - workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: workspaceOperations, fileOperations: fileOperations), + workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: rustWorkspaceOperations, fileOperations: fileOperations), storage: MacLocalHistoryStorage(storage: fileStorage), operations: localHistoryOperations ) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 393b12b5a..ec307fe23 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -180,7 +180,7 @@ struct RunView: View { ) } switch feature.generationState { - case .projectNotLoaded: + case .projectNotReady: return ( String(localized: "Project is still loading"), String(localized: "Wait for the project to finish loading, then identify it again."), diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 11c6cb6ad..1c2bc233f 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,11 +47,48 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } +/// How a run service is bound to a workspace. +/// +/// Being bound to a workspace URL and holding a complete file inventory are +/// different things. Reading an existing configuration only needs the URL, while +/// generating one scans the inventory, so a provisional inventory would write a +/// configuration that omits entry points the workspace actually contains. +package enum ProjectLoadState: Equatable, Sendable { + case idle + case loading(workspace: URL) + /// Bound to the workspace, but the file inventory is provisional because the + /// workspace snapshot has not been applied yet. Existing configuration can be + /// read; generation must wait. + case bound(workspace: URL) + /// The inventory came from the identified workspace snapshot, so generation + /// can scan it safely. + case ready(workspace: URL, snapshotID: UUID) + case failed(workspace: URL, message: String) + + /// The workspace this state describes, when it describes one. + package var workspace: URL? { + switch self { + case .idle: nil + case .loading(let workspace): workspace + case .bound(let workspace): workspace + case .ready(let workspace, _): workspace + case .failed(let workspace, _): workspace + } + } + + /// Whether the inventory for `workspace` is complete enough to generate from. + package func isReady(for workspace: URL) -> Bool { + guard case .ready(let boundWorkspace, _) = self else { return false } + return boundWorkspace == workspace.standardizedFileURL + } +} + package enum RunConfigurationGenerationState: Equatable, Sendable { case idle - /// The request arrived before the workspace finished loading, so there was - /// no project to identify. Nothing failed and nothing was written. - case projectNotLoaded + /// The request arrived before the workspace snapshot was applied, so there + /// was no complete file inventory to identify. Nothing failed, and nothing + /// was written. + case projectNotReady case succeeded(entryCount: Int) case noEntries case failed(String) diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 62131d72b..68ec554c4 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -159,14 +159,24 @@ package final class RunFeatureModel: ObservableObject { service.clearOutput() } - package var isProjectLoaded: Bool { service.isProjectLoaded } + package var projectLoadState: ProjectLoadState { service.projectLoadState } + + package func isProjectReady(for workspace: URL) -> Bool { + service.isProjectReady(for: workspace) + } package func loadProject( at workspaceURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { - await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject) + await service.loadProject( + at: workspaceURL, + files: files, + mavenProject: mavenProject, + snapshotID: snapshotID + ) } package func generateRunConfigurations() async { @@ -211,7 +221,11 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func loadProject(at workspaceURL: URL, files: [URL]) async { + package func isRunProjectReady(for workspace: URL) -> Bool { + runFeature.isProjectReady(for: workspace) + } + + package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { // Maven is one build-system Provider, not a workspace prerequisite. // Avoid scanning every project as Maven; non-Maven ecosystems should // reach the generic run pipeline without paying for Java discovery. @@ -226,7 +240,8 @@ package final class ProjectDevelopmentFeatureModel { await runFeature.loadProject( at: workspaceURL, files: files, - mavenProject: mavenFeature.project + mavenProject: mavenFeature.project, + snapshotID: snapshotID ) } } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index d41cb4246..7f18c4c6f 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -14,6 +14,7 @@ package final class RunService: ObservableObject { } } @Published package private(set) var isLoadingProject = false + @Published package private(set) var projectLoadState: ProjectLoadState = .idle @Published package private(set) var isRunning = false @Published package private(set) var runningTitle: String? @Published package private(set) var output = "" @@ -105,10 +106,13 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } - /// Reports whether `loadProject` has bound this service to a workspace. - /// Entry points that activate the execution module on demand use this to - /// avoid acting on a service that has no project yet. - package var isProjectLoaded: Bool { projectURL != nil } + /// Whether the file inventory for `workspace` came from its snapshot, and is + /// therefore complete enough to generate a configuration from. Entry points + /// that activate the execution module on demand use this to decide whether + /// the project still has to be loaded. + package func isProjectReady(for workspace: URL) -> Bool { + projectLoadState.isReady(for: workspace) + } @discardableResult package func registerLanguageRunExtension( @@ -137,14 +141,22 @@ package final class RunService: ObservableObject { return roots } + /// Loads run state for a workspace. + /// + /// `snapshotID` identifies the workspace snapshot `files` came from. Passing + /// `nil` means no snapshot has been applied yet, which binds the service so + /// existing configuration can be read while generation stays blocked. package func loadProject( at projectURL: URL, files: [URL], - mavenProject: MavenProject? + mavenProject: MavenProject?, + snapshotID: UUID? = nil ) async { let loadID = UUID() projectLoadID = loadID + let workspace = projectURL.standardizedFileURL isLoadingProject = true + projectLoadState = .loading(workspace: workspace) defer { if projectLoadID == loadID { isLoadingProject = false @@ -160,7 +172,10 @@ package final class RunService: ObservableObject { if let currentProject = self.projectURL { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } - self.projectURL = projectURL.standardizedFileURL + self.projectURL = workspace + projectLoadState = snapshotID + .map { .ready(workspace: workspace, snapshotID: $0) } + ?? .bound(workspace: workspace) self.mavenProject = mavenProject mavenProfiles = mavenProject?.profiles ?? [] self.projectFiles = files @@ -204,10 +219,12 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - // Dropping the request silently is indistinguishable from a broken - // button, so report that the workspace is not loaded yet instead. - guard let projectURL else { - generationState = .projectNotLoaded + // Generation scans the file inventory this service holds, so a + // provisional inventory would write a configuration that omits entry + // points the workspace contains. Dropping the request silently is also + // indistinguishable from a broken button, so report the pending state. + guard let projectURL, projectLoadState.isReady(for: projectURL) else { + generationState = .projectNotReady return } let loadID = projectLoadID @@ -593,6 +610,7 @@ package final class RunService: ObservableObject { stopAllServices() projectLoadID = UUID() projectURL = nil + projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] mavenProject = nil diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 95783ec0a..092629033 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -13,6 +13,10 @@ package enum WorkspaceRebuildResult: Sendable { package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var rootNode: FileNode? @Published package private(set) var projectFiles: [URL] = [] + /// Identifies the snapshot `projectFiles` came from, and is `nil` whenever no + /// snapshot has been applied. Consumers that scan the file inventory use this + /// to tell a complete inventory from a provisional one. + @Published package private(set) var snapshotID: UUID? @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -184,6 +188,7 @@ package final class WorkspaceFeatureModel: ObservableObject { hasRestoredWorkspaceSession = false rootNode = nil projectFiles = [] + snapshotID = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -294,6 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject { loadErrorMessage = nil rootNode = snapshot.root projectFiles = snapshot.files + snapshotID = UUID() scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 062b3519f..d82840100 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -66,10 +66,10 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) - #expect(!service.isProjectLoaded) + #expect(service.projectLoadState == .idle) await service.generateRunConfigurations() - #expect(service.generationState == .projectNotLoaded) + #expect(service.generationState == .projectNotReady) #expect(operations.generateCallCount == 0) #expect(service.configurationStatus == .missing) } @@ -93,10 +93,19 @@ struct ExecutionModuleTests { let root = URL(fileURLWithPath: "/workspace", isDirectory: true) await service.generateRunConfigurations() - #expect(service.generationState == .projectNotLoaded) + #expect(service.generationState == .projectNotReady) + // Binding without a snapshot only unlocks reading existing configuration. await service.loadProject(at: root, files: [], mavenProject: nil) - #expect(service.isProjectLoaded) + #expect(service.projectLoadState == .bound(workspace: root)) + #expect(!service.isProjectReady(for: root)) + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotReady) + #expect(operations.generateCallCount == 0) + + let snapshotID = UUID() + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID)) await service.generateRunConfigurations() #expect(operations.generateCallCount == 1) @@ -104,6 +113,48 @@ struct ExecutionModuleTests { #expect(service.configurationStatus == .ready) } + /// Generation scans the inventory the service holds, so a workspace that was + /// bound before its snapshot arrived must not be scanned with the provisional + /// list. Doing so writes a configuration that omits real entry points. + @Test + func generationScansTheSnapshotInventoryAndNeverAProvisionalOne() async throws { + let operations = RecordingRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let source = root.appendingPathComponent("src/main/java/demo/App.java") + + // The workspace snapshot has not arrived, so the inventory is empty. + await service.loadProject(at: root, files: [], mavenProject: nil) + await service.generateRunConfigurations() + #expect(service.generationState == .projectNotReady) + #expect(operations.generatedInventories.isEmpty, "a provisional inventory must not be scanned") + + await service.loadProject( + at: root, + files: [source], + mavenProject: nil, + snapshotID: UUID() + ) + await service.generateRunConfigurations() + + #expect(service.generationState == .succeeded(entryCount: 1)) + #expect( + operations.generatedInventories == [[source]], + "generation must scan exactly the inventory the snapshot reported" + ) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -383,19 +434,22 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } -/// Counts generation attempts so a test can prove that an unloaded project -/// never reaches the store. +/// Records the file inventory each generation attempt was given, so a test can +/// prove both that a pending workspace never reaches the store and that a ready +/// one is scanned with the complete inventory. private final class RecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { - private(set) var generateCallCount = 0 + private(set) var generatedInventories: [[URL]] = [] + + var generateCallCount: Int { generatedInventories.count } func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection( - status: generateCallCount == 0 ? .missing : .ready, + status: generatedInventories.isEmpty ? .missing : .ready, diagnostics: [] ) } func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { - generateCallCount += 1 + generatedInventories.append(files) return RunConfigurationGenerationResult(entryCount: 1) } func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 0e5b4446f..4ee1fc1b4 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -3078,10 +3078,13 @@ struct RunConfigurationIntegrationTests { generationEntryCount: 0 ) + // A snapshot identity marks the inventory complete, which is what lets + // generation run; an empty workspace legitimately has no entry point. await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject + mavenProject: fixture.mavenProject, + snapshotID: UUID() ) await fixture.service.generateRunConfigurations() @@ -3667,10 +3670,10 @@ struct RunConfigurationIntegrationTests { runConfigurationOperations: operations ) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: UUID()) let generation = Task { await service.generateRunConfigurations() } #expect(await operations.waitUntilBlocked()) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: UUID()) operations.releaseGeneration() await generation.value diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index e6d654d1b..dc92a3a6b 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -5,68 +5,178 @@ import Testing @Suite("Run entry points") @MainActor struct RunEntryPointTests { - /// Run and Debug activate the execution module on demand, so they can reach - /// a run feature that no workspace snapshot has bound to a project yet. The - /// entry point has to load the project itself; otherwise identification hits - /// an unbound service and the confirmed dialog appears to do nothing. + /// Run activates the execution module on demand, so it can reach a run + /// feature before the workspace snapshot has been applied. Binding the + /// workspace there is not enough: generation scans the file inventory the + /// run service holds, so identifying a provisional inventory would write a + /// `generated.json` that omits entry points the workspace contains. /// - /// An unreadable workspace makes the snapshot unavailable, which keeps - /// `onSnapshotLoaded` from running and leaves the entry point as the only - /// path that can bind the project. + /// The snapshot is held until after Run is pressed, then released with a + /// real Java entry point, so the test observes both halves of the contract. @Test - func runningBeforeTheSnapshotLoadsBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) - #expect(model.runFeatureIfActive == nil) + func runBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() + // The entry point binds the workspace so existing configuration is + // readable, but the pending snapshot must keep generation out. let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + model.runFeatureIfActive?.projectLoadState.workspace != nil } - #expect(bound, "the Run entry point did not bind the workspace to the run feature") + #expect(bound, "the Run entry point never bound the workspace") + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + + let runFeature = try #require(model.runFeatureIfActive) + await runFeature.generateRunConfigurations() + #expect(runFeature.generationState == .projectNotReady) + #expect(!workspace.hasGeneratedConfiguration, "a partial inventory must not be written") + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + } + #expect(ready, "the applied snapshot never made the run project ready") + // Which paths generation then scans is asserted against the run + // configuration store in ExecutionModuleTests, because the Swift test + // binary does not link the Rust Core that performs the scan. } /// Debugging reaches the same run feature through its own entry point. @Test - func debuggingBeforeTheSnapshotLoadsBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) - #expect(model.runFeatureIfActive == nil) + func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + model.openProjectDirectly(workspace.root) model.startDebugging() let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + model.runFeatureIfActive?.projectLoadState.workspace != nil } - #expect(bound, "the Debug entry point did not bind the workspace to the run feature") + #expect(bound, "the Debug entry point never bound the workspace") + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + } + #expect(ready, "the applied snapshot never made the run project ready") } - /// The tool-window entry point already loaded the project on demand. It must - /// keep doing so, because the other two now rely on the same contract. + /// Opening a project normally must reach the same ready state without any + /// entry point, so the tool-window path keeps working. @Test - func openingTheRunToolWindowBindsTheProject() async { - let model = makeAppModel() - model.openProjectDirectly(unreadableWorkspaceURL()) + func openingAProjectMakesTheRunProjectReadyOnItsOwn() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + operations.releaseSnapshot() + let model = makeAppModel(workspaceOperations: operations) - model.toggleRun() + model.openProjectDirectly(workspace.root) - let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectLoaded == true + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true } - #expect(bound, "opening the Run tool window did not bind the workspace") + #expect(ready, "opening a project should make the run project ready") } - private func makeAppModel() -> AppModel { + private func makeAppModel(workspaceOperations: any WorkspaceOperations) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) - let services = MacServiceContainer(store: store, settings: settings).services + let services = MacServiceContainer( + store: store, + settings: settings, + workspaceOperations: workspaceOperations + ).services return AppModel(settings: settings, services: services) } +} + +/// A real workspace on disk holding one Java entry point, so generation runs +/// through the shared Core instead of a stubbed result. +@MainActor +private struct JavaWorkspaceFixture { + let root: URL + let sourceURL: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-run-entry-\(UUID().uuidString)") + sourceURL = root.appendingPathComponent("src/main/java/demo/App.java") + try FileManager.default.createDirectory( + at: sourceURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + package demo; + public class App { + public static void main(String[] args) {} + } + """.write(to: sourceURL, atomically: true, encoding: .utf8) + } + + var snapshot: WorkspaceSnapshot { + WorkspaceSnapshot( + root: FileNode(url: root, isDirectory: true, children: []), + files: [sourceURL] + ) + } + + var hasGeneratedConfiguration: Bool { + FileManager.default.fileExists( + atPath: root.appendingPathComponent(".lithe/run/generated.json").path + ) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +/// Holds the workspace snapshot until the test releases it, so a test can decide +/// exactly when the file inventory becomes complete. +private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let gate = TestGate() + private let lock = NSLock() + private let preparedSnapshot: WorkspaceSnapshot + + init(snapshot: WorkspaceSnapshot) { + preparedSnapshot = snapshot + } + + func releaseSnapshot() { + gate.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + // Runs on the workspace feature's detached scan task, never the main + // actor, and the bounded wait keeps a failing test from pinning it. + guard gate.waitSynchronously() else { return nil } + lock.lock() + defer { lock.unlock() } + return preparedSnapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } - private func unreadableWorkspaceURL() -> URL { - FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-unreadable-workspace-\(UUID().uuidString)") + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil } } From 131e3ae1fa78903a8234b8f5ed900000c98d3faa Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 13:20:10 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(macos):=20=E5=B0=B1=E7=BB=AA=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E7=BA=B3=E5=85=A5=E5=BF=AB=E7=85=A7=E6=A0=87=E8=AF=86?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=AE=A9=E6=9C=AA=E5=B0=B1=E7=BB=AA=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=8E=A8=E8=BF=9F=E8=80=8C=E9=9D=9E=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 处理评审的三点。 一、AppModel.swift 与 preview 合并后达到 1806 行,超过 verify-service-boundaries 的 1800 上限。把加载编排移到 AppModel+Development.swift,现为 1781 行。上一轮 本地验证没有先合并 base,所以漏掉了这个失败。 二、snapshotID 放进了 .ready 却没参与比较。同一工作区刷新时, WorkspaceFeatureModel 在发布新快照与调用 onSnapshotLoaded 之间隔着两个 await, 这段窗口里 RunService 仍持有上一份清单却报就绪,重新识别会漏掉新增的入口。 isReady 现在同时比较工作区与快照标识,snapshotID 为 nil 时一律视为未就绪。 三、加载可能只到 .bound,而运行入口只看 configurationStatus。磁盘上已有配置时 会在完整文件列表与 Maven 信息就绪前直接启动,工具链解析因此缺少 Maven 项目。 运行与调试入口改为要求就绪,未就绪时记下 pendingRunAction 并返回;工作区重建 必然以 loadProjectServices 收尾,由它恢复这次操作,因此不需要轮询或等待。 识别现在统一走 AppModel.generateRunConfigurations,先把服务提升到当前快照再执行。 这是 RunService 内部仍可用自身状态判断的前提。 Refs #300 --- .../AppModel/AppModel+Development.swift | 101 ++++++++++++++++-- .../AppModel/AppModel+FeatureState.swift | 3 + .../Lithe/Models/AppModel/AppModel.swift | 35 +----- .../Lithe/Views/Workbench/WorkbenchView.swift | 4 +- .../Execution/RunConfigurationContracts.swift | 16 ++- .../Application/ExecutionFeatureModels.swift | 8 +- .../Services/RunService.swift | 6 +- .../ExecutionModuleTests.swift | 5 +- .../Tests/LitheTests/RunEntryPointTests.swift | 49 ++++++++- 9 files changed, 168 insertions(+), 59 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index a3ef15da6..b72dfe81a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -3,6 +3,12 @@ import LitheCoreContracts import LitheExecutionModule import LitheModuleAPI +/// An action deferred until the run feature holds the current workspace snapshot. +enum PendingRunAction { + case run + case debug +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -142,6 +148,19 @@ extension AppModel { runFeatureIfActive?.select(configuration) } + /// The single entry point for identification. + /// + /// Routing it through here is what keeps the run service from scanning a + /// superseded snapshot: the service can only compare its own state, so the + /// caller has to bring it up to the current snapshot first. + func generateRunConfigurations() async { + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + // Report the pending workspace through the generation state when the + // snapshot has not arrived, which the run panel surfaces as a notice. + _ = await ensureRunProjectReady(runFeature) + await runFeature.generateRunConfigurations() + } + func openRunConfiguration(relativePath: String?) { guard let workspaceURL else { return } let url = workspaceURL.appendingPathComponent(relativePath ?? ".lithe/run/generated.json") @@ -153,22 +172,78 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } - /// Run and Debug can activate the execution module before the workspace - /// snapshot reaches the run feature, which leaves it without a project. The - /// tool-window entry points already load on demand; these do the same so - /// every entry point observes the same state. + /// Loads build-system and run state at the workspace boundary. The generic + /// run lifecycle is intentionally not owned by JavaFeatureModel. + /// + /// Spring indexing is scheduled rather than awaited. It scales with the + /// number of Java sources, and run configurations, test discovery, and the + /// Git refresh that follows this call must not wait for it. + func loadProjectServices(at workspaceURL: URL, files: [URL]) async { + prepareJavaLanguageServerForWorkspaceIfNeeded( + at: workspaceURL, + files: files + ) + springFeature.scheduleLoad( + workspaceURL: workspaceURL, + files: files, + textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + ) + guard let execution = await activateExecutionModule() else { return } + execution.tests.discover(workspaceURL: workspaceURL, files: files) + // The workspace feature owns snapshot identity. Passing it through lets + // the run service tell a complete file inventory from a provisional one, + // so generation never scans a partial workspace. + await execution.projectDevelopment.loadProject( + at: workspaceURL, + files: files, + snapshotID: workspaceSnapshotID + ) + resumePendingRunActionIfProjectBecameReady(execution.runFeature) + } + + /// Brings the run feature up to the workspace snapshot the workspace feature + /// currently holds, and reports whether it got there. /// - /// This does not make an unfinished snapshot ready. When the snapshot is - /// still pending, the load binds the workspace so existing configuration is - /// readable and generation keeps reporting `projectNotReady`. - private func loadProjectServicesIfRunProjectIsNotReady(_ runFeature: RunFeatureModel) async { - guard let workspaceURL, !runFeature.isProjectReady(for: workspaceURL) else { return } + /// Run, Debug, and identification all scan or launch from the file inventory + /// the run service holds, so each of them needs the inventory to match the + /// current snapshot rather than merely being bound to the workspace. + private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { + guard let workspaceURL else { return false } + let snapshotID = workspaceSnapshotID + if runFeature.isProjectReady(for: workspaceURL, snapshotID: snapshotID) { return true } await loadProjectServices(at: workspaceURL, files: projectFiles) + return runFeature.isProjectReady( + for: workspaceURL, + snapshotID: workspaceSnapshotID + ) + } + + /// Continues an action that arrived before the snapshot did. The workspace + /// rebuild always finishes with `loadProjectServices`, so recording the + /// intent is enough to resume without polling or waiting. + private func resumePendingRunActionIfProjectBecameReady(_ runFeature: RunFeatureModel) { + guard let action = pendingRunAction, + let workspaceURL, + runFeature.isProjectReady(for: workspaceURL, snapshotID: workspaceSnapshotID) + else { return } + pendingRunAction = nil + switch action { + case .run: runSelectedConfiguration() + case .debug: startDebugging() + } } private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } - await loadProjectServicesIfRunProjectIsNotReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + // Launching from a provisional inventory resolves toolchains without + // the Maven project, so wait for the snapshot instead of running. + pendingRunAction = .run + return + } + pendingRunAction = nil guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -339,7 +414,11 @@ extension AppModel { guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature - await loadProjectServicesIfRunProjectIsNotReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + pendingRunAction = .debug + return + } + pendingRunAction = nil let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2b0a9c53b..9b9977392 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -9,6 +9,9 @@ extension AppModel { var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } + /// Identifies the snapshot `projectFiles` came from, and is `nil` until one + /// has been applied. + var workspaceSnapshotID: UUID? { workspaceFeature.snapshotID } var javaEnvironmentReport: JavaEnvironmentReport? { runtimeFeature.javaEnvironmentReport } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index dc7dfc3eb..6f70d1e33 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -146,6 +146,9 @@ final class AppModel: ObservableObject, Identifiable { private var workbenchBackgroundFeatureObservation: AnyCancellable? private var isProjectSessionActive = true private var fileVisibilityRulesObserverID: UUID? + /// Run or Debug that arrived before the workspace snapshot reached the run + /// feature. `loadProjectServices` resumes it once the inventory matches. + var pendingRunAction: PendingRunAction? private var requestProjectOpen: ((URL) -> Void)? private var didCloseProject: (() -> Void)? private var securityScopedWorkspaceURL: URL? @@ -749,36 +752,6 @@ final class AppModel: ObservableObject, Identifiable { } } - /// Loads build-system and run state at the workspace boundary. The generic - /// run lifecycle is intentionally not owned by JavaFeatureModel. - /// - /// Spring indexing is scheduled rather than awaited. It scales with the - /// number of Java sources, and run configurations, test discovery, and the - /// Git refresh that follows this call must not wait for it. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { - prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, - files: files - ) - springFeature.scheduleLoad( - workspaceURL: workspaceURL, - files: files, - textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { - ($0.url.standardizedFileURL, $0.text) - }) - ) - guard let execution = await activateExecutionModule() else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: files) - // The workspace feature owns snapshot identity. Passing it through lets - // the run service tell a complete file inventory from a provisional one, - // so generation never scans a partial workspace. - await execution.projectDevelopment.loadProject( - at: workspaceURL, - files: files, - snapshotID: workspaceFeature.snapshotID - ) - } - var projectName: String { workspaceURL?.lastPathComponent ?? "Lithe" } @@ -944,6 +917,7 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.openProject(at: normalizedURL) mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() @@ -1054,6 +1028,7 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.closeProject() mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619be..68b6c2b47 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -827,7 +827,9 @@ struct WorkbenchView: View { guard let runFeature = model.runFeatureIfActive else { return } let intent = runFeature.generationIntent Task { - await runFeature.generateRunConfigurations() + // Routed through AppModel so the run service is brought up to the + // current workspace snapshot before it scans anything. + await model.generateRunConfigurations() guard runFeature.configurationStatus == .ready else { return } switch intent { case .identifyOnly: diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 1c2bc233f..66a2ef6c7 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -76,10 +76,18 @@ package enum ProjectLoadState: Equatable, Sendable { } } - /// Whether the inventory for `workspace` is complete enough to generate from. - package func isReady(for workspace: URL) -> Bool { - guard case .ready(let boundWorkspace, _) = self else { return false } - return boundWorkspace == workspace.standardizedFileURL + /// Whether the inventory matches `snapshotID` for `workspace`, and is + /// therefore the current, complete inventory. + /// + /// Comparing the snapshot as well as the workspace is what rejects a + /// superseded snapshot of the same workspace: a refresh publishes a new + /// snapshot before the run service consumes it, and scanning the previous + /// inventory would miss entry points the refresh added. + package func isReady(for workspace: URL, snapshotID: UUID?) -> Bool { + guard let snapshotID, + case .ready(let boundWorkspace, let boundSnapshotID) = self + else { return false } + return boundWorkspace == workspace.standardizedFileURL && boundSnapshotID == snapshotID } } diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 68ec554c4..89ea2bb8b 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -161,8 +161,8 @@ package final class RunFeatureModel: ObservableObject { package var projectLoadState: ProjectLoadState { service.projectLoadState } - package func isProjectReady(for workspace: URL) -> Bool { - service.isProjectReady(for: workspace) + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + service.isProjectReady(for: workspace, snapshotID: snapshotID) } package func loadProject( @@ -221,8 +221,8 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func isRunProjectReady(for workspace: URL) -> Bool { - runFeature.isProjectReady(for: workspace) + package func isRunProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) } package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 7f18c4c6f..860d777ab 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -110,8 +110,8 @@ package final class RunService: ObservableObject { /// therefore complete enough to generate a configuration from. Entry points /// that activate the execution module on demand use this to decide whether /// the project still has to be loaded. - package func isProjectReady(for workspace: URL) -> Bool { - projectLoadState.isReady(for: workspace) + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } @discardableResult @@ -223,7 +223,7 @@ package final class RunService: ObservableObject { // provisional inventory would write a configuration that omits entry // points the workspace contains. Dropping the request silently is also // indistinguishable from a broken button, so report the pending state. - guard let projectURL, projectLoadState.isReady(for: projectURL) else { + guard let projectURL, case .ready = projectLoadState else { generationState = .projectNotReady return } diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index d82840100..8500682a0 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -98,7 +98,7 @@ struct ExecutionModuleTests { // Binding without a snapshot only unlocks reading existing configuration. await service.loadProject(at: root, files: [], mavenProject: nil) #expect(service.projectLoadState == .bound(workspace: root)) - #expect(!service.isProjectReady(for: root)) + #expect(!service.isProjectReady(for: root, snapshotID: UUID())) await service.generateRunConfigurations() #expect(service.generationState == .projectNotReady) #expect(operations.generateCallCount == 0) @@ -106,6 +106,9 @@ struct ExecutionModuleTests { let snapshotID = UUID() await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID)) + #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) + // A superseded snapshot of the same workspace is not ready. + #expect(!service.isProjectReady(for: root, snapshotID: UUID())) await service.generateRunConfigurations() #expect(operations.generateCallCount == 1) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index dc92a3a6b..1ea9b5e77 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -29,7 +29,7 @@ struct RunEntryPointTests { model.runFeatureIfActive?.projectLoadState.workspace != nil } #expect(bound, "the Run entry point never bound the workspace") - #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) let runFeature = try #require(model.runFeatureIfActive) await runFeature.generateRunConfigurations() @@ -39,7 +39,10 @@ struct RunEntryPointTests { operations.releaseSnapshot() let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "the applied snapshot never made the run project ready") // Which paths generation then scans is asserted against the run @@ -47,6 +50,36 @@ struct RunEntryPointTests { // binary does not link the Rust Core that performs the scan. } + /// Launching from a provisional inventory resolves toolchains without the + /// Maven project, so Run has to defer rather than proceed on a bound-only + /// workspace. The deferred action is remembered and resumed by the load the + /// snapshot drives, which is what lets the user press Run once. + @Test + func runDefersAndResumesWhenTheSnapshotArrivesLater() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + #expect(deferred, "Run must be deferred while the inventory is provisional") + + operations.releaseSnapshot() + + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(ready, "the applied snapshot never made the run project ready") + let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + #expect(resumed, "the deferred Run was never resumed") + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -62,12 +95,15 @@ struct RunEntryPointTests { model.runFeatureIfActive?.projectLoadState.workspace != nil } #expect(bound, "the Debug entry point never bound the workspace") - #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root) == false) + #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) operations.releaseSnapshot() let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "the applied snapshot never made the run project ready") } @@ -85,7 +121,10 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) let ready = await awaitChange(on: model) { - model.runFeatureIfActive?.isProjectReady(for: workspace.root) == true + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true } #expect(ready, "opening a project should make the run project ready") } From a63d39a41fcde33f3e770348da97315a3d714524 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 13:58:55 +0800 Subject: [PATCH 06/11] =?UTF-8?q?test(macos):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E9=85=8D=E7=BD=AE=E4=B8=94=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E6=97=B6=E8=BF=90=E8=A1=8C=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=8E=A8=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版的入口测试只证明了推迟与恢复,没能覆盖评审要的"已有配置 + 快照阻塞" 子场景:真实存储的 inspect 要经 Rust Core,而 Swift 测试二进制不链接它 (bridge.c 提供 weak 桩,isAvailable 为 false),所以 configurationStatus 无法在测试里变成 ready。 给 MacServiceContainer 增加 runConfigurationOperations 可选覆盖,与已有的 workspaceOperations、moduleStore 等注入缝同类。测试用一个直接报 ready 的替身 构造该前提,并记录 launchPlan 请求次数:快照阻塞期间断言运行被推迟、 configurationStatus 确为 ready、launchPlan 从未被请求;放行快照后断言恢复执行 且就绪。 未选择改为链接 Rust 的真实测试:CI 中没有任何任务在链接 Rust 的情况下执行 LitheTests(verify-rust-core.sh 只跑 cargo test、swift build 与独立的桥接验证 程序),因此这样的测试会永远跳过。仓库中已有 9 处依赖 isAvailable 的跳过测试, 不宜再添。 Refs #300 --- .../Platform/MacOS/MacServiceContainer.swift | 9 +- .../Tests/LitheTests/RunEntryPointTests.swift | 99 ++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 6c4aaf532..0bc9fedf5 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -53,6 +53,7 @@ final class MacServiceContainer { moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, + runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -74,6 +75,10 @@ final class MacServiceContainer { preferences: store ) self.runConfigurationStore = runConfigurationStore + // Only the run-configuration boundary is overridable. The concrete store + // stays available for callers that need the macOS adapter itself. + let runConfigurationOperations: any RunConfigurationOperations = + providedRunConfigurationOperations ?? runConfigurationStore let fileOperations = MacWorkspaceFileOperations() let processRunner = MacProcessRunner() let secureStore = MacLocalSecretStore() @@ -320,7 +325,7 @@ final class MacServiceContainer { fileAccess: MacRunFileAccess(storage: fileStorage), preferences: MacRunPreferenceStore(store: store), serverPortParser: javaMavenOperations, - runConfigurationOperations: runConfigurationStore, + runConfigurationOperations: runConfigurationOperations, executableResolver: executableResolver, languageProviderCatalog: languagePackRegistry.catalog, languageRunProviders: languagePackRegistry.runProviders, @@ -395,7 +400,7 @@ final class MacServiceContainer { processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) }, fileStorage: fileStorage, javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore + runConfigurationOperations: runConfigurationOperations ), adapterSessions: adapterSessions ) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 1ea9b5e77..e90ae4d8c 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -80,6 +80,48 @@ struct RunEntryPointTests { #expect(resumed, "the deferred Run was never resumed") } + /// A workspace that already has a configuration reports `configurationStatus + /// == .ready` as soon as it is bound, which used to be enough to launch. With + /// a provisional inventory the Maven project is absent, so toolchains resolve + /// without it. Readiness has to be checked before the configuration status. + @Test + func runWithExistingConfigurationLaunchesOnlyAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + #expect(deferred, "Run must be deferred while the file inventory is provisional") + let runFeature = try #require(model.runFeatureIfActive) + #expect( + runFeature.configurationStatus == .ready, + "the seeded configuration should already report ready" + ) + #expect( + runConfigurations.launchPlanCallCount == 0, + "Run must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + #expect(resumed, "the deferred Run was never resumed") + #expect( + runFeature.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) + ) + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -129,13 +171,17 @@ struct RunEntryPointTests { #expect(ready, "opening a project should make the run project ready") } - private func makeAppModel(workspaceOperations: any WorkspaceOperations) -> AppModel { + private func makeAppModel( + workspaceOperations: any WorkspaceOperations, + runConfigurationOperations: (any RunConfigurationOperations)? = nil + ) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) let services = MacServiceContainer( store: store, settings: settings, - workspaceOperations: workspaceOperations + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurationOperations ).services return AppModel(settings: settings, services: services) } @@ -219,6 +265,55 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se } } +/// Reports a workspace that already carries a configuration, which the Swift test +/// binary cannot obtain from the real store because it does not link the Rust +/// Core. Records launch-plan requests so a test can prove no launch was built. +private final class ReadyRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let lock = NSLock() + private var launchPlanCalls = 0 + + var launchPlanCallCount: Int { + lock.lock() + defer { lock.unlock() } + return launchPlanCalls + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: .currentFile, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: RunConfiguration.currentFileID + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { private var values: [String: Any] = [:] From 9ec894703185be59e93c3368de0bb5d3c82e1f21 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 16:19:36 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(macos):=20=E5=BF=AB=E7=85=A7=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E4=B8=8E=E6=96=87=E4=BB=B6=E6=B8=85=E5=8D=95=E5=90=8C?= =?UTF-8?q?=E6=BA=90=E4=BC=A0=E9=80=92=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A5=BD?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=8A=A0=E8=BD=BD=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 await 前后快照错配,以及快照已消费后入口仍用旧捕获 defer 导致 Run 永久挂起。 --- .../AppModel/AppModel+Development.swift | 128 +++++++++--- .../AppModel/AppModel+FeatureState.swift | 7 +- .../Lithe/Models/AppModel/AppModel.swift | 13 +- .../Execution/RunConfigurationContracts.swift | 21 +- .../Workspace/WorkspaceModels.swift | 10 +- .../Services/RunService.swift | 4 + .../Application/WorkspaceFeatureModel.swift | 30 ++- .../ExecutionModuleTests.swift | 53 +++++ .../Tests/LitheTests/RunEntryPointTests.swift | 184 +++++++++++++++++- 9 files changed, 382 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index b72dfe81a..c9d777ea2 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -4,9 +4,17 @@ import LitheExecutionModule import LitheModuleAPI /// An action deferred until the run feature holds the current workspace snapshot. -enum PendingRunAction { - case run - case debug +/// +/// The workspace it was deferred for is part of the value so a snapshot applied +/// for a different workspace cannot resume it. +struct PendingRunAction: Equatable { + enum Kind: Equatable { + case run + case debug + } + + let kind: Kind + let workspace: URL } @MainActor @@ -39,7 +47,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } isTestsVisible = false @@ -62,7 +70,7 @@ extension AppModel { Task { [weak self] in guard let self, await activateExecutionModule() != nil, let workspaceURL else { return } - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } isTestsVisible = false isGitLogVisible = false @@ -76,7 +84,7 @@ extension AppModel { guard let self else { return } let capability = await self.activateExecutionModule() if capability?.mavenFeature.project == nil { - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } @@ -172,13 +180,37 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } + /// Loads project services for the scan currently applied to the workspace. + /// + /// Callers that only want "whatever the workspace has now" use this so the + /// file list and its identity are captured in a single read. + func loadProjectServicesForAppliedSnapshot(at workspaceURL: URL) async { + let applied = workspaceFeature.appliedSnapshot + await loadProjectServices( + at: workspaceURL, + files: applied?.files ?? [], + snapshotID: applied?.id + ) + } + /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. /// /// Spring indexing is scheduled rather than awaited. It scales with the /// number of Java sources, and run configurations, test discovery, and the /// Git refresh that follows this call must not wait for it. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { + /// + /// `files` and `snapshotID` must describe the same scan; the caller captures + /// them together. `resumesDeferredRunAction` is set only by the workspace + /// snapshot callback, because a deferred Run waits for a snapshot and + /// resuming from any other load would either re-enter through + /// `ensureRunProjectReady` or fire the action from an unrelated reload. + func loadProjectServices( + at workspaceURL: URL, + files: [URL], + snapshotID: UUID?, + resumesDeferredRunAction: Bool = false + ) async { prepareJavaLanguageServerForWorkspaceIfNeeded( at: workspaceURL, files: files @@ -192,15 +224,20 @@ extension AppModel { ) guard let execution = await activateExecutionModule() else { return } execution.tests.discover(workspaceURL: workspaceURL, files: files) - // The workspace feature owns snapshot identity. Passing it through lets - // the run service tell a complete file inventory from a provisional one, - // so generation never scans a partial workspace. + // `files` and `snapshotID` are captured together by the caller. Reading + // the applied snapshot here instead would pair this file list with a + // newer scan's identity, which the readiness comparison cannot detect. await execution.projectDevelopment.loadProject( at: workspaceURL, files: files, - snapshotID: workspaceSnapshotID + snapshotID: snapshotID + ) + guard resumesDeferredRunAction else { return } + resumeDeferredRunAction( + execution.runFeature, + workspace: workspaceURL, + snapshotID: snapshotID ) - resumePendingRunActionIfProjectBecameReady(execution.runFeature) } /// Brings the run feature up to the workspace snapshot the workspace feature @@ -211,36 +248,69 @@ extension AppModel { /// current snapshot rather than merely being bound to the workspace. private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { guard let workspaceURL else { return false } - let snapshotID = workspaceSnapshotID - if runFeature.isProjectReady(for: workspaceURL, snapshotID: snapshotID) { return true } - await loadProjectServices(at: workspaceURL, files: projectFiles) - return runFeature.isProjectReady( - for: workspaceURL, - snapshotID: workspaceSnapshotID + // One read, so the file list and the identity describe the same scan. + let applied = workspaceFeature.appliedSnapshot + if runFeature.isProjectReady(for: workspaceURL, snapshotID: applied?.id) { return true } + await loadProjectServices( + at: workspaceURL, + files: applied?.files ?? [], + snapshotID: applied?.id ) + // A snapshot may land and be fully consumed while this load is in + // flight, including its deferred-run resume with nothing pending yet. + // Comparing against the pre-await capture would then treat a ready + // project as not ready, defer the action, and leave it stranded. + let current = workspaceFeature.appliedSnapshot + return runFeature.isProjectReady(for: workspaceURL, snapshotID: current?.id) } /// Continues an action that arrived before the snapshot did. The workspace - /// rebuild always finishes with `loadProjectServices`, so recording the - /// intent is enough to resume without polling or waiting. - private func resumePendingRunActionIfProjectBecameReady(_ runFeature: RunFeatureModel) { - guard let action = pendingRunAction, - let workspaceURL, - runFeature.isProjectReady(for: workspaceURL, snapshotID: workspaceSnapshotID) - else { return } + /// rebuild always finishes by applying a snapshot, so recording the intent is + /// enough to resume without polling or waiting. + /// + /// The action is dropped when the applied snapshot belongs to a different + /// workspace, which is the case the user is no longer waiting on. + /// `snapshotID` is the scan this load just applied, not whatever the + /// workspace holds now. Reading the current identity here would compare the + /// run feature against a scan it has not consumed. + private func resumeDeferredRunAction( + _ runFeature: RunFeatureModel, + workspace: URL, + snapshotID: UUID? + ) { + guard let action = pendingRunAction else { return } + guard action.workspace == workspace.standardizedFileURL else { + pendingRunAction = nil + return + } + guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { + return + } pendingRunAction = nil - switch action { + switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() } } + /// Records an action the current workspace snapshot is not ready for. + private func deferRunAction(_ kind: PendingRunAction.Kind) { + guard let workspaceURL else { + pendingRunAction = nil + return + } + pendingRunAction = PendingRunAction( + kind: kind, + workspace: workspaceURL.standardizedFileURL + ) + } + private func runSelectedConfigurationAfterActivation() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } guard await ensureRunProjectReady(runFeature) else { // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - pendingRunAction = .run + deferRunAction(.run) return } pendingRunAction = nil @@ -393,7 +463,7 @@ extension AppModel { guard let self else { return } guard await activateExecutionModule() != nil else { return } if let workspaceURL { - await loadProjectServices(at: workspaceURL, files: projectFiles) + await loadProjectServicesForAppliedSnapshot(at: workspaceURL) } _ = await activateDebugModule() } @@ -415,7 +485,7 @@ extension AppModel { let debug = await activateDebugModule() else { return } let runFeature = execution.runFeature guard await ensureRunProjectReady(runFeature) else { - pendingRunAction = .debug + deferRunAction(.debug) return } pendingRunAction = nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 9b9977392..3a7565e43 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -9,9 +9,10 @@ extension AppModel { var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } - /// Identifies the snapshot `projectFiles` came from, and is `nil` until one - /// has been applied. - var workspaceSnapshotID: UUID? { workspaceFeature.snapshotID } + /// Identifies the scan `projectFiles` came from, and is `nil` until one has + /// been applied. Callers that also need the file list read + /// `workspaceFeature.appliedSnapshot` once instead of combining the two. + var workspaceSnapshotID: UUID? { workspaceFeature.appliedSnapshot?.id } var javaEnvironmentReport: JavaEnvironmentReport? { runtimeFeature.javaEnvironmentReport } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 6f70d1e33..8822dad9f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -508,7 +508,7 @@ final class AppModel: ObservableObject, Identifiable { }, reloadProjectServices: { [weak self] in guard let self, let workspaceURL = self.workspaceURL else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) }, refreshGit: { [weak self] in guard let feature = self?.gitFeatureIfActive else { return } @@ -521,7 +521,14 @@ final class AppModel: ObservableObject, Identifiable { onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in guard let self, let workspaceURL = self.workspaceURL else { return } // WorkspaceFeatureModel requests the single Git refresh after this callback. - await self.loadProjectServices(at: workspaceURL, files: snapshot.files) + // The applied snapshot is the event a deferred Run waits for, and + // it carries its own identity so the pair cannot drift. + await self.loadProjectServices( + at: workspaceURL, + files: snapshot.files, + snapshotID: snapshot.id, + resumesDeferredRunAction: true + ) if isInitialLoad { self.projectHistoryFeatureIfActive?.seed(files: snapshot.files) } @@ -747,7 +754,7 @@ final class AppModel: ObservableObject, Identifiable { } Task { [weak self] in guard let self else { return } - await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) + await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL) } } } diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 66a2ef6c7..b05cf9b11 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,14 +47,21 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } -/// How a run service is bound to a workspace. +/// How complete the workspace file inventory a run service holds is. /// /// Being bound to a workspace URL and holding a complete file inventory are /// different things. Reading an existing configuration only needs the URL, while /// generating one scans the inventory, so a provisional inventory would write a /// configuration that omits entry points the workspace actually contains. +/// +/// This describes the inventory only. Whether the configuration on disk is +/// readable is `ProjectRunConfigurationStatus`, and the two must stay separate: +/// a broken `generated.json` has to remain regenerable. package enum ProjectLoadState: Equatable, Sendable { case idle + /// A load is in flight. Entering this state before the load's first + /// suspension point is what stops a cancelled or superseded load from + /// leaving the previous `ready` inventory in place. case loading(workspace: URL) /// Bound to the workspace, but the file inventory is provisional because the /// workspace snapshot has not been applied yet. Existing configuration can be @@ -63,18 +70,6 @@ package enum ProjectLoadState: Equatable, Sendable { /// The inventory came from the identified workspace snapshot, so generation /// can scan it safely. case ready(workspace: URL, snapshotID: UUID) - case failed(workspace: URL, message: String) - - /// The workspace this state describes, when it describes one. - package var workspace: URL? { - switch self { - case .idle: nil - case .loading(let workspace): workspace - case .bound(let workspace): workspace - case .ready(let workspace, _): workspace - case .failed(let workspace, _): workspace - } - } /// Whether the inventory matches `snapshotID` for `workspace`, and is /// therefore the current, complete inventory. diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift index 542bf7d45..14557f95f 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift @@ -38,8 +38,16 @@ package struct FileNode: Identifiable, Hashable, Sendable { package struct WorkspaceSnapshot: Sendable { package let root: FileNode package let files: [URL] - package init(root: FileNode, files: [URL]) { + /// Distinguishes this scan of the workspace from any other. + /// + /// Consumers that scan `files` compare this identity to decide whether their + /// inventory is current. Carrying it in the snapshot is what keeps a file + /// list from ever being paired with a different scan's identity. + package let id: UUID + + package init(root: FileNode, files: [URL], id: UUID = UUID()) { self.root = root self.files = files + self.id = id } } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 860d777ab..84d6a571b 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -173,6 +173,10 @@ package final class RunService: ObservableObject { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } self.projectURL = workspace + // Whether the existing configuration parses is `configurationStatus`, not + // this state. Keeping them apart is what lets a broken generated.json be + // regenerated: folding a parse failure in here would block generation, + // which is the only way to repair it. projectLoadState = snapshotID .map { .ready(workspace: workspace, snapshotID: $0) } ?? .bound(workspace: workspace) diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 092629033..43c698d7e 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -12,11 +12,13 @@ package enum WorkspaceRebuildResult: Sendable { @MainActor package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var rootNode: FileNode? - @Published package private(set) var projectFiles: [URL] = [] - /// Identifies the snapshot `projectFiles` came from, and is `nil` whenever no - /// snapshot has been applied. Consumers that scan the file inventory use this - /// to tell a complete inventory from a provisional one. - @Published package private(set) var snapshotID: UUID? + /// The scan currently applied to the workspace, or `nil` before one is. + /// + /// Consumers that scan the file inventory read this once so the file list and + /// its identity always come from the same scan. + @Published package private(set) var appliedSnapshot: WorkspaceSnapshot? + + package var projectFiles: [URL] { appliedSnapshot?.files ?? [] } @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -187,8 +189,7 @@ package final class WorkspaceFeatureModel: ObservableObject { workspaceURL = nil hasRestoredWorkspaceSession = false rootNode = nil - projectFiles = [] - snapshotID = nil + appliedSnapshot = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -298,8 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } loadErrorMessage = nil rootNode = snapshot.root - projectFiles = snapshot.files - snapshotID = UUID() + appliedSnapshot = snapshot scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service @@ -833,8 +833,18 @@ package final class WorkspaceFeatureModel: ObservableObject { } private func removeProjectItemFromSnapshot(_ targetURL: URL) { - projectFiles.removeAll { urlContains(targetURL, child: $0) } rootNode = rootNode.flatMap { removingProjectItem(targetURL, from: $0) } + // Dropping files changes the inventory, so the result is a new scan and + // needs a new identity. Reusing the old one would let a consumer treat a + // shortened inventory as the scan it had already accepted. + // + // The pruned tree is carried over too, so the applied snapshot never + // disagrees with `rootNode` about what the workspace contains. + guard let applied = appliedSnapshot else { return } + appliedSnapshot = WorkspaceSnapshot( + root: rootNode ?? applied.root, + files: applied.files.filter { !urlContains(targetURL, child: $0) } + ) } private func removingProjectItem(_ targetURL: URL, from node: FileNode) -> FileNode? { diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 8500682a0..f7a0bce60 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -158,6 +158,37 @@ struct ExecutionModuleTests { ) } + /// A broken configuration must stay regenerable. Inventory readiness and + /// configuration validity are separate concerns, so an unreadable + /// `generated.json` must not make the project un-ready and lock the user out + /// of the only action that repairs it. + @Test + func unreadableConfigurationStillAllowsRegeneration() async throws { + let operations = FailingInspectionRunConfigurationOperations() + let service = RunService( + runtime: TestRuntime(), + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let snapshotID = UUID() + + await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + + #expect(service.configurationStatus == .invalid("generated.json is invalid")) + #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) + + await service.generateRunConfigurations() + #expect(service.generationState != .projectNotReady) + } + @Test func currentGoFileRunsThroughExtensionOwnedSession() async throws { let builtInProcess = TestStreamingProcess() @@ -472,6 +503,28 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Reports an unreadable configuration so a test can observe the failed state. +private struct FailingInspectionRunConfigurationOperations: RunConfigurationOperations { + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection( + status: .invalid("generated.json is invalid"), + diagnostics: [], + recoveryAction: .editConfiguration + ) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 0) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Unavailable in inspection test") + } + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index e90ae4d8c..8c5a31fb8 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -26,7 +26,7 @@ struct RunEntryPointTests { // The entry point binds the workspace so existing configuration is // readable, but the pending snapshot must keep generation out. let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.projectLoadState.workspace != nil + model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Run entry point never bound the workspace") #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) @@ -64,7 +64,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the inventory is provisional") operations.releaseSnapshot() @@ -98,7 +98,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction != nil } + let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the file inventory is provisional") let runFeature = try #require(model.runFeatureIfActive) #expect( @@ -112,8 +112,14 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } - #expect(resumed, "the deferred Run was never resumed") + // Production clears the deferred action before it re-issues Run, so + // waiting for the action to clear would pass even if the relaunch were + // dropped. The launch plan request is what proves Run actually ran. + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred Run was never actually re-issued") + #expect(model.pendingRunAction == nil) #expect( runFeature.isProjectReady( for: workspace.root, @@ -122,6 +128,64 @@ struct RunEntryPointTests { ) } + /// When Run's own provisional load is still in flight, the snapshot can land + /// and be fully consumed first — including the deferred-run resume, which + /// finds nothing pending. The entry point must then re-check the *current* + /// snapshot rather than the one it captured before that load, or it defers + /// an action nothing will ever resume. + @Test + func runResumesWhenTheSnapshotLandsDuringTheEntryPointsOwnLoad() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + // The Run entry point starts its own load while the scan is gated, so it + // captures "no snapshot applied". + #expect( + await runConfigurations.inspectionEntered(1), + "the Run entry point never started its own load" + ) + + // The snapshot lands and is fully consumed while that load is suspended. + workspaceOperations.releaseSnapshot() + #expect( + await runConfigurations.inspectionEntered(2), + "the snapshot-driven load never started" + ) + runConfigurations.release(2) + let ready = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(ready, "the snapshot-driven load never made the run project ready") + // Wait for the snapshot-driven load to finish completely, so its resume + // point has already run and found nothing deferred. + let snapshotLoadFinished = await awaitChange(on: model) { + model.runFeatureIfActive?.isLoadingProject == false + && runConfigurations.resolveCallCount == 1 + } + #expect(snapshotLoadFinished, "the snapshot-driven load never finished") + + runConfigurations.release(1) + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed") + #expect(model.pendingRunAction == nil) + } + /// Debugging reaches the same run feature through its own entry point. @Test func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { @@ -134,7 +198,7 @@ struct RunEntryPointTests { model.startDebugging() let bound = await awaitChange(on: model) { - model.runFeatureIfActive?.projectLoadState.workspace != nil + model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Debug entry point never bound the workspace") #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false) @@ -246,7 +310,9 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { // Runs on the workspace feature's detached scan task, never the main // actor, and the bounded wait keeps a failing test from pinning it. - guard gate.waitSynchronously() else { return nil } + // The race test holds this gate while an inspect is suspended, so the + // deadline must outlast that coordination window. + guard gate.waitSynchronously(timeout: 30) else { return nil } lock.lock() defer { lock.unlock() } return preparedSnapshot @@ -286,14 +352,114 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, RunConfigurationGenerationResult(entryCount: 1) } + /// A configuration that does not depend on the active editor file, so a + /// resumed Run reaches the launch plan instead of stopping at "no open file". + static let entryPoint = RunConfiguration( + id: "java-main:demo.App", + name: "App", + kind: .javaMain, + execution: .application, + modulePath: nil, + mainClass: "demo.App" + ) + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { RunConfigurationResolution( configurations: [EffectiveRunConfiguration( - configuration: .currentFile, + configuration: Self.entryPoint, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: Self.entryPoint.id + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + +/// Reports a workspace that already carries a configuration, and lets a test +/// release each `inspect` individually so it can decide what happens while a +/// specific project load is suspended. +private final class InspectionGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let lock = NSLock() + private var inspectCalls = 0 + private var launchPlanCalls = 0 + private var resolveCalls = 0 + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + lock.lock() + inspectCalls += 1 + let ordinal = inspectCalls + lock.unlock() + // Runs on the run service's utility queue, never the cooperative + // executor. The race test holds the first gate across a full + // snapshot-driven load, so the deadline must cover that window. + entered[ordinal - 1].open() + _ = releases[ordinal - 1].waitSynchronously(timeout: 30) + return ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + lock.lock() + resolveCalls += 1 + lock.unlock() + return RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: ReadyRunConfigurationOperations.entryPoint, options: RunOptions() )], diagnostics: [], - defaultConfigurationID: RunConfiguration.currentFileID + defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id ) } From fa807e1c8e379b8a40f3cd47299b0d3f1674a324 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 17:42:34 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(macos):=20=E6=94=B6=E5=8F=A3=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9B=9E=E8=B0=83=E8=BA=AB=E4=BB=BD=E4=B8=8E=E6=9C=AA?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E7=9A=84=E7=94=9F=E6=88=90/=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 rebuild 在挂起点后再次拒绝过期 workspace,并把 workspace 与 snapshot 一并传给 onSnapshotLoaded;ensure 失败时停止生成;直接启动与批量服务入口统一走 readiness 守卫并保留 pending 意图。 --- .../Features/WorkspaceFeatureModel.swift | 2 +- .../AppModel/AppModel+Development.swift | 43 +- .../Lithe/Models/AppModel/AppModel.swift | 10 +- .../Platform/MacOS/MacServiceContainer.swift | 4 +- .../Application/ExecutionFeatureModels.swift | 5 + .../Services/RunService.swift | 9 + .../Application/WorkspaceFeatureModel.swift | 15 +- .../GitStatusObservationTests.swift | 2 +- .../LitheTests/LitheCoreLogicTests.swift | 79 +++- .../Tests/LitheTests/RunEntryPointTests.swift | 368 +++++++++++++++++- 10 files changed, 507 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift index dc411bcb6..51f326665 100644 --- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -42,7 +42,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { reloadProjectServices: @escaping @MainActor @Sendable () async -> Void, refreshGit: @escaping @MainActor @Sendable () async -> Void, updateHistoryVisibilityRules: @escaping @MainActor @Sendable (FileVisibilityRules) async -> Void, - onSnapshotLoaded: @escaping @MainActor @Sendable (WorkspaceSnapshot, Bool) async -> Void + onSnapshotLoaded: @escaping @MainActor @Sendable (URL, WorkspaceSnapshot, Bool) async -> Void ) { configureProjection( documentsProvider: { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index c9d777ea2..cd638dd5d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -6,11 +6,15 @@ import LitheModuleAPI /// An action deferred until the run feature holds the current workspace snapshot. /// /// The workspace it was deferred for is part of the value so a snapshot applied -/// for a different workspace cannot resume it. +/// for a different workspace cannot resume it. Direct launch entry points also +/// keep the concrete configuration (or the all-services intent) so resume can +/// re-issue the same action the UI asked for. struct PendingRunAction: Equatable { enum Kind: Equatable { case run case debug + case startConfiguration(RunConfiguration) + case runAllServices } let kind: Kind @@ -160,12 +164,18 @@ extension AppModel { /// /// Routing it through here is what keeps the run service from scanning a /// superseded snapshot: the service can only compare its own state, so the - /// caller has to bring it up to the current snapshot first. + /// caller has to bring it up to the current snapshot first. When that fails, + /// generation must stop — the service may still hold an older `.ready` + /// inventory, and scanning it would overwrite `generated.json` with stale + /// entry points. func generateRunConfigurations() async { guard let runFeature = await activateExecutionModule()?.runFeature else { return } // Report the pending workspace through the generation state when the // snapshot has not arrived, which the run panel surfaces as a notice. - _ = await ensureRunProjectReady(runFeature) + guard await ensureRunProjectReady(runFeature) else { + runFeature.reportGenerationProjectNotReady() + return + } await runFeature.generateRunConfigurations() } @@ -290,6 +300,8 @@ extension AppModel { switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() + case .startConfiguration(let configuration): startRunConfiguration(configuration) + case .runAllServices: runAllServiceConfigurations() } } @@ -367,12 +379,20 @@ extension AppModel { func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in guard let self, - let runFeature = await activateExecutionModule()?.runFeature, - await activateLanguageRunExtensionIfNeeded( - for: configuration, - currentFileURL: activeDocument?.url, - runFeature: runFeature - ) else { return } + let runFeature = await activateExecutionModule()?.runFeature else { return } + guard await ensureRunProjectReady(runFeature) else { + // Direct play buttons reach here without going through + // `runSelectedConfiguration`, so they need the same readiness + // gate and must remember which configuration to resume. + deferRunAction(.startConfiguration(configuration)) + return + } + pendingRunAction = nil + guard await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + ) else { return } runFeature.startConfiguration(configuration) } } @@ -381,6 +401,11 @@ extension AppModel { Task { [weak self] in guard let self, let runFeature = await activateExecutionModule()?.runFeature else { return } + guard await ensureRunProjectReady(runFeature) else { + deferRunAction(.runAllServices) + return + } + pendingRunAction = nil for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 8822dad9f..f65016af9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -518,11 +518,11 @@ final class AppModel: ObservableObject, Identifiable { guard let feature = await self?.activateHistoryModule() else { return } await feature.updateVisibilityRules(rules.localHistoryRules) }, - onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in - guard let self, let workspaceURL = self.workspaceURL else { return } - // WorkspaceFeatureModel requests the single Git refresh after this callback. - // The applied snapshot is the event a deferred Run waits for, and - // it carries its own identity so the pair cannot drift. + onSnapshotLoaded: { [weak self] workspaceURL, snapshot, isInitialLoad in + guard let self else { return } + // The rebuild already rejected a stale workspace before calling + // us, and it passes that workspace with the snapshot so this + // load cannot pair A.files with B's URL after a project switch. await self.loadProjectServices( at: workspaceURL, files: snapshot.files, diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0bc9fedf5..7052f5025 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -54,6 +54,7 @@ final class MacServiceContainer { moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider providedGitWatchContextProvider: (any GitWatchContextProviding)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -503,7 +504,8 @@ final class MacServiceContainer { fileOperations: fileOperations, binaryFileViewerRegistry: binaryFileViewerRegistry, projectRuntimeService: runtimeService, - gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore), + gitWatchContextProvider: providedGitWatchContextProvider + ?? RustGitWatchContextProvider(core: rustCore), githubService: githubService, secureStore: secureStore, databaseSecureStore: databaseSecureStore, diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 89ea2bb8b..0cda6c48b 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -165,6 +165,11 @@ package final class RunFeatureModel: ObservableObject { service.isProjectReady(for: workspace, snapshotID: snapshotID) } + package func reportGenerationProjectNotReady() { + isGenerationConfirmationPresented = false + service.reportGenerationProjectNotReady() + } + package func loadProject( at workspaceURL: URL, files: [URL], diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 84d6a571b..d97df8640 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -114,6 +114,15 @@ package final class RunService: ObservableObject { projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } + /// Surfaces the "project still loading" generation notice without scanning. + /// + /// AppModel uses this when readiness cannot be established for the current + /// snapshot: the service may still hold an older `.ready` inventory, and + /// calling `generateRunConfigurations` would scan that stale list. + package func reportGenerationProjectNotReady() { + generationState = .projectNotReady + } + @discardableResult package func registerLanguageRunExtension( _ provider: any LanguageRunExtensionProviding, diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 43c698d7e..f78595d02 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -66,7 +66,7 @@ package final class WorkspaceFeatureModel: ObservableObject { private var reloadProjectServices: (@MainActor () async -> Void)? private var refreshGit: (@MainActor () async -> Void)? private var updateHistoryVisibilityRules: (@MainActor (FileVisibilityRules) async -> Void)? - private var onSnapshotLoaded: (@MainActor (WorkspaceSnapshot, Bool) async -> Void)? + private var onSnapshotLoaded: (@MainActor (URL, WorkspaceSnapshot, Bool) async -> Void)? private var warmSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? private var updateSearchIndex: (@MainActor (URL, [String], FileVisibilityRules) async -> Void)? private var invalidateSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? @@ -102,7 +102,7 @@ package final class WorkspaceFeatureModel: ObservableObject { reloadProjectServices: @escaping @MainActor () async -> Void, refreshGit: @escaping @MainActor () async -> Void, updateHistoryVisibilityRules: @escaping @MainActor (FileVisibilityRules) async -> Void, - onSnapshotLoaded: @escaping @MainActor (WorkspaceSnapshot, Bool) async -> Void, + onSnapshotLoaded: @escaping @MainActor (URL, WorkspaceSnapshot, Bool) async -> Void, warmSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void, updateSearchIndex: @escaping @MainActor (URL, [String], FileVisibilityRules) async -> Void, invalidateSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void @@ -310,14 +310,23 @@ package final class WorkspaceFeatureModel: ObservableObject { isRefreshingWorkspace = false } + // Session restore and watch setup can suspend. A project switch in that + // window must not let this rebuild keep mutating the new workspace or + // deliver this scan under the new root's identity. if !hasRestoredWorkspaceSession { if let restoreSession, let session = workspaceSessionStore.load(for: workspaceURL) { await restoreSession(session, snapshot.files) } + guard isCurrent() else { return .stale } hasRestoredWorkspaceSession = true } + guard isCurrent() else { return .stale } await updateWatchConfiguration() - await onSnapshotLoaded?(snapshot, isInitialLoad) + guard isCurrent() else { return .stale } + // Pass the rebuild's workspace with the snapshot so the callback never + // re-reads a global URL that may already belong to a different project. + await onSnapshotLoaded?(workspaceURL, snapshot, isInitialLoad) + guard isCurrent() else { return .stale } await requestGitRefreshNow() if pendingFullRescan || pendingWatchRootsChanged { scheduleRecovery() diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift index f08b9bd5f..f4886a4d9 100644 --- a/macos/Tests/LitheTests/GitStatusObservationTests.swift +++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift @@ -563,7 +563,7 @@ private func makeObservationModel(recorder: GitObservationRecorder) -> Workspace recorder.gitRefreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) return model } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 00efa2175..a549e2136 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3148,7 +3148,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: {}, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/retry-workspace") @@ -3202,7 +3202,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: { gitRefreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in snapshotLoadCount += 1 } + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } ) let workspace = URL(fileURLWithPath: "/tmp/lithe-initial-refresh") model.beginWorkspace(at: workspace, visibilityRules: .default) @@ -3216,6 +3216,77 @@ struct EditorDocumentTests { #expect(gitRefreshCount == 1) } + /// After the snapshot is published, restoreSession and watch setup can still + /// suspend. A project switch in that window must not deliver the old scan + /// through onSnapshotLoaded under the new workspace identity. + @Test + @MainActor + func rebuildRejectsStaleWorkspaceBeforeSnapshotCallback() async { + let enteredRestore = TestGate() + let releaseRestore = TestGate() + defer { releaseRestore.open() } + + let operations = SequencedWorkspaceOperations(snapshotAvailability: [true]) + let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore()) + let workspace = URL(fileURLWithPath: "/tmp/lithe-stale-snapshot-callback") + sessionStore.save( + WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"), + for: workspace + ) + + var snapshotLoadCount = 0 + var isCurrent = true + let model = WorkspaceFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), + directoryWatcherFactory: TestDirectoryWatcherFactory(), + workspaceSessionStore: sessionStore + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in + enteredRestore.open() + _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5)) + }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: { _ in false }, + reloadProjectServices: {}, + refreshGit: {}, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } + ) + + model.beginWorkspace(at: workspace, visibilityRules: .default) + let rebuildTask = Task { + await model.rebuild( + at: workspace, + rules: .default, + isCurrent: { isCurrent } + ) + } + + #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5))) + #expect(model.appliedSnapshot != nil, "the snapshot should already be published") + isCurrent = false + releaseRestore.open() + + let result = await rebuildTask.value + if case .stale = result {} else { + Issue.record("A rebuild that lost isCurrent before the callback should report stale") + } + #expect(snapshotLoadCount == 0, "the stale rebuild must not deliver onSnapshotLoaded") + } + @Test @MainActor func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws { @@ -3361,7 +3432,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: { refreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/frozen-workspace") @@ -3984,7 +4055,7 @@ private func makeWorkspaceObservationUnitModel( reloadProjectServices: reloadProjectServices, refreshGit: refreshGit, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) return model } diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 8c5a31fb8..98510ebfe 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -235,9 +235,177 @@ struct RunEntryPointTests { #expect(ready, "opening a project should make the run project ready") } + /// The Run panel's play buttons call `startRunConfiguration`, not + /// `runSelectedConfiguration`. That path must defer under a provisional + /// inventory and remember the concrete configuration for resume. + @Test + func startRunConfigurationDefersAndResumesAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + let configuration = ReadyRunConfigurationOperations.entryPoint + + model.openProjectDirectly(workspace.root) + model.startRunConfiguration(configuration) + + let deferred = await awaitChange(on: model) { + model.pendingRunAction?.kind == .startConfiguration(configuration) + } + #expect(deferred, "direct start must be deferred while the inventory is provisional") + #expect( + runConfigurations.launchPlanCallCount == 0, + "direct start must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred direct start was never actually re-issued") + #expect(model.pendingRunAction == nil) + } + + /// The Run panel's "Run All Services" button calls `runAllServiceConfigurations`, + /// which must defer under a provisional inventory and remember that batch + /// intent — not collapse into a generic `.run`. + @Test + func runAllServicesDefersAndResumesAfterTheSnapshotArrives() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let workspaceOperations = GatedWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runAllServiceConfigurations() + + let deferred = await awaitChange(on: model) { + model.pendingRunAction?.kind == .runAllServices + } + #expect(deferred, "run-all-services must be deferred while the inventory is provisional") + #expect( + runConfigurations.launchPlanCallCount == 0, + "run-all-services must not build a launch plan from a provisional inventory" + ) + + workspaceOperations.releaseSnapshot() + + let relaunched = await awaitChange(on: model) { + runConfigurations.launchPlanCallCount == 1 + } + #expect(relaunched, "the deferred run-all-services was never actually re-issued") + #expect(model.pendingRunAction == nil) + } + + /// When ensure loads snapshot A and snapshot B is published before that load + /// finishes — and B's callback has not yet consumed into the run service — + /// generation must stop rather than scan A's still-ready inventory. + @Test + func generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + let first = workspace.snapshot + let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java") + try """ + package demo; + public class Other { + public static void main(String[] args) {} + } + """.write(to: secondSource, atomically: true, encoding: .utf8) + let second = WorkspaceSnapshot( + root: first.root, + files: [workspace.sourceURL, secondSource], + id: UUID() + ) + + let workspaceOperations = SequencedGatedWorkspaceOperations(snapshots: [first, second]) + let watchContext = GatedGitWatchContextProvider() + defer { watchContext.releaseAll() } + let runConfigurations = InventoryRecordingGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + + model.openProjectDirectly(workspace.root) + workspaceOperations.releaseNext() + // Rebuild publishes the snapshot, then waits in updateWatchConfiguration + // before onSnapshotLoaded can start the run-service load. + #expect(await watchContext.entered(1)) + watchContext.release(1) + #expect(await runConfigurations.inspectionEntered(1)) + runConfigurations.release(1) + + let readyForFirst = await awaitChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: first.id + ) == true + } + #expect(readyForFirst, "the first snapshot never made the run project ready") + + let runFeature = try #require(model.runFeatureIfActive) + // Force a reload path so ensure captures the first snapshot, then waits. + let bindTask = Task { + await runFeature.loadProject( + at: workspace.root, + files: [], + mavenProject: nil, + snapshotID: nil + ) + } + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) + await bindTask.value + #expect(runFeature.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)) + + let generateTask = Task { await model.generateRunConfigurations() } + #expect(await runConfigurations.inspectionEntered(3)) + + // Publish B, but hold its watch-context await so onSnapshotLoaded cannot + // consume B into the run service before ensure finishes loading A. + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + workspaceOperations.releaseNext() + #expect(await watchContext.entered(2)) + let advanced = await awaitChange(on: model) { + model.workspaceSnapshotID == second.id + } + #expect(advanced, "the refreshed snapshot was never published") + + runConfigurations.release(3) + await generateTask.value + #expect(runFeature.generationState == .projectNotReady) + #expect( + runConfigurations.generatedInventories.isEmpty, + "generation must not scan the superseded inventory" + ) + #expect( + runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), + "ensure should have finished loading A while B was only published" + ) + + watchContext.release(2) + #expect(await runConfigurations.inspectionEntered(4)) + runConfigurations.release(4) + _ = await refreshTask.value + } + private func makeAppModel( workspaceOperations: any WorkspaceOperations, - runConfigurationOperations: (any RunConfigurationOperations)? = nil + runConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider: (any GitWatchContextProviding)? = nil ) -> AppModel { let store = RunEntryPointTestStore() let settings = AppSettings(store: store) @@ -245,7 +413,8 @@ struct RunEntryPointTests { store: store, settings: settings, workspaceOperations: workspaceOperations, - runConfigurationOperations: runConfigurationOperations + runConfigurationOperations: runConfigurationOperations, + gitWatchContextProvider: gitWatchContextProvider ).services return AppModel(settings: settings, services: services) } @@ -331,6 +500,53 @@ private final class GatedWorkspaceOperations: WorkspaceOperations, @unchecked Se } } +/// Releases workspace snapshots one at a time so a refresh can publish a newer +/// scan while an older project load is still in flight. +private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private var remaining: [WorkspaceSnapshot] + private let gates: [TestGate] + private var nextReleaseIndex = 0 + + init(snapshots: [WorkspaceSnapshot]) { + remaining = snapshots + gates = snapshots.map { _ in TestGate() } + } + + func releaseNext() { + lock.lock() + let index = nextReleaseIndex + nextReleaseIndex += 1 + let gate = index < gates.count ? gates[index] : nil + lock.unlock() + gate?.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + let consumed = gates.count - remaining.count + let gate = consumed < gates.count ? gates[consumed] : nil + lock.unlock() + guard let gate, gate.waitSynchronously(timeout: 30) else { return nil } + lock.lock() + defer { lock.unlock() } + guard !remaining.isEmpty else { return nil } + return remaining.removeFirst() + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Reports a workspace that already carries a configuration, which the Swift test /// binary cannot obtain from the real store because it does not link the Rust /// Core. Records launch-plan requests so a test can prove no launch was built. @@ -363,12 +579,29 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, mainClass: "demo.App" ) + /// A service configuration so `runAllServiceConfigurations` has something to + /// launch after a deferred resume. + static let serviceEntryPoint = RunConfiguration( + id: "spring-boot:demo.App", + name: "App (Spring Boot)", + kind: .mavenFramework(.springBoot), + execution: .service, + modulePath: nil, + mainClass: "demo.App" + ) + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { RunConfigurationResolution( - configurations: [EffectiveRunConfiguration( - configuration: Self.entryPoint, - options: RunOptions() - )], + configurations: [ + EffectiveRunConfiguration( + configuration: Self.entryPoint, + options: RunOptions() + ), + EffectiveRunConfiguration( + configuration: Self.serviceEntryPoint, + options: RunOptions() + ), + ], diagnostics: [], defaultConfigurationID: Self.entryPoint.id ) @@ -480,6 +713,129 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// Holds `updateWatchConfiguration`'s git-context fetch so a test can publish a +/// newer snapshot without letting `onSnapshotLoaded` consume it yet. +private final class GatedGitWatchContextProvider: GitWatchContextProviding, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let queue = DispatchQueue(label: "lithe.tests.gated-git-watch-context") + private var calls = 0 + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func watchContext(for workspace: URL) async -> GitWatchContext? { + let ordinal: Int = await withCheckedContinuation { continuation in + queue.async { + self.calls += 1 + continuation.resume(returning: self.calls) + } + } + entered[ordinal - 1].open() + _ = await releases[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + return nil + } +} + +/// Like `InspectionGatedRunConfigurationOperations`, but also records every +/// generate inventory so a superseded-snapshot test can prove generation never +/// scanned the stale file list. +private final class InventoryRecordingGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { + private let entered: [TestGate] + private let releases: [TestGate] + private let lock = NSLock() + private var inspectCalls = 0 + private var launchPlanCalls = 0 + private var inventories: [[URL]] = [] + + init(capacity: Int = 8) { + entered = (0.. Bool { + await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5)) + } + + func release(_ ordinal: Int) { + releases[ordinal - 1].open() + } + + func releaseAll() { + releases.forEach { $0.open() } + } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + lock.lock() + inspectCalls += 1 + let ordinal = inspectCalls + lock.unlock() + entered[ordinal - 1].open() + _ = releases[ordinal - 1].waitSynchronously(timeout: 30) + return ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + lock.lock() + inventories.append(files) + lock.unlock() + return RunConfigurationGenerationResult(entryCount: files.count) + } + + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: ReadyRunConfigurationOperations.entryPoint, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan { + lock.lock() + launchPlanCalls += 1 + lock.unlock() + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable { private var values: [String: Any] = [:] From 82169547e1df4be74a00033c2074357891921c85 Mon Sep 17 00:00:00 2001 From: fenghp Date: Sat, 29 Aug 2026 18:50:39 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(macos):=20Restart=20=E4=B8=8E?= =?UTF-8?q?=E8=B7=A8=E5=B7=A5=E7=A8=8B=E5=85=A5=E5=8F=A3=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E6=BC=8F=E6=96=97=E5=B9=B6=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restart 走 ensureRunProjectReady/pending 恢复;入口捕获 workspace 区分 stale 与 waiting,避免旧任务 defer 到新工程或清掉对方 pending。 --- .../AppModel/AppModel+Development.swift | 208 +++++++++--- .../Lithe/Models/AppModel/AppModel.swift | 2 + .../Execution/RunConfigurationContracts.swift | 12 + .../Application/ExecutionFeatureModels.swift | 4 + .../Services/RunService.swift | 7 + .../Tests/LitheTests/RunEntryPointTests.swift | 320 ++++++++++++++---- 6 files changed, 439 insertions(+), 114 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index cd638dd5d..025ecd4de 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -15,12 +15,24 @@ struct PendingRunAction: Equatable { case debug case startConfiguration(RunConfiguration) case runAllServices + case restart } let kind: Kind let workspace: URL } +/// Result of bringing the run feature up to a specific workspace's snapshot. +/// +/// Distinguishing "still waiting on this workspace" from "this entry task is +/// stale" is what stops a cross-workspace await from re-recording the old +/// action against the new project's pending slot. +private enum RunProjectReadiness: Equatable { + case ready + case waitingForSnapshot(workspace: URL) + case stale +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -169,14 +181,19 @@ extension AppModel { /// inventory, and scanning it would overwrite `generated.json` with stale /// entry points. func generateRunConfigurations() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - // Report the pending workspace through the generation state when the - // snapshot has not arrived, which the run panel surfaces as a notice. - guard await ensureRunProjectReady(runFeature) else { + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + await runFeature.generateRunConfigurations() + case .waitingForSnapshot: + // Report the pending workspace through the generation state when the + // snapshot has not arrived, which the run panel surfaces as a notice. runFeature.reportGenerationProjectNotReady() + case .stale: return } - await runFeature.generateRunConfigurations() } func openRunConfiguration(relativePath: String?) { @@ -221,48 +238,75 @@ extension AppModel { snapshotID: UUID?, resumesDeferredRunAction: Bool = false ) async { + let target = workspaceURL.standardizedFileURL + guard isCurrentWorkspace(target) else { return } prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, + at: target, files: files ) springFeature.scheduleLoad( - workspaceURL: workspaceURL, + workspaceURL: target, files: files, textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { ($0.url.standardizedFileURL, $0.text) }) ) guard let execution = await activateExecutionModule() else { return } - execution.tests.discover(workspaceURL: workspaceURL, files: files) + // Module activation suspends; a project switch must not let this load + // write the captured inventory into the new workspace's run service. + guard isCurrentWorkspace(target) else { return } + execution.tests.discover(workspaceURL: target, files: files) // `files` and `snapshotID` are captured together by the caller. Reading // the applied snapshot here instead would pair this file list with a // newer scan's identity, which the readiness comparison cannot detect. await execution.projectDevelopment.loadProject( - at: workspaceURL, + at: target, files: files, snapshotID: snapshotID ) + guard isCurrentWorkspace(target) else { return } guard resumesDeferredRunAction else { return } resumeDeferredRunAction( execution.runFeature, - workspace: workspaceURL, + workspace: target, snapshotID: snapshotID ) } - /// Brings the run feature up to the workspace snapshot the workspace feature - /// currently holds, and reports whether it got there. + private func isCurrentWorkspace(_ workspace: URL) -> Bool { + workspaceURL?.standardizedFileURL == workspace.standardizedFileURL + } + + /// Brings the run feature up to the snapshot for a captured workspace. /// - /// Run, Debug, and identification all scan or launch from the file inventory - /// the run service holds, so each of them needs the inventory to match the - /// current snapshot rather than merely being bound to the workspace. - private func ensureRunProjectReady(_ runFeature: RunFeatureModel) async -> Bool { - guard let workspaceURL else { return false } + /// Entry tasks capture the workspace before any await so a cross-project + /// switch can be reported as `.stale` instead of being re-deferred against + /// whatever URL is current when the load finishes. + /// + /// When a newer snapshot is published but the run service still holds an + /// older `.ready` inventory for this workspace, the snapshot callback owns + /// the transition. Loading from the entry path would race that callback and + /// let Restart proceed from a half-applied refresh. + /// + /// When the run service is not already ready for this workspace, the entry + /// path applies the published scan itself (open-before-run, prune, tool + /// window) so readiness does not wait on a callback that may never arrive. + private func ensureRunProjectReady( + _ runFeature: RunFeatureModel, + for workspace: URL + ) async -> RunProjectReadiness { + let target = workspace.standardizedFileURL + guard isCurrentWorkspace(target) else { return .stale } // One read, so the file list and the identity describe the same scan. let applied = workspaceFeature.appliedSnapshot - if runFeature.isProjectReady(for: workspaceURL, snapshotID: applied?.id) { return true } + if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready } + if applied != nil, runFeature.hasReadyInventory(for: target) { + return .waitingForSnapshot(workspace: target) + } + // No matching ready inventory: bind provisionally, or apply the + // published scan when one already exists. await loadProjectServices( - at: workspaceURL, + at: target, files: applied?.files ?? [], snapshotID: applied?.id ) @@ -270,16 +314,21 @@ extension AppModel { // flight, including its deferred-run resume with nothing pending yet. // Comparing against the pre-await capture would then treat a ready // project as not ready, defer the action, and leave it stranded. + guard isCurrentWorkspace(target) else { return .stale } let current = workspaceFeature.appliedSnapshot - return runFeature.isProjectReady(for: workspaceURL, snapshotID: current?.id) + if runFeature.isProjectReady(for: target, snapshotID: current?.id) { + return .ready + } + return .waitingForSnapshot(workspace: target) } /// Continues an action that arrived before the snapshot did. The workspace /// rebuild always finishes by applying a snapshot, so recording the intent is /// enough to resume without polling or waiting. /// - /// The action is dropped when the applied snapshot belongs to a different - /// workspace, which is the case the user is no longer waiting on. + /// A load for workspace A must not clear a pending action that belongs to B: + /// openProject already cleared A's pending on the switch, and a stale A + /// callback arriving later would otherwise wipe B's newly recorded intent. /// `snapshotID` is the scan this load just applied, not whatever the /// workspace holds now. Reading the current identity here would compare the /// run feature against a scan it has not consumed. @@ -289,43 +338,55 @@ extension AppModel { snapshotID: UUID? ) { guard let action = pendingRunAction else { return } - guard action.workspace == workspace.standardizedFileURL else { - pendingRunAction = nil - return - } + guard action.workspace == workspace.standardizedFileURL else { return } guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { return } - pendingRunAction = nil + setPendingRunAction(nil) switch action.kind { case .run: runSelectedConfiguration() case .debug: startDebugging() case .startConfiguration(let configuration): startRunConfiguration(configuration) case .runAllServices: runAllServiceConfigurations() + case .restart: restartSelectedRun() } } - /// Records an action the current workspace snapshot is not ready for. - private func deferRunAction(_ kind: PendingRunAction.Kind) { - guard let workspaceURL else { - pendingRunAction = nil - return - } - pendingRunAction = PendingRunAction( - kind: kind, - workspace: workspaceURL.standardizedFileURL - ) + /// Records an action for the workspace the entry task captured, not whatever + /// URL happens to be current after an await. + private func clearPendingRunAction(for workspace: URL) { + guard pendingRunAction?.workspace == workspace.standardizedFileURL else { return } + setPendingRunAction(nil) + } + + private func setPendingRunAction(_ action: PendingRunAction?) { + pendingRunAction = action + // pendingRunAction is not @Published; relay so tests and any UI that + // observes AppModel learn about defer/resume without a feature load. + scheduleObjectWillChangeRelay() + } + + private func deferRunAction(_ kind: PendingRunAction.Kind, for workspace: URL) { + let target = workspace.standardizedFileURL + guard isCurrentWorkspace(target) else { return } + setPendingRunAction(PendingRunAction(kind: kind, workspace: target)) } private func runSelectedConfigurationAfterActivation() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - deferRunAction(.run) + deferRunAction(.run, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -338,6 +399,7 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(workspace) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -350,6 +412,7 @@ extension AppModel { return } } + guard isCurrentWorkspace(workspace) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -362,8 +425,20 @@ extension AppModel { func restartSelectedRun() { isRunVisible = true Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + guard runFeature.lastConfiguration != nil else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.restart, for: waitingWorkspace) + return + case .stale: + return + } guard let configuration = runFeature.lastConfiguration else { return } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, @@ -372,46 +447,63 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(workspace) else { return } runFeature.restart() } } func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): // Direct play buttons reach here without going through // `runSelectedConfiguration`, so they need the same readiness - // gate and must remember which configuration to resume. - deferRunAction(.startConfiguration(configuration)) + // gate and must remember which configuration to resume — bound + // to the workspace this task started for, not the URL after an + // await. + deferRunAction(.startConfiguration(configuration), for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: activeDocument?.url, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(workspace) else { return } runFeature.startConfiguration(configuration) } } func runAllServiceConfigurations() { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } - guard await ensureRunProjectReady(runFeature) else { - deferRunAction(.runAllServices) + guard let self else { return } + guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(workspace) else { return } + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.runAllServices, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: nil, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(workspace) else { return } } runFeature.runAllServices() } @@ -506,14 +598,20 @@ extension AppModel { } private func startDebuggingAfterActivation() async { + guard let workspace = workspaceURL?.standardizedFileURL else { return } guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } + guard isCurrentWorkspace(workspace) else { return } let runFeature = execution.runFeature - guard await ensureRunProjectReady(runFeature) else { - deferRunAction(.debug) + switch await ensureRunProjectReady(runFeature, for: workspace) { + case .ready: + clearPendingRunAction(for: workspace) + case .waitingForSnapshot(let waitingWorkspace): + deferRunAction(.debug, for: waitingWorkspace) + return + case .stale: return } - pendingRunAction = nil let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index f65016af9..05e7b2f3d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -925,6 +925,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() @@ -1036,6 +1037,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index b05cf9b11..dd6c33340 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -84,6 +84,18 @@ package enum ProjectLoadState: Equatable, Sendable { else { return false } return boundWorkspace == workspace.standardizedFileURL && boundSnapshotID == snapshotID } + + /// Whether a complete inventory for `workspace` is already applied, whichever + /// snapshot produced it. + /// + /// A refresh publishes its snapshot before this state consumes it, so in that + /// window the inventory is complete but superseded. Distinguishing it from a + /// provisional or foreign binding is what lets a caller leave the transition + /// to the snapshot callback instead of loading the scan itself. + package func hasReadyInventory(for workspace: URL) -> Bool { + guard case .ready(let boundWorkspace, _) = self else { return false } + return boundWorkspace == workspace.standardizedFileURL + } } package enum RunConfigurationGenerationState: Equatable, Sendable { diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 0cda6c48b..0a0afffd4 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -165,6 +165,10 @@ package final class RunFeatureModel: ObservableObject { service.isProjectReady(for: workspace, snapshotID: snapshotID) } + package func hasReadyInventory(for workspace: URL) -> Bool { + service.hasReadyInventory(for: workspace) + } + package func reportGenerationProjectNotReady() { isGenerationConfirmationPresented = false service.reportGenerationProjectNotReady() diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index d97df8640..a1b7f4fc2 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -114,6 +114,13 @@ package final class RunService: ObservableObject { projectLoadState.isReady(for: workspace, snapshotID: snapshotID) } + /// Whether a complete inventory for `workspace` is already loaded, even if a + /// newer snapshot has since been published. Entry points use this to tell a + /// superseded inventory apart from one that was never loaded. + package func hasReadyInventory(for workspace: URL) -> Bool { + projectLoadState.hasReadyInventory(for: workspace) + } + /// Surfaces the "project still loading" generation notice without scanning. /// /// AppModel uses this when readiness cannot be established for the current diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 98510ebfe..5ec817c82 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -25,7 +25,7 @@ struct RunEntryPointTests { // The entry point binds the workspace so existing configuration is // readable, but the pending snapshot must keep generation out. - let bound = await awaitChange(on: model) { + let bound = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Run entry point never bound the workspace") @@ -38,7 +38,7 @@ struct RunEntryPointTests { operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -64,19 +64,19 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the inventory is provisional") operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID ) == true } #expect(ready, "the applied snapshot never made the run project ready") - let resumed = await awaitChange(on: model) { model.pendingRunAction == nil } + let resumed = await awaitLoadDrivenChange(on: model) { model.pendingRunAction == nil } #expect(resumed, "the deferred Run was never resumed") } @@ -98,7 +98,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runSelectedConfiguration() - let deferred = await awaitChange(on: model) { model.pendingRunAction?.kind == .run } + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } #expect(deferred, "Run must be deferred while the file inventory is provisional") let runFeature = try #require(model.runFeatureIfActive) #expect( @@ -115,9 +115,7 @@ struct RunEntryPointTests { // Production clears the deferred action before it re-issues Run, so // waiting for the action to clear would pass even if the relaunch were // dropped. The launch plan request is what proves Run actually ran. - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred Run was never actually re-issued") #expect(model.pendingRunAction == nil) #expect( @@ -162,7 +160,7 @@ struct RunEntryPointTests { "the snapshot-driven load never started" ) runConfigurations.release(2) - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -171,7 +169,7 @@ struct RunEntryPointTests { #expect(ready, "the snapshot-driven load never made the run project ready") // Wait for the snapshot-driven load to finish completely, so its resume // point has already run and found nothing deferred. - let snapshotLoadFinished = await awaitChange(on: model) { + let snapshotLoadFinished = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isLoadingProject == false && runConfigurations.resolveCallCount == 1 } @@ -179,9 +177,7 @@ struct RunEntryPointTests { runConfigurations.release(1) - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed") #expect(model.pendingRunAction == nil) } @@ -197,7 +193,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.startDebugging() - let bound = await awaitChange(on: model) { + let bound = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL) } #expect(bound, "the Debug entry point never bound the workspace") @@ -205,7 +201,7 @@ struct RunEntryPointTests { operations.releaseSnapshot() - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -226,7 +222,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) - let ready = await awaitChange(on: model) { + let ready = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: model.workspaceSnapshotID @@ -253,7 +249,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.startRunConfiguration(configuration) - let deferred = await awaitChange(on: model) { + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .startConfiguration(configuration) } #expect(deferred, "direct start must be deferred while the inventory is provisional") @@ -264,9 +260,7 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred direct start was never actually re-issued") #expect(model.pendingRunAction == nil) } @@ -288,7 +282,7 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) model.runAllServiceConfigurations() - let deferred = await awaitChange(on: model) { + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .runAllServices } #expect(deferred, "run-all-services must be deferred while the inventory is provisional") @@ -299,16 +293,155 @@ struct RunEntryPointTests { workspaceOperations.releaseSnapshot() - let relaunched = await awaitChange(on: model) { - runConfigurations.launchPlanCallCount == 1 - } + let relaunched = await runConfigurations.launchPlanRequested(1) #expect(relaunched, "the deferred run-all-services was never actually re-issued") #expect(model.pendingRunAction == nil) } - /// When ensure loads snapshot A and snapshot B is published before that load - /// finishes — and B's callback has not yet consumed into the run service — - /// generation must stop rather than scan A's still-ready inventory. + /// Restart must use the same readiness funnel as direct start. A published + /// but not-yet-consumed refresh still leaves the run service on the old + /// inventory; restarting then would rebuild a launch plan from that stale + /// scan. + @Test + func restartDefersWhenANewerSnapshotIsPublishedButNotYetConsumed() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + let first = workspace.snapshot + let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java") + try """ + package demo; + public class Other { + public static void main(String[] args) {} + } + """.write(to: secondSource, atomically: true, encoding: .utf8) + let second = WorkspaceSnapshot( + root: first.root, + files: [workspace.sourceURL, secondSource], + id: UUID() + ) + + let workspaceOperations = SequencedGatedWorkspaceOperations(snapshots: [first, second]) + let watchContext = GatedGitWatchContextProvider() + defer { watchContext.releaseAll() } + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + + // Establish lastConfiguration through the same deferred-run path the + // existing entry tests already cover, then refresh to a newer snapshot + // without letting the run service consume it. + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + let deferredRun = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } + #expect(deferredRun, "the initial run must defer until the first snapshot arrives") + + workspaceOperations.releaseNext() + #expect(await watchContext.entered(1)) + watchContext.release(1) + let launched = await runConfigurations.launchPlanRequested(1) + #expect(launched, "the initial run never requested a launch plan") + #expect(model.runFeatureIfActive?.lastConfiguration != nil) + #expect(model.pendingRunAction == nil) + + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + workspaceOperations.releaseNext() + #expect(await watchContext.entered(2)) + let advanced = await awaitLoadDrivenChange(on: model) { + model.workspaceSnapshotID == second.id + } + #expect(advanced, "the refreshed snapshot was never published") + #expect( + model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: first.id) == true, + "the run service should still hold the first snapshot while B's callback is held" + ) + + model.restartSelectedRun() + let deferredRestart = await awaitLoadDrivenChange(on: model) { + model.pendingRunAction?.kind == .restart + } + #expect(deferredRestart, "Restart must defer while the newer snapshot is unpublished to the run service") + #expect( + runConfigurations.launchPlanCallCount == 1, + "Restart must not rebuild a launch plan from the superseded inventory" + ) + + watchContext.release(2) + let relaunched = await runConfigurations.launchPlanRequested(2) + #expect(relaunched, "the deferred Restart was never actually re-issued") + #expect(model.pendingRunAction == nil) + _ = await refreshTask.value + } + + /// An entry task that started for workspace A must not re-record its action + /// against B after a project switch, and must not wipe B's own pending. + @Test + func directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace() async throws { + let workspaceA = try JavaWorkspaceFixture() + let workspaceB = try JavaWorkspaceFixture() + defer { + workspaceA.remove() + workspaceB.remove() + } + + let workspaceOperations = MultiRootGatedWorkspaceOperations(snapshotsByRoot: [ + workspaceA.root: workspaceA.snapshot, + workspaceB.root: workspaceB.snapshot, + ]) + defer { + workspaceOperations.release(workspaceA.root) + workspaceOperations.release(workspaceB.root) + } + // Hold both scans until the test decides so B's direct start defers. + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + let configurationA = ReadyRunConfigurationOperations.entryPoint + let configurationB = ReadyRunConfigurationOperations.serviceEntryPoint + + model.openProjectDirectly(workspaceA.root) + model.startRunConfiguration(configurationA) + #expect(await runConfigurations.inspectionEntered(1)) + + model.openProjectDirectly(workspaceB.root) + #expect(model.pendingRunAction == nil, "opening B must clear A's pending") + + // Keep B's snapshot gated so the direct start defers for B itself. + model.startRunConfiguration(configurationB) + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) + let deferredForB = await awaitLoadDrivenChange(on: model) { + model.pendingRunAction?.kind == .startConfiguration(configurationB) + && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL + } + #expect(deferredForB, "B should record its own deferred direct start") + + // A's in-flight ensure finishes after the switch. It must be treated as + // stale: no re-defer against B, and B's pending must survive. + runConfigurations.release(1) + let corruptedByStaleA = await awaitChange(on: model, timeout: .seconds(1)) { + model.pendingRunAction?.kind == .startConfiguration(configurationA) + } + #expect( + !corruptedByStaleA, + "a stale entry task for A must not re-defer its configuration onto B" + ) + #expect( + model.pendingRunAction?.kind == .startConfiguration(configurationB) + && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL, + "B's pending action must survive the stale A task finishing" + ) + } + + /// When snapshot B is already published but its callback has not consumed it + /// into the run service, generation must stop rather than scan the still-ready + /// A inventory. @Test func generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad() async throws { let workspace = try JavaWorkspaceFixture() @@ -341,14 +474,12 @@ struct RunEntryPointTests { model.openProjectDirectly(workspace.root) workspaceOperations.releaseNext() - // Rebuild publishes the snapshot, then waits in updateWatchConfiguration - // before onSnapshotLoaded can start the run-service load. #expect(await watchContext.entered(1)) watchContext.release(1) #expect(await runConfigurations.inspectionEntered(1)) runConfigurations.release(1) - let readyForFirst = await awaitChange(on: model) { + let readyForFirst = await awaitLoadDrivenChange(on: model) { model.runFeatureIfActive?.isProjectReady( for: workspace.root, snapshotID: first.id @@ -357,48 +488,28 @@ struct RunEntryPointTests { #expect(readyForFirst, "the first snapshot never made the run project ready") let runFeature = try #require(model.runFeatureIfActive) - // Force a reload path so ensure captures the first snapshot, then waits. - let bindTask = Task { - await runFeature.loadProject( - at: workspace.root, - files: [], - mavenProject: nil, - snapshotID: nil - ) - } - #expect(await runConfigurations.inspectionEntered(2)) - runConfigurations.release(2) - await bindTask.value - #expect(runFeature.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)) - - let generateTask = Task { await model.generateRunConfigurations() } - #expect(await runConfigurations.inspectionEntered(3)) - - // Publish B, but hold its watch-context await so onSnapshotLoaded cannot - // consume B into the run service before ensure finishes loading A. let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } workspaceOperations.releaseNext() #expect(await watchContext.entered(2)) - let advanced = await awaitChange(on: model) { + let advanced = await awaitLoadDrivenChange(on: model) { model.workspaceSnapshotID == second.id } #expect(advanced, "the refreshed snapshot was never published") + #expect( + runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), + "the run service should still hold A while B's callback is held" + ) - runConfigurations.release(3) - await generateTask.value + await model.generateRunConfigurations() #expect(runFeature.generationState == .projectNotReady) #expect( runConfigurations.generatedInventories.isEmpty, "generation must not scan the superseded inventory" ) - #expect( - runFeature.isProjectReady(for: workspace.root, snapshotID: first.id), - "ensure should have finished loading A while B was only published" - ) watchContext.release(2) - #expect(await runConfigurations.inspectionEntered(4)) - runConfigurations.release(4) + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) _ = await refreshTask.value } @@ -420,6 +531,19 @@ struct RunEntryPointTests { } } +/// Awaits a state this suite reaches only after a full snapshot-driven load — +/// watch configuration, project load, toolchain resolution, and the deferred +/// resume that follows. That pipeline is far longer than a single publication, +/// so these waits carry the same deadline as the file's cross-load gates +/// instead of the shared default a failing test would otherwise hit first. +@MainActor +private func awaitLoadDrivenChange( + on model: AppModel, + until isSatisfied: @escaping @MainActor @Sendable () -> Bool +) async -> Bool { + await awaitChange(on: model, timeout: .seconds(30), until: isSatisfied) +} + /// A real workspace on disk holding one Java entry point, so generation runs /// through the shared Core instead of a stubbed result. @MainActor @@ -547,12 +671,63 @@ private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unc } } +/// Holds each workspace root's snapshot behind its own gate so a test can keep +/// an old project's scan suspended while a new project opens. +private final class MultiRootGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private var snapshotsByRoot: [String: WorkspaceSnapshot] + private var gatesByRoot: [String: TestGate] + + init(snapshotsByRoot: [URL: WorkspaceSnapshot]) { + var snapshots: [String: WorkspaceSnapshot] = [:] + var gates: [String: TestGate] = [:] + for (root, snapshot) in snapshotsByRoot { + let key = root.standardizedFileURL.path + snapshots[key] = snapshot + gates[key] = TestGate() + } + self.snapshotsByRoot = snapshots + self.gatesByRoot = gates + } + + func release(_ root: URL) { + gatesByRoot[root.standardizedFileURL.path]?.open() + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + let key = rootURL.standardizedFileURL.path + lock.lock() + let gate = gatesByRoot[key] + let snapshot = snapshotsByRoot[key] + lock.unlock() + guard let gate, gate.waitSynchronously(timeout: 30) else { return nil } + return snapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Reports a workspace that already carries a configuration, which the Swift test /// binary cannot obtain from the real store because it does not link the Rust /// Core. Records launch-plan requests so a test can prove no launch was built. private final class ReadyRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { private let lock = NSLock() private var launchPlanCalls = 0 + private let launchPlanRequests: [TestGate] + + init(capacity: Int = 8) { + launchPlanRequests = (0.. Bool { + await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + } + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) } @@ -616,7 +805,9 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, ) throws -> SharedLaunchPlan { lock.lock() launchPlanCalls += 1 + let ordinal = launchPlanCalls lock.unlock() + launchPlanRequests[ordinal - 1].open() throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") } @@ -630,6 +821,7 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, private final class InspectionGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { private let entered: [TestGate] private let releases: [TestGate] + private let launchPlanRequests: [TestGate] private let lock = NSLock() private var inspectCalls = 0 private var launchPlanCalls = 0 @@ -638,6 +830,7 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO init(capacity: Int = 8) { entered = (0.. Bool { + await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) + } + var resolveCallCount: Int { lock.lock() defer { lock.unlock() } @@ -705,7 +905,9 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO ) throws -> SharedLaunchPlan { lock.lock() launchPlanCalls += 1 + let ordinal = launchPlanCalls lock.unlock() + launchPlanRequests[ordinal - 1].open() throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") } From ef55aee8568c471f1a1ab7cc59509b04bb7e7034 Mon Sep 17 00:00:00 2001 From: fenghp Date: Mon, 31 Aug 2026 09:40:10 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(macos):=20=E7=94=A8=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=B8=96=E4=BB=A3=E9=97=AD=E7=8E=AF=E5=90=8C=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=87=8D=E5=BC=80=E7=9A=84=E8=BA=AB=E4=BB=BD=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一路径重开时 URL 相同,旧会话的入口任务与旧 rebuild 都会被判为当前。改为每次 open/close 推进世代,入口任务、await 后的守卫与 rebuild 一并比较 URL + 世代。 --- .../AppModel/AppModel+Development.swift | 184 ++++++++++-------- .../Lithe/Models/AppModel/AppModel.swift | 9 +- .../Application/WorkspaceFeatureModel.swift | 18 +- .../LitheTests/LitheCoreLogicTests.swift | 74 +++++++ .../Tests/LitheTests/RunEntryPointTests.swift | 118 ++++++++++- 5 files changed, 317 insertions(+), 86 deletions(-) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 025ecd4de..b9c2dab73 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -3,12 +3,23 @@ import LitheCoreContracts import LitheExecutionModule import LitheModuleAPI +/// One opening of one workspace. +/// +/// The path alone repeats when the same project is closed and reopened, so a +/// task that started before the reopen would still compare equal to the current +/// workspace. Pairing the path with the opening's generation is what lets such a +/// task be recognized as belonging to a session that is over. +struct WorkspaceIdentity: Equatable { + let url: URL + let generation: Int +} + /// An action deferred until the run feature holds the current workspace snapshot. /// -/// The workspace it was deferred for is part of the value so a snapshot applied -/// for a different workspace cannot resume it. Direct launch entry points also -/// keep the concrete configuration (or the all-services intent) so resume can -/// re-issue the same action the UI asked for. +/// The opening it was deferred for is part of the value so a snapshot applied for +/// a different workspace — or for a later opening of the same one — cannot resume +/// it. Direct launch entry points also keep the concrete configuration (or the +/// all-services intent) so resume can re-issue the same action the UI asked for. struct PendingRunAction: Equatable { enum Kind: Equatable { case run @@ -19,17 +30,17 @@ struct PendingRunAction: Equatable { } let kind: Kind - let workspace: URL + let identity: WorkspaceIdentity } -/// Result of bringing the run feature up to a specific workspace's snapshot. +/// Result of bringing the run feature up to a specific opening's snapshot. /// -/// Distinguishing "still waiting on this workspace" from "this entry task is -/// stale" is what stops a cross-workspace await from re-recording the old -/// action against the new project's pending slot. +/// Distinguishing "still waiting on this opening" from "this entry task is +/// stale" is what stops an await that outlived a project switch or a reopen from +/// re-recording the old action against the current pending slot. private enum RunProjectReadiness: Equatable { case ready - case waitingForSnapshot(workspace: URL) + case waitingForSnapshot(identity: WorkspaceIdentity) case stale } @@ -181,10 +192,10 @@ extension AppModel { /// inventory, and scanning it would overwrite `generated.json` with stale /// entry points. func generateRunConfigurations() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: await runFeature.generateRunConfigurations() case .waitingForSnapshot: @@ -239,7 +250,9 @@ extension AppModel { resumesDeferredRunAction: Bool = false ) async { let target = workspaceURL.standardizedFileURL - guard isCurrentWorkspace(target) else { return } + // The caller established that this load belongs to the current opening, + // so the identity is captured here and re-checked after every await. + guard let identity = currentWorkspaceIdentity, identity.url == target else { return } prepareJavaLanguageServerForWorkspaceIfNeeded( at: target, files: files @@ -252,9 +265,10 @@ extension AppModel { }) ) guard let execution = await activateExecutionModule() else { return } - // Module activation suspends; a project switch must not let this load - // write the captured inventory into the new workspace's run service. - guard isCurrentWorkspace(target) else { return } + // Module activation suspends; a project switch or a reopen must not let + // this load write the captured inventory into the new opening's run + // service. + guard isCurrentWorkspace(identity) else { return } execution.tests.discover(workspaceURL: target, files: files) // `files` and `snapshotID` are captured together by the caller. Reading // the applied snapshot here instead would pair this file list with a @@ -264,24 +278,30 @@ extension AppModel { files: files, snapshotID: snapshotID ) - guard isCurrentWorkspace(target) else { return } + guard isCurrentWorkspace(identity) else { return } guard resumesDeferredRunAction else { return } resumeDeferredRunAction( execution.runFeature, - workspace: target, + identity: identity, snapshotID: snapshotID ) } - private func isCurrentWorkspace(_ workspace: URL) -> Bool { - workspaceURL?.standardizedFileURL == workspace.standardizedFileURL + /// The opening an entry task captures before its first await. + var currentWorkspaceIdentity: WorkspaceIdentity? { + guard let url = workspaceURL?.standardizedFileURL else { return nil } + return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration) + } + + private func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { + currentWorkspaceIdentity == identity } - /// Brings the run feature up to the snapshot for a captured workspace. + /// Brings the run feature up to the snapshot for a captured opening. /// - /// Entry tasks capture the workspace before any await so a cross-project - /// switch can be reported as `.stale` instead of being re-deferred against - /// whatever URL is current when the load finishes. + /// Entry tasks capture the opening before any await so a project switch — or + /// a close and reopen of the same path — can be reported as `.stale` instead + /// of being re-deferred against whatever is current when the load finishes. /// /// When a newer snapshot is published but the run service still holds an /// older `.ready` inventory for this workspace, the snapshot callback owns @@ -293,15 +313,15 @@ extension AppModel { /// window) so readiness does not wait on a callback that may never arrive. private func ensureRunProjectReady( _ runFeature: RunFeatureModel, - for workspace: URL + for identity: WorkspaceIdentity ) async -> RunProjectReadiness { - let target = workspace.standardizedFileURL - guard isCurrentWorkspace(target) else { return .stale } + guard isCurrentWorkspace(identity) else { return .stale } + let target = identity.url // One read, so the file list and the identity describe the same scan. let applied = workspaceFeature.appliedSnapshot if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready } if applied != nil, runFeature.hasReadyInventory(for: target) { - return .waitingForSnapshot(workspace: target) + return .waitingForSnapshot(identity: identity) } // No matching ready inventory: bind provisionally, or apply the // published scan when one already exists. @@ -314,32 +334,33 @@ extension AppModel { // flight, including its deferred-run resume with nothing pending yet. // Comparing against the pre-await capture would then treat a ready // project as not ready, defer the action, and leave it stranded. - guard isCurrentWorkspace(target) else { return .stale } + guard isCurrentWorkspace(identity) else { return .stale } let current = workspaceFeature.appliedSnapshot if runFeature.isProjectReady(for: target, snapshotID: current?.id) { return .ready } - return .waitingForSnapshot(workspace: target) + return .waitingForSnapshot(identity: identity) } /// Continues an action that arrived before the snapshot did. The workspace /// rebuild always finishes by applying a snapshot, so recording the intent is /// enough to resume without polling or waiting. /// - /// A load for workspace A must not clear a pending action that belongs to B: - /// openProject already cleared A's pending on the switch, and a stale A - /// callback arriving later would otherwise wipe B's newly recorded intent. + /// A load for one opening must not clear a pending action that belongs to + /// another: openProject already cleared the old pending on the switch, and a + /// stale callback arriving later would otherwise wipe the newly recorded + /// intent. /// `snapshotID` is the scan this load just applied, not whatever the /// workspace holds now. Reading the current identity here would compare the /// run feature against a scan it has not consumed. private func resumeDeferredRunAction( _ runFeature: RunFeatureModel, - workspace: URL, + identity: WorkspaceIdentity, snapshotID: UUID? ) { guard let action = pendingRunAction else { return } - guard action.workspace == workspace.standardizedFileURL else { return } - guard runFeature.isProjectReady(for: workspace, snapshotID: snapshotID) else { + guard action.identity == identity else { return } + guard runFeature.isProjectReady(for: identity.url, snapshotID: snapshotID) else { return } setPendingRunAction(nil) @@ -352,10 +373,10 @@ extension AppModel { } } - /// Records an action for the workspace the entry task captured, not whatever - /// URL happens to be current after an await. - private func clearPendingRunAction(for workspace: URL) { - guard pendingRunAction?.workspace == workspace.standardizedFileURL else { return } + /// Records an action for the opening the entry task captured, not whatever + /// workspace happens to be current after an await. + private func clearPendingRunAction(for identity: WorkspaceIdentity) { + guard pendingRunAction?.identity == identity else { return } setPendingRunAction(nil) } @@ -366,23 +387,22 @@ extension AppModel { scheduleObjectWillChangeRelay() } - private func deferRunAction(_ kind: PendingRunAction.Kind, for workspace: URL) { - let target = workspace.standardizedFileURL - guard isCurrentWorkspace(target) else { return } - setPendingRunAction(PendingRunAction(kind: kind, workspace: target)) + private func deferRunAction(_ kind: PendingRunAction.Kind, for identity: WorkspaceIdentity) { + guard isCurrentWorkspace(identity) else { return } + setPendingRunAction(PendingRunAction(kind: kind, identity: identity)) } private func runSelectedConfigurationAfterActivation() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): // Launching from a provisional inventory resolves toolchains without // the Maven project, so wait for the snapshot instead of running. - deferRunAction(.run, for: waitingWorkspace) + deferRunAction(.run, for: waitingIdentity) return case .stale: return @@ -399,7 +419,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -412,7 +432,7 @@ extension AppModel { return } } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -426,15 +446,15 @@ extension AppModel { isRunVisible = true Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } guard runFeature.lastConfiguration != nil else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.restart, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.restart, for: waitingIdentity) return case .stale: return @@ -447,7 +467,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.restart() } } @@ -455,19 +475,19 @@ extension AppModel { func startRunConfiguration(_ configuration: RunConfiguration) { Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): // Direct play buttons reach here without going through // `runSelectedConfiguration`, so they need the same readiness // gate and must remember which configuration to resume — bound - // to the workspace this task started for, not the URL after an - // await. - deferRunAction(.startConfiguration(configuration), for: waitingWorkspace) + // to the opening this task started for, not whatever is current + // after an await. + deferRunAction(.startConfiguration(configuration), for: waitingIdentity) return case .stale: return @@ -477,7 +497,7 @@ extension AppModel { currentFileURL: activeDocument?.url, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.startConfiguration(configuration) } } @@ -485,14 +505,14 @@ extension AppModel { func runAllServiceConfigurations() { Task { [weak self] in guard let self else { return } - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(workspace) else { return } - switch await ensureRunProjectReady(runFeature, for: workspace) { + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.runAllServices, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.runAllServices, for: waitingIdentity) return case .stale: return @@ -503,7 +523,7 @@ extension AppModel { currentFileURL: nil, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } } runFeature.runAllServices() } @@ -598,16 +618,16 @@ extension AppModel { } private func startDebuggingAfterActivation() async { - guard let workspace = workspaceURL?.standardizedFileURL else { return } + guard let identity = currentWorkspaceIdentity else { return } guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } - guard isCurrentWorkspace(workspace) else { return } + guard isCurrentWorkspace(identity) else { return } let runFeature = execution.runFeature - switch await ensureRunProjectReady(runFeature, for: workspace) { + switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: - clearPendingRunAction(for: workspace) - case .waitingForSnapshot(let waitingWorkspace): - deferRunAction(.debug, for: waitingWorkspace) + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.debug, for: waitingIdentity) return case .stale: return diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 05e7b2f3d..1f8137ea4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -960,11 +960,18 @@ final class AppModel: ObservableObject, Identifiable { pendingProjectItemDeletion = nil recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects) + // The rebuild belongs to this opening. Reopening the same path advances + // the generation, so a rebuild left over from the previous opening stops + // instead of publishing its snapshot into this one. + let generation = workspaceFeature.workspaceGeneration Task { _ = await workspaceFeature.rebuild( at: normalizedURL, rules: visibilityRules, - isCurrent: { [weak self] in self?.workspaceURL == normalizedURL } + isCurrent: { [weak self] in + self?.workspaceURL == normalizedURL + && self?.workspaceFeature.workspaceGeneration == generation + } ) } } diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index f78595d02..c1d0a7a87 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -19,6 +19,14 @@ package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var appliedSnapshot: WorkspaceSnapshot? package var projectFiles: [URL] { appliedSnapshot?.files ?? [] } + + /// Counts openings of a workspace, so consumers can tell one opening apart + /// from the next even when both use the same path. + /// + /// A URL alone repeats when the same project is closed and reopened: a task + /// that started before the reopen would still compare equal and be treated + /// as current. Every open and every reset advances this instead. + @Published package private(set) var workspaceGeneration = 0 @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -167,6 +175,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func reset() { + workspaceGeneration += 1 if let workspaceURL { scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules) } @@ -208,6 +217,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { + workspaceGeneration += 1 workspaceURL = url.standardizedFileURL self.visibilityRules = visibilityRules hasRestoredWorkspaceSession = false @@ -339,10 +349,16 @@ package final class WorkspaceFeatureModel: ObservableObject { refreshTask?.cancel() pendingExternalPaths.removeAll() externalRefreshGeneration += 1 + // A refresh belongs to the opening that started it. Comparing the path + // alone would let a refresh that outlives a close/reopen of the same + // project publish its snapshot into the new opening. + let generation = workspaceGeneration _ = await rebuild( at: workspaceURL, rules: visibilityRules, - isCurrent: { [weak self] in self?.workspaceURL == workspaceURL } + isCurrent: { [weak self] in + self?.workspaceURL == workspaceURL && self?.workspaceGeneration == generation + } ) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index a549e2136..200acc5d3 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3287,6 +3287,80 @@ struct EditorDocumentTests { #expect(snapshotLoadCount == 0, "the stale rebuild must not deliver onSnapshotLoaded") } + /// Closing and reopening the same path leaves the workspace URL unchanged, so + /// only the opening's generation can tell a refresh that outlived the close + /// from one that belongs to the current session. Without it, the earlier + /// refresh would publish its scan into the new opening after `reset`. + @Test + @MainActor + func refreshFromAnEarlierOpeningOfTheSamePathDoesNotDeliverItsSnapshot() async { + let enteredRestore = TestGate() + let releaseRestore = TestGate() + defer { releaseRestore.open() } + + let operations = SequencedWorkspaceOperations(snapshotAvailability: [true]) + let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore()) + let workspace = URL(fileURLWithPath: "/tmp/lithe-same-path-reopen-refresh") + sessionStore.save( + WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"), + for: workspace + ) + + var snapshotLoadCount = 0 + let model = WorkspaceFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), + directoryWatcherFactory: TestDirectoryWatcherFactory(), + workspaceSessionStore: sessionStore + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in + enteredRestore.open() + _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5)) + }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: { _ in false }, + reloadProjectServices: {}, + refreshGit: {}, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 } + ) + + model.beginWorkspace(at: workspace, visibilityRules: .default) + // The refresh builds its own current guard, so this exercises production + // identity rather than a guard supplied by the test. + let refreshTask = Task { await model.refreshCurrent() } + + #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5))) + #expect(model.appliedSnapshot != nil, "the snapshot should already be published") + + // Close and reopen the same path while the refresh is suspended. + model.reset() + model.beginWorkspace(at: workspace, visibilityRules: .default) + releaseRestore.open() + await refreshTask.value + + #expect( + snapshotLoadCount == 0, + "a refresh from the previous opening must not deliver its snapshot to the new one" + ) + #expect( + model.appliedSnapshot == nil, + "the new opening has not scanned yet, so no snapshot should be applied" + ) + } + @Test @MainActor func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws { diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 5ec817c82..dc5c626bd 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -418,7 +418,7 @@ struct RunEntryPointTests { runConfigurations.release(2) let deferredForB = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .startConfiguration(configurationB) - && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL + && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL } #expect(deferredForB, "B should record its own deferred direct start") @@ -434,11 +434,82 @@ struct RunEntryPointTests { ) #expect( model.pendingRunAction?.kind == .startConfiguration(configurationB) - && model.pendingRunAction?.workspace == workspaceB.root.standardizedFileURL, + && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL, "B's pending action must survive the stale A task finishing" ) } + /// Reopening the same path starts a new session while the URL stays the same, + /// so only the opening's generation separates the two. An entry task from the + /// previous opening must be discarded — otherwise it finds the new opening's + /// snapshot already applied, reads that as "ready", and launches its own + /// configuration into a session the user has replaced. + @Test + func directStartFromAnEarlierOpeningOfTheSameWorkspaceIsDiscarded() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + + // The first opening never gets a snapshot, so the direct start takes the + // pre-snapshot path and suspends in its own load; the second opening + // scans normally. + let workspaceOperations = UnavailableThenReadyWorkspaceOperations(snapshot: workspace.snapshot) + let runConfigurations = InspectionGatedRunConfigurationOperations() + defer { runConfigurations.releaseAll() } + // This test does not coordinate on the watch configuration, and the real + // provider would run Git twice for the two openings. + let watchContext = GatedGitWatchContextProvider() + watchContext.releaseAll() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations, + gitWatchContextProvider: watchContext + ) + let earlierConfiguration = ReadyRunConfigurationOperations.entryPoint + + model.openProjectDirectly(workspace.root) + model.startRunConfiguration(earlierConfiguration) + #expect( + await runConfigurations.inspectionEntered(1), + "the direct start never began its own load" + ) + + // Reopen the same path and let this opening reach a fully loaded state. + model.openProjectDirectly(workspace.root) + #expect(model.pendingRunAction == nil, "reopening must clear the previous pending") + #expect( + await runConfigurations.inspectionEntered(2), + "the reopened project never loaded its own snapshot" + ) + runConfigurations.release(2) + let readyForCurrentOpening = await awaitLoadDrivenChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #expect(readyForCurrentOpening, "the reopened project never became ready") + let currentSnapshotID = model.workspaceSnapshotID + + // The earlier opening's task finishes last. Its captured URL still + // matches, so only the generation can reject it. + runConfigurations.release(1) + #expect( + await runConfigurations.launchPlanNotRequested(within: .seconds(1)), + "a task from the previous opening must not launch into the current one" + ) + #expect( + model.pendingRunAction == nil, + "a discarded task must not record a pending action either" + ) + #expect( + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: currentSnapshotID + ) == true, + "the current opening's inventory must survive the discarded task" + ) + } + /// When snapshot B is already published but its callback has not consumed it /// into the run service, generation must stop rather than scan the still-ready /// A inventory. @@ -671,6 +742,43 @@ private final class SequencedGatedWorkspaceOperations: WorkspaceOperations, @unc } } +/// Reports "the project folder could not be scanned yet" for the first scan and +/// a real snapshot afterwards. +/// +/// A reopen test needs the first opening to sit before any applied snapshot while +/// the second opening loads normally. Reporting the first scan as unavailable +/// reaches that state without suspending a scan, which would occupy a thread of +/// the pool the workspace feature scans on for the whole test. +private final class UnavailableThenReadyWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private let preparedSnapshot: WorkspaceSnapshot + private var scanCount = 0 + + init(snapshot: WorkspaceSnapshot) { + preparedSnapshot = snapshot + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + let ordinal = scanCount + scanCount += 1 + lock.unlock() + return ordinal == 0 ? nil : preparedSnapshot + } + + func readFile(at rootURL: URL, relativePath: String) -> String? { + try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8) + } + + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { + (try? text.write( + to: rootURL.appendingPathComponent(relativePath), + atomically: true, + encoding: .utf8 + )) != nil + } +} + /// Holds each workspace root's snapshot behind its own gate so a test can keep /// an old project's scan suspended while a new project opens. private final class MultiRootGatedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { @@ -846,6 +954,12 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30)) } + /// Asserting that no launch happens needs a short deadline: the whole wait is + /// paid on the passing path, so it must not carry a load-sized one. + func launchPlanNotRequested(within duration: Duration) async -> Bool { + await !launchPlanRequests[0].waitUntilOpen(timeout: duration) + } + var resolveCallCount: Int { lock.lock() defer { lock.unlock() } From 9d661a0b2ebf50096601ec1dd35c9c2c41e0032b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 31 Aug 2026 10:50:56 +0800 Subject: [PATCH 11/11] fix(macos): ignore stale watcher context after workspace reopen --- .../Application/WorkspaceFeatureModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index c1d0a7a87..6aca435ba 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -625,8 +625,12 @@ package final class WorkspaceFeatureModel: ObservableObject { private func updateWatchConfiguration(forceRebuild: Bool = false) async { guard let workspaceURL else { return } + // The same path can be closed and reopened while the Git context is + // being resolved. Keep the watcher tied to the opening that requested it. + let generation = workspaceGeneration let context = await gitWatchContextProvider.watchContext(for: workspaceURL) - guard self.workspaceURL == workspaceURL else { return } + guard self.workspaceURL == workspaceURL, + self.workspaceGeneration == generation else { return } let configuration = DirectoryWatchConfiguration( workspaceRoot: workspaceURL, gitContext: context