diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index ef34fe405..28f146c21 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -944,6 +944,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..98d976945 100644 --- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -51,6 +51,30 @@ 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 + // 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, + refreshDependencyMetadata: refreshDependencyMetadata + ) + } + } + func reset() { reloadTask?.cancel() reloadTask = nil 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 f1f5f3ef5..782b79199 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -3,6 +3,47 @@ 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 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 + case debug + case startConfiguration(RunConfiguration) + case runAllServices + case restart + } + + let kind: Kind + let identity: WorkspaceIdentity +} + +/// Result of bringing the run feature up to a specific opening's snapshot. +/// +/// 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(identity: WorkspaceIdentity) + case stale +} + @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -33,7 +74,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 @@ -56,7 +97,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 @@ -70,7 +111,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) } } } @@ -149,6 +190,30 @@ 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. 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 identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + 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 + } + } + func openRunConfiguration(relativePath: String?) { guard let workspaceURL else { return } let url = workspaceURL.appendingPathComponent(relativePath ?? ".lithe/run/generated.json") @@ -160,8 +225,195 @@ 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. + /// + /// `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 { + let target = workspaceURL.standardizedFileURL + // 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 + ) + springFeature.scheduleLoad( + workspaceURL: target, + files: files, + textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + ) + guard let execution = await activateExecutionModule() 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 + // newer scan's identity, which the readiness comparison cannot detect. + await execution.projectDevelopment.loadProject( + at: target, + files: files, + snapshotID: snapshotID + ) + guard isCurrentWorkspace(identity) else { return } + guard resumesDeferredRunAction else { return } + resumeDeferredRunAction( + execution.runFeature, + identity: identity, + snapshotID: snapshotID + ) + } + + /// 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 opening. + /// + /// 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 + /// 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 identity: WorkspaceIdentity + ) async -> RunProjectReadiness { + 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(identity: identity) + } + // No matching ready inventory: bind provisionally, or apply the + // published scan when one already exists. + await loadProjectServices( + at: target, + 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. + guard isCurrentWorkspace(identity) else { return .stale } + let current = workspaceFeature.appliedSnapshot + if runFeature.isProjectReady(for: target, snapshotID: current?.id) { + return .ready + } + 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 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, + identity: WorkspaceIdentity, + snapshotID: UUID? + ) { + guard let action = pendingRunAction else { return } + guard action.identity == identity else { return } + guard runFeature.isProjectReady(for: identity.url, snapshotID: snapshotID) else { + return + } + 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 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) + } + + 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 identity: WorkspaceIdentity) { + guard isCurrentWorkspace(identity) else { return } + setPendingRunAction(PendingRunAction(kind: kind, identity: identity)) + } + private func runSelectedConfigurationAfterActivation() async { + guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + 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: waitingIdentity) + return + case .stale: + return + } guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return @@ -174,6 +426,7 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(identity) else { return } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -186,6 +439,7 @@ extension AppModel { return } } + guard isCurrentWorkspace(identity) else { return } runFeature.runSelected(currentFileURL: activeDocument?.url) isRunVisible = true isGitLogVisible = false @@ -198,8 +452,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 identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + guard runFeature.lastConfiguration != nil else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.restart, for: waitingIdentity) + return + case .stale: + return + } guard let configuration = runFeature.lastConfiguration else { return } if !(await activateLanguageRunExtensionIfNeeded( for: configuration, @@ -208,33 +474,63 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(identity) else { return } runFeature.restart() } } 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 } + guard let self else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + 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 opening this task started for, not whatever is current + // after an await. + deferRunAction(.startConfiguration(configuration), for: waitingIdentity) + return + case .stale: + return + } + guard await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + ) else { return } + guard isCurrentWorkspace(identity) else { return } runFeature.startConfiguration(configuration) } } func runAllServiceConfigurations() { Task { [weak self] in - guard let self, - let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let self else { return } + guard let identity = currentWorkspaceIdentity else { return } + guard let runFeature = await activateExecutionModule()?.runFeature else { return } + guard isCurrentWorkspace(identity) else { return } + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.runAllServices, for: waitingIdentity) + return + case .stale: + return + } for configuration in runFeature.configurations where configuration.execution == .service { guard await activateLanguageRunExtensionIfNeeded( for: configuration, currentFileURL: nil, runFeature: runFeature ) else { return } + guard isCurrentWorkspace(identity) else { return } } runFeature.runAllServices() } @@ -311,7 +607,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() } @@ -329,9 +625,20 @@ extension AppModel { } private func startDebuggingAfterActivation() async { + guard let identity = currentWorkspaceIdentity else { return } guard let execution = await activateExecutionModule(), let debug = await activateDebugModule() else { return } + guard isCurrentWorkspace(identity) else { return } let runFeature = execution.runFeature + switch await ensureRunProjectReady(runFeature, for: identity) { + case .ready: + clearPendingRunAction(for: identity) + case .waitingForSnapshot(let waitingIdentity): + deferRunAction(.debug, for: waitingIdentity) + return + case .stale: + return + } let debugFeature = debug.javaFeature javaFeature.configureRuntime( mavenFeature: execution.mavenFeature, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 9c95a36db..860ae47b6 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -26,6 +26,11 @@ extension AppModel { } func activateExecutionModule() async -> ExecutionFeatureAccess? { + // Run and Debug activate on demand, so they can arrive while the previous + // session's module graph is still being torn down. Activating first would + // hand back a run feature that teardown releases moments later, and the + // deferred action waiting on it would never be resumed. + await awaitModuleRuntimeShutdown() if let mavenFeature = mavenFeatureIfActive, let runFeature = runFeatureIfActive, let tests = languageTestServiceIfActive, @@ -54,6 +59,7 @@ extension AppModel { } func activateDebugModule() async -> DebugFeatureAccess? { + await awaitModuleRuntimeShutdown() if let javaFeature = debugFeatureIfActive, let genericFeature = genericDebugFeatureIfActive { return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 8e0adffa2..0719d17b5 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -9,6 +9,10 @@ extension AppModel { var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } + /// 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 164c7bbf1..b1bbde899 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -140,6 +140,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? @@ -499,7 +502,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 } @@ -509,10 +512,17 @@ 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. - await self.loadProjectServices(at: workspaceURL, files: snapshot.files) + 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, + snapshotID: snapshot.id, + resumesDeferredRunAction: true + ) if isInitialLoad { self.projectHistoryFeatureIfActive?.seed(files: snapshot.files) } @@ -723,22 +733,39 @@ final class AppModel: ObservableObject, Identifiable { await shutdownModuleRuntime() } + /// Starts this session's module-graph teardown, or joins the one already in + /// flight, and records it before returning. + /// + /// Recording the task synchronously is what lets an on-demand activation + /// that follows a project switch see the teardown at all: the caller does not + /// block the switch on it, so an activation can otherwise start first and be + /// released moments later by this shutdown. + private func beginModuleRuntimeShutdown() { + guard moduleRuntimeShutdownTask == nil else { return } + let moduleRuntime = services.moduleRuntime + moduleRuntimeShutdownTask = Task { @MainActor [weak self] in + await moduleRuntime.shutdownAll() + guard let self else { return } + self.moduleRuntimeShutdownTask = nil + self.clearModuleBindings(for: .database) + } + } + /// Shuts down this session's module graph once, even when multiple /// lifecycle paths request cleanup at the same time. private func shutdownModuleRuntime() async { - if let moduleRuntimeShutdownTask { - await moduleRuntimeShutdownTask.value - return - } + beginModuleRuntimeShutdown() + await moduleRuntimeShutdownTask?.value + } - let moduleRuntime = services.moduleRuntime - let shutdownTask = Task { @MainActor in - await moduleRuntime.shutdownAll() + /// Lets an on-demand activation resume after a session teardown finishes. + /// + /// A shutdown that starts while this wait is suspended is joined as well, so + /// activation never returns a capability the runtime is about to release. + func awaitModuleRuntimeShutdown() async { + while let shutdownTask = moduleRuntimeShutdownTask { + await shutdownTask.value } - moduleRuntimeShutdownTask = shutdownTask - await shutdownTask.value - moduleRuntimeShutdownTask = nil - clearModuleBindings(for: .database) } private func reloadJavaRuntimeServices() { @@ -754,32 +781,11 @@ 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) } } } - /// Loads build-system and run state at the workspace boundary. The generic - /// run lifecycle is intentionally not owned by JavaFeatureModel. - func loadProjectServices(at workspaceURL: URL, files: [URL]) async { - let execution = await activateExecutionModule() - if let execution { - await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) - } - prepareJavaLanguageServerForWorkspaceIfNeeded( - at: workspaceURL, - files: files - ) - await springFeature.load( - workspaceURL: workspaceURL, - files: files, - textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { - ($0.url.standardizedFileURL, $0.text) - }) - ) - execution?.tests.discover(workspaceURL: workspaceURL, files: files) - } - var projectName: String { workspaceURL?.lastPathComponent ?? "Lithe" } @@ -922,10 +928,7 @@ final class AppModel: ObservableObject, Identifiable { func openProjectDirectly(_ url: URL) { let normalizedURL = url.standardizedFileURL - Task { [weak self] in - guard let self else { return } - await self.shutdownModuleRuntime() - } + beginModuleRuntimeShutdown() if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) } @@ -942,6 +945,8 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.openProject(at: normalizedURL) mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() @@ -976,11 +981,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 + } ) } } @@ -1007,10 +1019,7 @@ final class AppModel: ObservableObject, Identifiable { private func performCloseProject() { cancelJavaLanguageServerPreparation() - Task { [weak self] in - guard let self else { return } - await self.shutdownModuleRuntime() - } + beginModuleRuntimeShutdown() if let workspaceURL { workspaceFeature.persistWorkspaceSession(for: workspaceURL) } @@ -1049,6 +1058,8 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeature.closeProject() mavenFeatureIfActive?.reset() runFeatureIfActive?.reset() + pendingRunAction = nil + scheduleObjectWillChangeRelay() debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 994067603..d29d9bd1e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -52,6 +52,10 @@ final class MacServiceContainer { processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(), moduleLaunchMode: ModuleLaunchMode = .normal, moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, + workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil, + runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider providedGitWatchContextProvider: (any GitWatchContextProviding)? = nil, + runExecutableResolver providedRunExecutableResolver: (any RunExecutableResolving)? = nil, pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil, authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil ) { @@ -73,6 +77,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() @@ -301,11 +309,16 @@ final class MacServiceContainer { do { try moduleRegistry.register(ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) { ExecutionModule(makeGraph: { - let executableResolver = RunExecutableResolver( - runtimeService: runtimeService, - toolchainRegistry: runToolchainRegistry, - metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) - ) + // Toolchain discovery inspects installed JDKs through + // processes, so it is overridable for the same reason the + // other ports here are: a caller that does not exercise + // toolchain resolution should not depend on the machine. + let executableResolver: any RunExecutableResolving = providedRunExecutableResolver + ?? RunExecutableResolver( + runtimeService: runtimeService, + toolchainRegistry: runToolchainRegistry, + metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) + ) let graph = ExecutionFeatureGraph( maven: MavenService( runtimeService: runtimeService, @@ -320,7 +333,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 +408,7 @@ final class MacServiceContainer { processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) }, fileStorage: fileStorage, javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore + runConfigurationOperations: runConfigurationOperations ), adapterSessions: adapterSessions ) @@ -406,7 +419,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) @@ -418,11 +436,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 ) @@ -493,7 +511,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/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index e5aedac77..ec307fe23 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 .projectNotReady: + 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/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index d40a82f94..b454c483a 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1011,7 +1011,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 0b11b76d0..405fa1092 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -47,8 +47,63 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { } } +/// 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 + /// 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) + + /// 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 + } + + /// 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 { case idle + /// 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/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/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 481407875..444b04838 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -207,12 +207,33 @@ package final class RunFeatureModel: ObservableObject { service.clearOutput() } + package var projectLoadState: ProjectLoadState { service.projectLoadState } + + package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { + service.isProjectReady(for: workspace, snapshotID: snapshotID) + } + + package func hasReadyInventory(for workspace: URL) -> Bool { + service.hasReadyInventory(for: workspace) + } + + package func reportGenerationProjectNotReady() { + isGenerationConfirmationPresented = false + service.reportGenerationProjectNotReady() + } + 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 { @@ -257,7 +278,11 @@ package final class ProjectDevelopmentFeatureModel { self.runFeature = runFeature } - package func loadProject(at workspaceURL: URL, files: [URL]) async { + 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 { // 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. @@ -272,7 +297,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 b603ec2ba..6374126ce 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 = "" @@ -106,6 +107,30 @@ package final class RunService: ObservableObject { package var lastRunFileURL: URL? { lastCurrentFileURL } package var lastConfiguration: RunConfiguration? { lastRunConfiguration } + /// 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, snapshotID: UUID?) -> Bool { + 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 + /// snapshot: the service may still hold an older `.ready` inventory, and + /// calling `generateRunConfigurations` would scan that stale list. + package func reportGenerationProjectNotReady() { + generationState = .projectNotReady + } + package func configureMavenContextProvider( _ provider: @escaping @MainActor () -> MavenLaunchContext? ) { @@ -139,14 +164,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 @@ -162,7 +195,14 @@ package final class RunService: ObservableObject { if let currentProject = self.projectURL { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } - self.projectURL = projectURL.standardizedFileURL + 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) self.mavenProject = mavenProject mavenProfiles = mavenProject?.profiles ?? [] self.projectFiles = files @@ -206,7 +246,14 @@ package final class RunService: ObservableObject { } package func generateRunConfigurations() async { - guard let projectURL else { return } + // 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, case .ready = projectLoadState else { + generationState = .projectNotReady + return + } let loadID = projectLoadID isLoadingProject = true defer { @@ -598,6 +645,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..6aca435ba 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -12,7 +12,21 @@ package enum WorkspaceRebuildResult: Sendable { @MainActor package final class WorkspaceFeatureModel: ObservableObject { @Published package private(set) var rootNode: FileNode? - @Published package private(set) var projectFiles: [URL] = [] + /// 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 ?? [] } + + /// 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? @@ -60,7 +74,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)? @@ -96,7 +110,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 @@ -161,6 +175,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } package func reset() { + workspaceGeneration += 1 if let workspaceURL { scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules) } @@ -183,7 +198,7 @@ package final class WorkspaceFeatureModel: ObservableObject { workspaceURL = nil hasRestoredWorkspaceSession = false rootNode = nil - projectFiles = [] + appliedSnapshot = nil isLoadingWorkspace = false isRefreshingWorkspace = false loadErrorMessage = nil @@ -202,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 @@ -293,7 +309,7 @@ package final class WorkspaceFeatureModel: ObservableObject { } loadErrorMessage = nil rootNode = snapshot.root - projectFiles = snapshot.files + appliedSnapshot = snapshot scheduleSearchIndexWarm(at: workspaceURL, rules: rules) // The tree is usable as soon as the shared snapshot is ready. Service @@ -304,14 +320,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() @@ -324,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 + } ) } @@ -594,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 @@ -827,8 +862,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 bc6af7cb7..15d0943a0 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -47,6 +47,148 @@ 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.projectLoadState == .idle) + await service.generateRunConfigurations() + + #expect(service.generationState == .projectNotReady) + #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 == .projectNotReady) + + // 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, snapshotID: UUID())) + 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)) + #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) + #expect(service.generationState == .succeeded(entryCount: 1)) + #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" + ) + } + + /// 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() @@ -650,6 +792,63 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} } +/// 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 generatedInventories: [[URL]] = [] + + var generateCallCount: Int { generatedInventories.count } + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection( + status: generatedInventories.isEmpty ? .missing : .ready, + diagnostics: [] + ) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + generatedInventories.append(files) + 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 {} +} + +/// 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/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 cde850bb9..9819c8583 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3492,7 +3492,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: {}, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/retry-workspace") @@ -3546,7 +3546,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) @@ -3560,6 +3560,151 @@ 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") + } + + /// 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 { @@ -3705,7 +3850,7 @@ struct EditorDocumentTests { reloadProjectServices: {}, refreshGit: { refreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) let workspace = URL(fileURLWithPath: "/tmp/frozen-workspace") @@ -4328,7 +4473,7 @@ private func makeWorkspaceObservationUnitModel( reloadProjectServices: reloadProjectServices, refreshGit: refreshGit, updateHistoryVisibilityRules: { _ in }, - onSnapshotLoaded: { _, _ in } + onSnapshotLoaded: { _, _, _ in } ) return model } 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/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 1e09f1661..713512988 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -3089,10 +3089,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() @@ -3680,10 +3683,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 new file mode 100644 index 000000000..c21f24042 --- /dev/null +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -0,0 +1,1068 @@ +import Foundation +import Testing +@testable import Lithe + +// Each test here opens a real workspace and builds a full service container, and +// several of them coordinate on gates. Run in parallel they contend hard enough +// to stretch their own durations by two orders of magnitude and to push other +// suites past their deadlines, so this suite takes one test at a time. +@Suite("Run entry points", .serialized) +@MainActor +struct RunEntryPointTests { + /// 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. + /// + /// 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 runBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = SequencedWorkspaceOperations.unavailableThenReady(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 awaitLoadDrivenChange(on: model) { + 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) + + let runFeature = try #require(model.runFeatureIfActive) + await runFeature.generateRunConfigurations() + #expect(runFeature.generationState == .projectNotReady) + #expect(!workspace.hasGeneratedConfiguration, "a partial inventory must not be written") + + await model.workspaceFeature.refreshCurrent() + + 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") + // 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. + } + + /// 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 = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run } + #expect(deferred, "Run must be deferred while the inventory is provisional") + + await model.workspaceFeature.refreshCurrent() + + 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 awaitLoadDrivenChange(on: model) { model.pendingRunAction == nil } + #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 = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runSelectedConfiguration() + + 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( + 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" + ) + + await model.workspaceFeature.refreshCurrent() + + // 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 runConfigurations.launchPlanRequested(1) + #expect(relaunched, "the deferred Run was never actually re-issued") + #expect(model.pendingRunAction == nil) + #expect( + runFeature.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) + ) + } + + /// 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 = SequencedWorkspaceOperations.unavailableThenReady(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 before any scan succeeded, 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. + // The refresh cannot be awaited here: the load it drives suspends on the + // inspection this test releases further down. + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + #expect( + await runConfigurations.inspectionEntered(2), + "the snapshot-driven load never started" + ) + runConfigurations.release(2) + let ready = await awaitLoadDrivenChange(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 awaitLoadDrivenChange(on: model) { + model.runFeatureIfActive?.isLoadingProject == false + && runConfigurations.resolveCallCount == 1 + } + #expect(snapshotLoadFinished, "the snapshot-driven load never finished") + + runConfigurations.release(1) + + let relaunched = await runConfigurations.launchPlanRequested(1) + #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed") + #expect(model.pendingRunAction == nil) + _ = await refreshTask.value + } + + /// Debugging reaches the same run feature through its own entry point. + @Test + func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + let operations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot) + let model = makeAppModel(workspaceOperations: operations) + + model.openProjectDirectly(workspace.root) + model.startDebugging() + + let bound = await awaitLoadDrivenChange(on: model) { + 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) + + await model.workspaceFeature.refreshCurrent() + + 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") + } + + /// Opening a project normally must reach the same ready state without any + /// entry point, so the tool-window path keeps working. + @Test + func openingAProjectMakesTheRunProjectReadyOnItsOwn() async throws { + let workspace = try JavaWorkspaceFixture() + defer { workspace.remove() } + // The scan succeeds right away here, which is the ordinary case this test + // protects: no entry point is involved. + let operations = SequencedWorkspaceOperations(snapshots: [workspace.snapshot]) + let model = makeAppModel(workspaceOperations: operations) + + model.openProjectDirectly(workspace.root) + + let ready = await awaitLoadDrivenChange(on: model) { + model.runFeatureIfActive?.isProjectReady( + for: workspace.root, + snapshotID: model.workspaceSnapshotID + ) == true + } + #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 = SequencedWorkspaceOperations.unavailableThenReady(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 awaitLoadDrivenChange(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" + ) + + await model.workspaceFeature.refreshCurrent() + + let relaunched = await runConfigurations.launchPlanRequested(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 = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot) + let runConfigurations = ReadyRunConfigurationOperations() + let model = makeAppModel( + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurations + ) + + model.openProjectDirectly(workspace.root) + model.runAllServiceConfigurations() + + let deferred = await awaitLoadDrivenChange(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" + ) + + await model.workspaceFeature.refreshCurrent() + + let relaunched = await runConfigurations.launchPlanRequested(1) + #expect(relaunched, "the deferred run-all-services was never actually re-issued") + #expect(model.pendingRunAction == nil) + } + + /// 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 = SequencedWorkspaceOperations(snapshots: [nil, 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") + + // Each refresh is held at its watch-configuration fetch, so it runs as a + // task the test releases; a refresh must also finish before the next one + // starts, or the workspace feature would drop it as already refreshing. + let firstRefresh = Task { await model.workspaceFeature.refreshCurrent() } + #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) + _ = await firstRefresh.value + + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + #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() + } + + // Neither project ever gets an inventory, so both direct starts take the + // pre-snapshot path and the only coordination point is the inspection. + let workspaceOperations = SequencedWorkspaceOperations.neverScans() + 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") + + // B has no inventory either, so its 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?.identity.url == 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?.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 = SequencedWorkspaceOperations.unavailableThenReady(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. + @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 = SequencedWorkspaceOperations(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) + #expect(await watchContext.entered(1)) + watchContext.release(1) + #expect(await runConfigurations.inspectionEntered(1)) + runConfigurations.release(1) + + let readyForFirst = await awaitLoadDrivenChange(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) + let refreshTask = Task { await model.workspaceFeature.refreshCurrent() } + #expect(await watchContext.entered(2)) + 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" + ) + + await model.generateRunConfigurations() + #expect(runFeature.generationState == .projectNotReady) + #expect( + runConfigurations.generatedInventories.isEmpty, + "generation must not scan the superseded inventory" + ) + + watchContext.release(2) + #expect(await runConfigurations.inspectionEntered(2)) + runConfigurations.release(2) + _ = await refreshTask.value + } + + private func makeAppModel( + workspaceOperations: any WorkspaceOperations, + runConfigurationOperations: (any RunConfigurationOperations)? = nil, + gitWatchContextProvider: (any GitWatchContextProviding)? = nil + ) -> AppModel { + let store = RunEntryPointTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, + settings: settings, + workspaceOperations: workspaceOperations, + runConfigurationOperations: runConfigurationOperations, + gitWatchContextProvider: gitWatchContextProvider, + // These tests assert load ordering, never toolchain selection. The + // real resolver inspects installed JDKs through processes, which + // makes every test here depend on the machine and serialize behind + // that discovery. + runExecutableResolver: StubRunExecutableResolver() + ).services + return AppModel(settings: settings, services: services) + } +} + +/// 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 +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) + } +} + +/// Answers toolchain questions without inspecting the machine. +/// +/// Launching is out of scope for this suite, so resolution never has to succeed; +/// the protocol's default candidate and refresh behavior is what keeps real JDK +/// discovery out of these tests. +private final class StubRunExecutableResolver: RunExecutableResolving { + func resolve( + _ plan: SharedLaunchPlan, + projectURL: URL, + options: RunOptions + ) throws -> ResolvedRunExecutable { + throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test") + } +} + +/// Hands out prepared scans in call order, and reports the folder as unreadable +/// once they run out. +/// +/// Reporting "no snapshot" is how these tests reach the pre-snapshot entry path. +/// Suspending the scan instead would model a scan in flight, but the workspace +/// feature scans from a detached task, so a held scan occupies a thread of the +/// cooperative pool for the whole test and starves every other suite running in +/// parallel. The tests coordinate on the run service's inspection and on the +/// watch-configuration fetch instead, both of which suspend without a thread. +private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private let snapshots: [WorkspaceSnapshot?] + private var scanCount = 0 + + init(snapshots: [WorkspaceSnapshot?]) { + self.snapshots = snapshots + } + + /// No scan ever succeeds, so every opening stays before its inventory. + static func neverScans() -> SequencedWorkspaceOperations { + SequencedWorkspaceOperations(snapshots: []) + } + + /// The first scan finds nothing and later scans succeed, which is what a test + /// uses to publish an inventory on demand through `refreshCurrent()`. + static func unavailableThenReady(_ snapshot: WorkspaceSnapshot) -> SequencedWorkspaceOperations { + SequencedWorkspaceOperations(snapshots: [nil, snapshot, snapshot, snapshot]) + } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + let ordinal = scanCount + scanCount += 1 + lock.unlock() + guard ordinal < snapshots.count else { return nil } + return snapshots[ordinal] + } + + 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: []) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + 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" + ) + + /// 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() + ), + EffectiveRunConfiguration( + configuration: Self.serviceEntryPoint, + 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 + let ordinal = launchPlanCalls + lock.unlock() + launchPlanRequests[ordinal - 1].open() + 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 launchPlanRequests: [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 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() } + return resolveCalls + } + + /// Waits for the `ordinal`-th inspection (1-based) to reach its gate. + func inspectionEntered(_ ordinal: Int) async -> 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: ReadyRunConfigurationOperations.entryPoint.id + ) + } + + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) 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") + } + + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + 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] = [:] + + 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 48d557610..ca8f5a828 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -99,17 +99,111 @@ 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. + @Test + func scheduleLoadDefersIndexingAndPublishesTheResult() async throws { + let root = URL(fileURLWithPath: "/workspace") + let beanURL = root.appendingPathComponent("Service.java") + 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) + #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 pending one so a burst of reloads cannot + /// publish a stale index. + @Test + func scheduleLoadReplacesAPendingSchedule() async throws { + let root = URL(fileURLWithPath: "/workspace") + 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: [staleURL]) + feature.scheduleLoad(workspaceURL: root, files: [freshURL]) + + 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"]) + } +} + +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: [] + ) } -private struct SpringTestOperations: JavaMavenOperations { - let result: SpringIndexResult +private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable { + private let makeResult: @Sendable ([URL]) -> SpringIndexResult + private let lock = NSLock() + private var requested: [[URL]] = [] + + /// 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 requested + } + + init(result: SpringIndexResult) { + makeResult = { _ in result } + } + + init(resultForFiles: @escaping @Sendable ([URL]) -> SpringIndexResult) { + makeResult = resultForFiles + } func springIndex( at rootURL: URL, files: [URL], textOverrides: [URL: String], refreshDependencyMetadata: Bool - ) -> SpringIndexResult? { result } + ) -> SpringIndexResult? { + lock.lock() + requested.append(files) + lock.unlock() + return makeResult(files) + } 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] { [] }