fix(macos): 运行配置加载不再等待 Spring 索引 - #305
Conversation
Opening a large multi-module Maven workspace left Run and Debug unusable for as long as the Spring index took, because loadProjectServices awaited it before loading build-system and run state. Schedule the index instead so run configurations, test discovery, and the Git refresh that follows no longer depend on its duration. Identifying the project during that window was dropped silently, which is indistinguishable from a broken button. Report the unloaded workspace as an explicit state, and let the Run and Debug entry points load the project on demand the way the tool-window entry points already do. Refs 1lck#300
CI 的测试稳定性门禁拒绝了原先的两个 scheduleLoad 测试:轮询里用了真实 Task.sleep,测试替身里用了无界的 DispatchSemaphore.wait()。改成由 actor 模型 保证的同步断言,加上以 objectWillChange 为事件源、带本地超时的等待。 重写后的测试暴露出一个真实缺陷:Swift 的取消是协作式的,reloadTask.cancel() 并不会阻止任务体运行,而 load 里没有取消检查点,所以被取代的那次调度仍然会跑 完一次全量工作区索引,只是结果被 generation 令牌丢弃。为 scheduleLoad 补上 Task.isCancelled 检查,与既有的 scheduleReload 保持一致。 Refs 1lck#300
之前只靠编译期类型检查和手动验证,没有测试覆盖这两个入口。 新增的三个测试用一个不可读的工作区让快照返回 unavailable,从而跳过 onSnapshotLoaded,把入口变成唯一能绑定项目的路径。把 AppModel+Development 里 两处 loadProjectServicesIfRunProjectIsUnbound 调用去掉后,Run 与 Debug 两个 测试会失败,而覆盖既有行为的工具窗口测试仍然通过,说明它们确实能抓到回归。 把 objectWillChange 加本地超时的等待器提成 ObservableChangeWaiter,供 SpringFeatureModelTests 与新测试共用,超时收到 5 秒以留出计时预算。 Refs 1lck#300
1lck
left a comment
There was a problem hiding this comment.
整体修复方向正确:Spring 索引与运行配置加载解耦符合问题根因,Execution module、Core contracts、视图与平台适配器的边界也保持清晰。
但冷启动入口目前会把“URL 已绑定”当成“工作区快照已就绪”,在 projectFiles 仍为空时允许生成配置,存在写出不完整 generated.json 的数据完整性风险,因此建议修复阻塞批注后再合并。
非阻塞建议已分别标注:将项目加载建模为携带 workspace/snapshot 身份的显式状态;为不可取消的 Spring 全量索引增加 single-flight/latest-pending 语义;同时建议在项目加载与生成路径记录 workspace、文件数量、load/generation ID 和状态转换原因,降低后续竞态问题的排查难度。项目加载编排长期可收口到 ProjectDevelopmentFeatureModel,避免 AppModel 根据服务内部布尔值承担业务时序。
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
1lck
commented
Aug 28, 2026
@Farewell0375 你好 以上是一些pr阻塞点 麻烦处理一下哦 如果您有一些其他好的方案也可以在此PR下与我讨论 |
评审指出冷启动入口把"URL 已绑定"当成了"工作区快照已就绪"。快照未完成时 projectFiles 为空,入口仍会绑定 projectURL 并允许确认"识别并生成",而 runConfig.generate 只扫描传入的 paths,于是可能写出缺少 Java 入口的 .lithe/run/generated.json。 把两件事显式分开。新增 ProjectLoadState(idle / loading / bound / ready / failed):bound 表示已绑定工作区但清单是临时的,只允许读取既有配置;ready 携带 工作区与快照标识,才允许生成。快照标识由 WorkspaceFeatureModel 拥有——它是唯一 应用快照的地方——经 AppModel 与 ProjectDevelopmentFeatureModel 传到 RunService。 snapshotID 缺省为 nil,因此忘记传递的调用方会退到 bound 而不是误判为就绪。 generateRunConfigurations 现在要求当前工作区处于 ready,否则报 projectNotReady(原 projectNotLoaded,改名以覆盖"已绑定但清单不完整")。 MacServiceContainer 增加 workspaceOperations 注入缝,与既有的 moduleStore 等 可选覆盖一致,使 AppModel 层能用可控快照做测试。Search 与本地历史仍使用具体的 RustWorkspaceOperations,它们依赖 WorkspaceOperations 之外的能力。 测试改为断言数据完整性而非仅仅"已绑定":入口测试用受控快照,先阻塞、按下 Run、 断言未就绪且生成被拒且未写出配置,再放行含真实 Java 文件的快照并断言就绪; ExecutionModuleTests 断言生成实际扫描的清单恰好是快照上报的那份。去掉 RunService 的就绪守卫后,后者会直接暴露 generatedInventories == [[]]。 Refs 1lck#300
Farewell0375
commented
Aug 29, 2026
@1lck 感谢评审,阻塞项和测试覆盖都已处理, 阻塞项确认成立,是我引入的。 冷启动入口把"URL 已绑定"当成"快照已就绪",快照未完成时 修法:新增 测试改为断言数据完整性。 入口测试改用受控的 反向验证:一次性 worktree 里去掉就绪守卫后, 为测试新增的注入缝: 验证: 两条非阻塞建议我在批注下各自回了想法: |
1lck
left a comment
There was a problem hiding this comment.
整体方向没问题,之前的初始空清单问题也已经修了。现在还剩 3 个点:一个会让仓库门禁失败,另外两个是新旧快照和提前执行的时序问题。建议修完再合并,细节见行内批注。
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
处理评审的三点。 一、AppModel.swift 与 preview 合并后达到 1806 行,超过 verify-service-boundaries 的 1800 上限。把加载编排移到 AppModel+Development.swift,现为 1781 行。上一轮 本地验证没有先合并 base,所以漏掉了这个失败。 二、snapshotID 放进了 .ready 却没参与比较。同一工作区刷新时, WorkspaceFeatureModel 在发布新快照与调用 onSnapshotLoaded 之间隔着两个 await, 这段窗口里 RunService 仍持有上一份清单却报就绪,重新识别会漏掉新增的入口。 isReady 现在同时比较工作区与快照标识,snapshotID 为 nil 时一律视为未就绪。 三、加载可能只到 .bound,而运行入口只看 configurationStatus。磁盘上已有配置时 会在完整文件列表与 Maven 信息就绪前直接启动,工具链解析因此缺少 Maven 项目。 运行与调试入口改为要求就绪,未就绪时记下 pendingRunAction 并返回;工作区重建 必然以 loadProjectServices 收尾,由它恢复这次操作,因此不需要轮询或等待。 识别现在统一走 AppModel.generateRunConfigurations,先把服务提升到当前快照再执行。 这是 RunService 内部仍可用自身状态判断的前提。 Refs 1lck#300
上一版的入口测试只证明了推迟与恢复,没能覆盖评审要的"已有配置 + 快照阻塞" 子场景:真实存储的 inspect 要经 Rust Core,而 Swift 测试二进制不链接它 (bridge.c 提供 weak 桩,isAvailable 为 false),所以 configurationStatus 无法在测试里变成 ready。 给 MacServiceContainer 增加 runConfigurationOperations 可选覆盖,与已有的 workspaceOperations、moduleStore 等注入缝同类。测试用一个直接报 ready 的替身 构造该前提,并记录 launchPlan 请求次数:快照阻塞期间断言运行被推迟、 configurationStatus 确为 ready、launchPlan 从未被请求;放行快照后断言恢复执行 且就绪。 未选择改为链接 Rust 的真实测试:CI 中没有任何任务在链接 Rust 的情况下执行 LitheTests(verify-rust-core.sh 只跑 cargo test、swift build 与独立的桥接验证 程序),因此这样的测试会永远跳过。仓库中已有 9 处依赖 isAvailable 的跳过测试, 不宜再添。 Refs 1lck#300
Farewell0375
commented
Aug 29, 2026
@1lck 三条都已处理, 门禁那条是我的验证流程漏洞。 上一轮本地跑门禁是通过的(1799 行,上限 1800),但分支处于
未就绪时运行改为推迟而非降级执行。 按你说的记录待执行动作,由快照驱动的加载恢复,不在生产路径上放等待。 测试覆盖了完整场景,但代价是第二个注入缝。 Swift 测试二进制不链接 Rust Core( 每一条我都做了反向验证:去掉对应守卫后新测试失败、其余通过。 验证: 上一轮提到的 Spring single-flight 与结构化日志两条非阻塞建议仍建议在 #299 合并后单开 issue,理由写在那条批注下。 |
避免 await 前后快照错配,以及快照已消费后入口仍用旧捕获 defer 导致 Run 永久挂起。
Farewell0375
commented
Aug 29, 2026
@1lck 最后两条未闭环项已处理,
恢复启动断言补全。 已有配置 + 快照阻塞的测试改为等待并断言 自查多修一处竞态。 入口自己的 provisional load 还在 另外:配置解析失败与 inventory 就绪继续分开( 验证: |
| // The applied snapshot is the event a deferred Run waits for, and | ||
| // it carries its own identity so the pair cannot drift. | ||
| await self.loadProjectServices( | ||
| at: workspaceURL, |
There was a problem hiding this comment.
【阻塞 / P1】这里仍把 snapshot.files + snapshot.id 与回调执行时重新读取的 self.workspaceURL 组合,三者并非同源。WorkspaceFeatureModel.rebuild 在通过一次 isCurrent 检查并发布快照后,还会 await restoreSession、await updateWatchConfiguration,之后才调用 onSnapshotLoaded。如果这期间从项目 A 切换到 B,A 的旧回调仍可能继续,此处读到的却是 B,于是会把 A.files + A.id 按 B 加载;resumesDeferredRunAction: true 还可能恢复 B 的 pending Run,最终用 A 的清单启动 B。请让 workspace 身份与 snapshot 一起传递,并在回调前再次拒绝 stale workspace;同时补一个“旧项目快照发布后、回调前切换项目”的竞态测试。
There was a problem hiding this comment.
成立,已修,fa807e1c。
onSnapshotLoaded 现在接收 rebuild 传入的 workspaceURL,不再回读 self.workspaceURL。rebuild 在 restoreSession / updateWatchConfiguration / 回调前都会再次 isCurrent(),过期直接 .stale 且不交付回调,因此 A 的清单不会按 B 加载,也不会用 resumesDeferredRunAction 恢复 B 的 pending。
由 rebuildRejectsStaleWorkspaceBeforeSnapshotCallback 锁住:在 restore 挂起期间翻转 isCurrent 后,断言回调计数为 0。去掉这些守卫后该测试失败。
There was a problem hiding this comment.
“回调开始前切换 workspace”这一段已经修好,新增测试也通过;但 workspace 身份还没有贯穿入口任务本身。可稳定复现:在 A 调用 startRunConfiguration,让 ensureRunProjectReady 挂在 inspect;切到 B(这里会清空 pending),再放行 A 的 inspect。旧任务返回 false 后,deferRunAction 会重新读取当前的 self.workspaceURL,于是把 A 的具体 configuration 记成了 B 的 pending;B 快照到达后就可能按 B 恢复 A 的配置。我加的反向测试 directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace 在当前 head 稳定失败。建议入口开始时一次捕获 workspace 身份,并让 readiness 结果区分“当前 workspace 等快照”和“任务已 stale”;后者直接丢弃,不能再用全局当前 URL defer。另外 stale A 的回调也不应清掉属于当前 B 的 pending,最好用同一个 token/current guard 覆盖每个 await 后的恢复点。
There was a problem hiding this comment.
成立,已修,82169547。
工作区身份现在贯穿入口任务本身:
- 每个入口(Run / Debug / 直接启动 / 批量服务 / Restart / 识别)在任何 await 之前一次性捕获
workspaceURL?.standardizedFileURL,之后不再回读全局当前 URL。 - readiness 结果按你的建议分成三种:
RunProjectReadiness.ready/.waitingForSnapshot(workspace:)/.stale。.stale直接丢弃,不 defer;.waitingForSnapshot携带任务自己捕获的 workspace,deferRunAction(_:for:)只按它登记并再次校验 current。 - stale A 的回调不再动 B 的 pending:
resumeDeferredRunAction在action.workspace与本次加载的 workspace 不一致时只 return,不清空;clearPendingRunAction(for:)同样只清属于本任务 workspace 的那一条。 - 每个 await 后的恢复点都加了 current 守卫,包括
loadProjectServices内部:模块激活之后、loadProject之后各校验一次,避免把 A 的清单写进 B 的 run 服务。
回归测试用你给的名字和场景 directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace:A 上 startRunConfiguration 挂在 inspect → 切到 B(断言 pending 被清空)→ B 自己 defer 出 .startConfiguration(B) → 放行 A 的 inspect,断言 A 的配置没有被登记成 B 的 pending,且 B 的 pending 存活。为「不该发生」的那一步单独用了 1s 短期限,避免用长等待掩盖问题。
一处选型说明:守卫用的是 workspace URL 身份而不是自增 token。差别只在「关闭后重开同一路径」这一种情况——在途任务仍会被判为 current,动作会在重开后按同一工程恢复,不会跨工程写错清单。如果你倾向彻底闭合这个窗口,我可以再加一个世代计数。
There was a problem hiding this comment.
这轮跨不同 workspace 的修复和测试我复核通过;你最后提到的“关闭/重开同一路径”窗口需要一并闭合,建议采用 generation/token,URL 身份还不够。已稳定复现:同一路径第一次打开后让 startRunConfiguration(A) 挂在 inspect;再次 openProjectDirectly 同一路径并让 startRunConfiguration(B) 成为当前 pending;随后放行第一次打开的 inspect。因为两个任务捕获的 URL 相等,旧任务仍被判 current,最终把 B 的 pending 覆盖成 A。反向测试 directStartFromAnEarlierOpeningOfTheSameWorkspaceIsDiscarded 当前稳定失败。这个 token 也应进入 workspaceFeature.rebuild(... isCurrent:) 的判定,而不只是 Run 入口:否则同路径重开时旧 rebuild 也可能在 reset 后重新发布旧 snapshot。请让每次 open/close 推进 workspace generation,并让 rebuild、入口任务及各 await 后的 current guard 同时比较 URL + generation;补上上述同路径重开测试。这样 workspace 身份才真正闭环。
Uh oh!
There was an error while loading. Please reload this page.
1lck
commented
Aug 29, 2026
@Farewell0375 你好,感谢前面几轮认真处理评审意见。目前还有 3 处与工作区快照时序相关的阻塞点,细节和复现场景已经分别写在对应行内批注中:
麻烦有空时一起修复并补上对应的竞态/入口回归测试。处理完成后请在 PR 下艾特 @1lck 复审即可,谢谢! |
让 rebuild 在挂起点后再次拒绝过期 workspace,并把 workspace 与 snapshot 一并传给 onSnapshotLoaded;ensure 失败时停止生成;直接启动与批量服务入口统一走 readiness 守卫并保留 pending 意图。
Farewell0375
commented
Aug 29, 2026
@1lck 三条阻塞项都已处理, 1. workspace 与 snapshot 同源。 2. 3. 启动入口收口。 测试(均做过反向验证):
验证: 请复审,谢谢。 |
1lck
left a comment
There was a problem hiding this comment.
复审 fa807e1c:作者本轮新增的 3 项主体修复和 9 条 RunEntryPointTests 均已复核,其中“快照文件与 ID 同源”和“readiness 为 false 时禁止生成旧 inventory”两条已关闭;直接启动与批量服务的 defer/resume 也通过。边界、共享契约、模块边界和测试稳定性校验全部通过。当前仍有两个同一状态机内的阻塞缺口,已续写在原讨论中:一是 Restart 仍绕过 readiness;二是入口任务跨 workspace await 后会把 A 的动作重新登记为 B 的 pending。两条都用反向测试稳定复现,因此本轮暂时维持 changes requested。修好并补回归测试后请再 @1lck 复审。
Restart 走 ensureRunProjectReady/pending 恢复;入口捕获 workspace 区分 stale 与 waiting,避免旧任务 defer 到新工程或清掉对方 pending。
Farewell0375
commented
Aug 29, 2026
@1lck 本轮两个阻塞缺口已修完, 1. Restart 绕过 readiness 2. 入口任务跨 workspace 身份 测试稳定性 验证
一处待你定调:current 守卫用 workspace URL 身份而非自增 token,残留窗口只有「关闭后重开同一路径」,后果是在途动作按同一工程恢复,不跨工程写错清单。需要彻底闭合我再加世代计数。 |
1lck
left a comment
There was a problem hiding this comment.
复审 82169547:Restart readiness 与跨不同 workspace 的 stale/pending 保护均已验证通过,对应 Restart 讨论已关闭;作者新增的 11 条入口测试本地全部通过,边界/契约/模块/测试稳定性校验也通过。当前只剩 1 条阻塞:URL 无法区分同一路径的不同打开世代,旧会话任务可覆盖新会话 pending,且旧 rebuild 也可能在 reset 后发布快照;已用反向测试稳定复现并续写到原 workspace 讨论,建议用 URL + generation/token 一次性闭环。GitHub Swift tests 的红灯来自未改动的 JDTLS 指纹测试达到 15 秒性能阈值,不是本轮入口测试失败;后续新提交会重新触发 CI。修复 generation/token 并补同路径重开回归测试后请再 @1lck。
修复 #300。
问题
打开大型多模块 Maven 项目后,Run 面板长时间显示"未找到项目运行配置",即使
.lithe/run/generated.json已存在且完全有效。这段时间内点击"识别并生成"并确认后没有任何反应——没有错误、没有进度、没有日志。在一个 9765 个 Java 文件的项目上,这个窗口约 11 分钟。
原因
两处。
一、
loadProjectServices把 Spring 索引串在运行配置加载之前。await springFeature.load(...)排在projectDevelopment.loadProject(...)前面,而后者才负责把configurationStatus置为.ready并给RunService.projectURL赋值。Spring 索引的耗时随 Java 源文件数量增长(见 #299),运行配置、测试发现,以及WorkspaceFeatureModel.rebuild在回调之后的那次 Git 刷新,全都被压在它后面。需要说明的是,即使 Spring 索引很快,这个串行耦合本身仍然是缺陷——它只是把不可用窗口从分钟级缩短到秒级,窗口依然存在。
二、
RunService.generateRunConfigurations()在项目未加载时静默返回。reset()在打开项目时把projectURL置为 nil。窗口期内点击识别会命中这个 guard 直接返回,UI 无任何状态变化,用户无法区分"操作被丢弃"和"操作失败"。改动
把 Spring 索引改为调度而非等待。
SpringFeatureModel新增scheduleLoad,复用它已有的reloadTask取消机制和generation令牌(reset()本来就会 cancel 它)。任务管理留在状态所在的层,AppModel里不出现裸 Task。用显式状态取代静默返回。
RunConfigurationGenerationState新增.projectNotLoaded,RunView渲染为"项目仍在加载中"。没有复用.failed,因为什么都没有失败——那会让标题显示成"项目识别失败"。这个状态不跨 Rust 边界,是纯 macOS 应用层的瞬时状态,不需要新增 shared fixture。补齐入口一致性。
toggleRun、toggleMaven、toggleDebug在激活执行模块后都会按需加载项目,但runSelectedConfigurationAfterActivation和startDebuggingAfterActivation不会,导致"打开项目后第一个动作就是点运行按钮"这条路径会冷激活出一个没人给它加载项目的RunService。新增RunService.isProjectLoaded,让这两个入口和工具窗口入口行为一致。没有采用"让
activateExecutionModule()自己负责加载项目"的方案:loadProjectServices本身就会调它,会形成递归,需要额外的防重入标志,风险大于收益。验证
新增 4 个测试:
identificationBeforeProjectLoadReportsUnloadedProjectWithoutGenerating.projectNotLoadedidentificationAfterProjectLoadGeneratesAndClearsTheUnloadedStatescheduleLoadDefersIndexingAndPublishesTheResultscheduleLoadReplacesAPendingSchedulerunningBeforeTheSnapshotLoadsBindsTheProjectdebuggingBeforeTheSnapshotLoadsBindsTheProjectopeningTheRunToolWindowBindsTheProject后两个
scheduleLoad测试用objectWillChange加本地超时的等待器,不依赖固定 sleep;调度未发布前的断言由 actor 模型保证,无需闸门。入口测试用一个不可读的工作区让快照返回
unavailable,从而跳过onSnapshotLoaded,把入口变成唯一能绑定项目的路径。去掉AppModel+Development.swift里两处loadProjectServicesIfRunProjectIsUnbound调用后,Run 与 Debug 两个测试失败、工具窗口测试仍通过,确认它们能抓到回归。在报告问题的那个 9765 文件项目上做过手动验证:运行配置在项目树出现后几秒内列出全部 27 条,此时 Spring 面板仍在索引中。
已知限制
已经开始执行的 Rust
spring.index调用无法中断。 该命令没有取消点,所以一次已经进入load的索引会跑完。scheduleLoad现在会在任务体开头检查Task.isCancelled,因此尚未开始的调度不再重复执行全量索引;这一点由scheduleLoadReplacesAPendingSchedule覆盖。降低单次索引本身的成本属于 #299 的范围,本 PR 不涉及rust/lithe-core。