Skip to content

Fix: 删除实例时先落盘待写入的设置,避免实例目录被重建 - #6597

Closed
dwgx wants to merge 1 commit into
HMCL-dev:mainfrom
dwgx:fix/instance-removal-resurrect
Closed

Fix: 删除实例时先落盘待写入的设置,避免实例目录被重建#6597
dwgx wants to merge 1 commit into
HMCL-dev:mainfrom
dwgx:fix/instance-removal-resurrect

Conversation

@dwgx

@dwgx dwgx commented Aug 3, 2026

Copy link
Copy Markdown

问题是什么

删除一个游戏实例后,versions/ 目录下可能会残留一个只含 HMCL 元数据的空壳目录,而且这个实例已被删除的设置会被恢复回来。

如果被恢复的设置里带有「独立游戏目录」(PROPERTY_RUNNING_DIRECTORY)覆盖项,那么 getRunDirectory 会继续返回实例目录,而不是游戏目录。

为什么会这样

这是一个时间窗口的问题。

HMCL 保存实例设置时,走的是 saveGameSettingsFileSaver.save。而 FileSaver 是异步的:它在后台线程上批量写入,收到第一个写入请求后会先 Thread.sleep(200) 等一会儿,看还有没有别的变更要一起写,然后才统一落盘。

所以从「提交写入」到「真正写进磁盘」之间,有一个大约 200 毫秒的窗口。

问题在于,实例设置文件是存在实例目录里面的:

<游戏目录>/versions/<实例>/<metadata>/<config>/instance-game-settings.json
                     └────── removeInstanceFromDisk 要删的就是这一层

FileUtils.saveSafely 在写文件之前,会先调用 Files.createDirectories 把缺失的父目录补齐 —— 这本身是合理的,第一次保存时目录还不存在。

于是就出现了这样的顺序:

  1. 某次设置变更 → 写入请求进入 FileSaver 队列,还没落盘
  2. 用户删除这个实例 → removeInstanceFromDiskversions/<实例>/ 删掉
  3. 队列里那个写入这时才落盘 → Files.createDirectories 把刚删掉的目录树重建出来,并写回设置文件
  4. removeInstanceFromDiskfinally 块里的 refreshAsync 紧接着又把这份设置读回内存

结果就是:目录删了又被建回来,设置删了又被读回来。

怎么改的

removeInstanceFromDisk 真正删文件之前,先等待所有排队中的写入落盘:

flushPendingSettingsWrites();   // 内部调用 FileSaver.waitForAllSaves()

boolean removed = super.removeInstanceFromDisk(instanceId);

这样删除发生时队列已经空了,不会再有写入回来重建目录。

FileSaver.waitForAllSaves() 本来就是为这个场景设计的(它的文档写的是 "Wait for all saves to complete"),UpdateHandlerControllers 在退出前也是这样用的,这里只是把同样的做法用在删除之前。

关于死锁:removeInstanceFromDisk 运行在 Schedulers.io() 线程上(见 Instances.java 中删除按钮的处理),不在 FileSaver 线程上,所以不存在自己等自己的情况。中断异常按惯例恢复中断标志并记录日志。

测试

GameDirectoriesTest.newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists 这个测试正好覆盖了这条路径:它保存设置、删除实例,然后断言 getRunDirectory 回到游戏目录。

这个测试此前依赖时序 —— 在 Linux 上通过,在 macOS 上稳定失败:

expected: /var/folders/.../junit-92788681067517542
 but was: /var/folders/.../junit-92788681067517542/versions/1.21.11-fabric

失败的正是最后那句断言,也就是「删除之后 getRunDirectory 应该回到游戏目录」。

本 PR 不含任何测试改动,修复生产代码后该测试即可通过:

修复前 修复后
GameDirectoriesTest 22 tests, 1 failed 22 tests, 0 failed
:HMCL:test 全量 206 tests, 1 failed 206 tests, 0 failed

我在未修改的 c8929c8 上复跑确认过,该失败在修复前就存在,不是别的改动引入的。

环境:macOS 26.5.2 / Apple Silicon (arm64) / JDK 21.0.11。

检查项:

  • ./gradlew test checkstyle checkTranslations --rerun-tasks 全部通过

补充说明

这个失败之所以一直没被发现,是因为它依赖时序 —— Java CI 工作流跑在 ubuntu-latest 上,在那个环境下时序恰好不触发。仓库目前没有任何工作流在 macOS 上运行,而 HMCL/build.gradle.kts 里声明了 "macos-x86_64", "macos-arm64" 两个目标平台。我可以另开一个 PR 补一条 macOS 测试通道,如果维护者认为有必要的话。

saveGameSettings 通过 FileSaver 异步写入实例设置文件。FileSaver 在后台线程
上合并写入,收到第一个写入请求后会先 sleep 200ms 等待更多变更再统一落盘,
因此提交写入与实际落盘之间存在一个时间窗口。

实例设置文件位于将被删除的实例目录内部:

  <游戏目录>/versions/<实例>/<metadata>/<config>/instance-game-settings.json

而 FileUtils.saveSafely 在写入前会调用 Files.createDirectories 补齐缺失的
父目录。因此如果一次排队中的写入在 removeInstanceFromDisk 之后才落盘,它会
把刚被删除的实例目录整棵重建出来,并写回设置文件;removeInstanceFromDisk
的 finally 块中的 refreshAsync 随后又会把这份设置读回内存。

结果是删除实例后 versions/ 下残留一个只含 HMCL 元数据的目录,且已删除实例
的设置被复原。若该设置包含 PROPERTY_RUNNING_DIRECTORY 覆盖项,getRunDirectory
会继续返回实例目录而非游戏目录。

修复方式是在 removeInstanceFromDisk 删除文件前调用 FileSaver.waitForAllSaves,
确保没有写入还在排队。该方法本就是为此设计,UpdateHandler 与 Controllers
在退出前也是这样使用的。删除操作运行在 Schedulers.io 线程上,不在 FileSaver
线程上,因此不存在自等待。

GameDirectoriesTest.newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists
此前依赖时序,在 macOS 上稳定失败、在 Linux 上通过。本修复后该测试无需改动
即可通过,测试本身不含任何变更。
@github-actions github-actions Bot added the 10+ label Aug 3, 2026
@burningtnt

Copy link
Copy Markdown
Member

我怀疑本 PR 是由 AI 完全独立完成的,并没有经过你的审查。介于 HMCL 近期被 AI slop 骚扰的情况,我要求您提供相关证据证明本 PR 确实正常工作,或证明你已经充分审查了该 PR 的代码。

@Glavo Glavo closed this Aug 3, 2026
@ToobLac

ToobLac commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

注意你提到的测试失败案例,那个案例在两次签出分支之间只会出现一次,不一定是因为你修好了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants