本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/FlintHub_升级conflict死锁_修复.md。
FlintHub 升级「conflict 死锁」彻查与修复(2026-10-05)
症状:升级时反复报 「存在本地已修改的文件(conflict 1 个),需人工确认后再升级」,
且无论重试多少次都不会好转 —— 这是一个永久死锁,不是偶发。
一、结论(一句话)
那 1 个 conflict 就是 version.php。它是升级器自己必然要改写的版本元数据,
三方天然不一致(本地=当前版 / 基线=更早版 / 远端=新版),被 Updater::diff() 误判成「本地已修改」;
而 backup() 见 conflict 即拒、planOps() 又只在 modify 里认它 → 版本号永远写不进去 → 死锁。
二、证据链(全部只读复刻,未改任何生产文件)
2.1 现场事实
| 项 | 值 | 说明 |
|---|---|---|
version.php(磁盘) | V1.1.4 | sha 与 release/core/flinthub1.1.4/version.php 逐字节一致 |
data/runtime/update_baseline.json | version = 1.1.0,at = 2026-10-02T11:42:17 | 基线停在 1.1.0 |
审计日志 action LIKE 'update%' | 只有一条成功:V1.0.46 → 1.1.0(2026-10-02) | 此后再无成功升级 |
release/core/latest.json | 指向 1.1.7 | 站长想升到 1.1.7 |
2.2 只读复刻 Updater::diff(manifest-1.1.7, baseline)
ADD (0): MODIFY (0): DELETE (0):
CONFLICT (1):
- version.php
same = 312 skipped = 4(data/.htaccess, data/web.config, protected/.htaccess, protected/web.config)三方 sha:
| 来源 | sha256 | 实际对应 |
|---|---|---|
| 本地 | 5ef3f11b… | = 1.1.4 镜像 → 磁盘是 1.1.4 |
| 远端 | 30a4cf9b… | = 1.1.7 清单 |
| 基线 | 93735d31… | = 1.1.0 镜像 |
→ 本地既不是远端、也不是基线 ⇒ 被判 conflict。
2.3 diff() 的判据(app/Helpers/Updater.php)
foreach ($remote as $p => $sha) {
if (!isset($local[$p])) { $add[] = $p; continue; }
if ($local[$p]['sha256'] === $sha) { $same++; continue; }
if ($base !== null && isset($base[$p]) && $local[$p]['sha256'] !== $base[$p]) {
$conflict[] = $p; // ← version.php 必然命中这里
} else {
$modify[] = $p;
}
}2.4 死锁链(为什么无法自愈)
conflict(version.php)
└─ UpdateController::backup() :523 见 conflict 即 return
⚠️ 文案说「需人工确认」,但**全站没有任何「确认 / 强制继续」入口**
└─ 即使绕过:Updater::planOps() :1696
$version = in_array('version.php', $diff['modify'], true) ? 'version.php' : null;
→ 它在 conflict 里 ⇒ plan.version = null ⇒ version.php **永远不写**
└─ 升级不成功 ⇒ writeBaseline() 不执行 ⇒ 基线**永远停在旧版本**
└─ 下次 diff 照样 conflict ← 回到起点(死锁)2.5 只读模拟三连
| 场景 | conflict | version.php 会被写吗 |
|---|---|---|
| ① 现状(基线 1.1.0) | 1 | 不会 ✗ |
② 基线为 null(= 删 update_baseline.json) | 0 | 会 ✓ ← 这就是「删基线」为何一直有效 |
③ version.php 豁免 conflict | 0 | 会 ✓ |
三、为什么上一轮(10-04)修过还会出现
上轮修的是另一个独立成因 —— 「分批 apply 的游标错位 → 磁盘与基线脱节」,那条已生效。
本轮的成因是 version.php 的三方天然不一致,且上轮的测试场景恰好不会暴露它:
当时基线 == 当前版本,version.php 落入 modify,一切正常。
只有「基线 ≠ 当前版本」时才触发。
触发条件(通用,不限本机)
只要 version.php 被非升级器的方式更新过,基线就落后于版本号 ⇒ 必然 conflict ⇒ 死锁:
- 宝塔手动覆盖代码 / 上传 zip 解压
- 开发机直接编辑(工作区即站点根)
writeBaseline()曾静默失败(原来没检查返回值)- 从旧包手工部署后再改用升级器
四、修复(方案 A + 两处加固)
4.1 主修复:version.php 豁免 conflict(app/Helpers/Updater.php · diff())
// 内容不同:本地是否偏离了官方基线?
if ($p === 'version.php') { $modify[] = $p; continue; } // ★ 新增:版本元数据不参与 conflict
if ($base !== null && isset($base[$p]) && $local[$p]['sha256'] !== $base[$p]) {
$conflict[] = $p;
} else {
$modify[] = $p;
}理由:version.php 在 WHITELIST_FILES 里、由 apply() 在所有普通操作之后最后单独写 ——
它是「升级器自己管理的元数据」,升级本来就要改它;用户对它的任何「修改」都会被覆盖(既有设计如此)。
⚠️ 其他文件的 conflict 语义完全不变 —— 本地真改过的普通文件仍会拦下要求人工确认。
4.2 加固①:writeBaseline() 返回值检查(UpdateController.php · apply())
原代码 $this->writeBaseline($manifest, $version); 忽略返回值 → 写失败只进 error.log,
升级却报「成功」,留下「基线落后」的隐患。现改为:
$blOk = $this->writeBaseline($manifest, $version);
if (!$blOk) {
AuditLog::log('update_baseline_fail', 'core', 0,
'升级 ' . $version . ' 已写盘,但基线写入失败(下次升级可能提示冲突)');
}并在响应里补 baseline_written 字段。⚠️ 升级已写盘成功,故不中断流程(锁照常释放、状态照常更新)。
4.3 加固②:conflict 提示补「可操作指引」(新增私有方法 conflictMessage())
原文案只说「需人工确认」,但系统里根本没有确认入口 → 用户无路可走。现补上文件清单 + 解决路径:
存在本地已修改的文件(conflict 1 个),需人工确认后再升级:
version.php。这些文件在站点上的内容与官方基线不一致(多为手工改动,或从旧包直接覆盖而来)。
若确认这些改动可以丢弃,请先备份
data/runtime/update_baseline.json,再把它删除后重试—— 下次升级成功会自动重建基线。
(两处调用:backup() 的拦截、apply() 后的复核;超过 5 个文件时截断并加「等」。)
五、验证结果(四组探针,全部零残留)
| # | 探针 | 结果 |
|---|---|---|
| 1 | 只读复刻 diff()(正向 + 反向断言) | 10 PASS / 0 FAIL —— version.php 不在 conflict 里且落入 modify;planOps.version = 'version.php';普通文件仍被判 conflict(保护未削弱);插件作用域无副作用;基线/version.php 未被改动 |
| 2 | 沙箱端到端(真实 Updater.php + 最小站点,skill §7 范式) | 12 PASS / 0 FAIL —— 修复前判据复现 conflict;修复后 diff→planOps→apply→writeBaseline→再 diff 完全收敛(conflict/modify/add/delete 全 0);沙箱零残留 |
| 3 | 文案方法(反射跳过后台门禁) | 7 PASS / 0 FAIL —— 数量/文件名/指引齐备;>5 截断加「等」;全角标点未吞变量 |
| 4 | 门禁 | php -l 8.2 + 8.0 双版本全绿;fh_i18n_check 55 PASS / 0 FAIL;php_var_cjk_check OK |
★ 探针 2 的「二次 diff 完全收敛」是关键 —— 它证明修复后下次升级不会再被拦(自愈成立)。
六、✅ 已完成:重建发布包(站长选 A → 出 1.1.8)
本次改的是核心代码,而 release/core/flinthub-core-1.1.7.zip(16:02 出的)不含本修复:
$ diff -rq release/core/flinthub1.1.7 .
Files .../app/Controllers/Admin/UpdateController.php and ./app/Controllers/Admin/UpdateController.php differ
Files .../app/Helpers/Updater.php and ./app/Helpers/Updater.php differ
Files .../version.php and ./version.php differ ← 预期差异(升级时才写)不重建包的后果(两条,都会发生):
- 站长升级时
diff会报 conflict 2 个(Updater.php+UpdateController.php,因为包内是旧版)
→ 比现在的 1 个更糟;
- 即使绕过,升级会用包内的旧版覆盖站点 → 修复被冲掉,下次照样死锁。
→ 违反红线「发布包必须与站点代码一致」。
站长决定(2026-10-05):选 A —— 出 1.1.8(版本号递增,不动已发布的 1.1.7)。
6.1 出包结果(2026-10-05 18:22)
FH_RELEASE_KEY=D:/keys/flinthub/flinthub-2026.key \
FH_RELEASE_KEY_PASS="$(tr -d '\r\n' < /d/keys/flinthub/.fh_passphrase)" \
php cli/release_build.php --type=core --version=1.1.8 --sign --key-id=flinthub-2026 \
--notes=.workbuddy-ai/tmp/fh_118_notes.md- 门禁 9 项:#1~#8 全 PASS(#8 签名链路 PASS),#9 SKIP(仅插件包)。
- 产物:
| 文件 | 大小 | sha256 |
|---|---|---|
release/core/flinthub-core-1.1.8.zip | 1,855,424 B / 318 文件 + 42 目录 | e242bedb1aed5db4935d15ca9f8d0cada5cae5dc99b33153921a2e8a313054d6 |
release/core/manifest-1.1.8.json | 317 条目 | 6108be45c08e983dc8f83fc8942bae4af94a9bddc480bb211ae4a310449d1c2f |
release/core/latest.json | core → 1.1.8(全量包) | — |
- manifest 签名:
RS256 / key_id=flinthub-2026 / 256 B(站点端自验通过)。 改动说明.md§四:用--notes写入了本版修复的完整说明(现象 / 根因 / 修复 / 加固)。- 1.1.7 → 1.1.8 差异:
diff -rq实测正好 3 个代码文件(Updater.php、UpdateController.php、version.php)+改动说明.md。
6.2 发布后一致性校验(三件套,全绿)
| 校验 | 结果 |
|---|---|
fh_manifest_mirror_check.php | 9 PASS / 0 FAIL(镜像↔manifest↔latest + 站点端验签) |
fh_pkg_vs_site.php | 316/316 逐字节一致(0 不一致 / 0 缺失) |
fh_zip_vs_site_118.php(直读 zip 条目 ↔ 站点) | 316 一致 / 0 不一致 / 0 缺失(排除 version.php、改动说明.md) |
manifest_zip_invariant_probe.php | 1.1.8:哈希不一致 0 |
data/runtime/update_check.json:不存在(无需清理)。- 站点根
version.php仍为V1.1.4(正确 —— 打包器明确不改站点根那份,包内写V1.1.8)。
七、给用户的一句话
修复包 = flinthub-core-1.1.8.zip(含 Updater.php 豁免 + UpdateController.php 两处加固)。
若站点当前卡在 conflict:升级到 1.1.8 后不需要删基线 —— 升级成功后会自动重建基线。
(若暂时不便更新代码,才用「备份后删除 data/runtime/update_baseline.json」这一临时绕过手段。)