fix(gui): 连接配置不再被空/半截文件覆写,保存改原子写

## 现象
connections.json 变成 {"connections":[],"currentId":null},
用户的连接列表全部丢失(2026-10-01 实测)。

## 根因:两个缺陷叠加
1) loadConnections() 的损坏恢复分支无法区分两种情况:
     · 文件内容确实损坏        → 备份+重建(合理)
     · 读到空/半截内容        → **也走同一条路,覆写成空配置**
   第二类真实会发生:saveConnections() 用非原子 writeFileSync 截断重写,
   落地过程存在「已截断、未写完」窗口;此刻任何一次读取拿到半截内容,
   JSON.parse 抛错 → 用户配置被永久销毁,且没有二次确认。

   实测(沙箱跑真实函数):空文件 -> 返回空配置,
   **并把文件改写成 {"connections":[],"currentId":null}**。

2) 损坏备份只写一个固定的 .bak,反复损坏会把上一份真实配置覆盖掉,
   于是「唯一的退路」也丢了。

## 改动
1) loadConnections():raw.trim()==="" 视为「尚未配置」,
   直接返回空配置,**不覆写、不备份**(BOM/纯空白同样处理)。
2) saveConnections():改为原子写 —— 先写同目录 .tmp-<pid>,
   再 renameSync 覆盖。rename 在同卷内原子,读者只会看到改写前
   或改写后的完整文件,从根上消除产生半截内容的竞态;失败时清理 tmp。
3) 真损坏的备份名带时间戳(connections.json.corrupt-<ISO>.bak),
   反复损坏不再互相覆盖。

## 判据
新增 cmd/gui/connections-io.test.mjs(进 npm test):
在 node:vm 里抽出 main.js 的**真实函数**执行,11 条断言覆盖
合法/空/纯空白/损坏/原子写残留。已做变异测试:删掉空文件守卫、
把备份名改回固定 .bak,均被判红。

## 实测(本机 Electron + 真实凭据)
  正常启动:connections.json MD5 前后一致(E8DD588F...),0 个 tmp 残留
  人工清空后启动:文件被自愈填充回真实配置,而非覆写成空

判据:cmd/gui npm test 全绿(32 条)。
This commit is contained in:
JianFeeeee
2026-10-01 23:16:49 +08:00
parent ee2d43dcbc
commit e3c81269b6
3 changed files with 211 additions and 3 deletions

View File

@ -209,6 +209,19 @@ function loadConnections() {
const raw = fs
.readFileSync(CONNECTIONS_FILE, "utf-8")
.replace(/^\uFEFF/, "");
// ★ 空/纯空白 = 「还没有配置」,不是「配置损坏」。
//
// 原实现在这里直接 JSON.parse,空文件会抛错并掉进下面的 catch,
// 而 catch 会**把文件改写成空配置**。也就是说:只要读到一次空/半截
// 内容(写入竞争、上次异常退出的残留、另一实例正在改写),
// 用户的真实连接列表就被永久覆盖成空 —— 且没有二次确认。
//
// 实测(沙箱复现原函数):空文件 -> 返回空配置,
// **并把文件写成 {"connections":[],"currentId":null}**。
if (raw.trim() === "") {
console.log("connections.json is empty; treating as uninitialized");
return { connections: [], currentId: null };
}
const data = JSON.parse(raw);
normalizeConnections(data);
return data;
@ -217,7 +230,10 @@ function loadConnections() {
console.error("Failed to load connections:", e);
// 配置损坏:备份后重建,避免应用一直处于"无连接"状态
try {
const backup = CONNECTIONS_FILE + ".bak";
// 带时间戳:原实现只写一个固定的 .bak,反复损坏会把上一份
// 真实配置覆盖掉,于是「唯一的退路」也丢了。
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = CONNECTIONS_FILE + ".corrupt-" + stamp + ".bak";
fs.copyFileSync(CONNECTIONS_FILE, backup);
fs.writeFileSync(
CONNECTIONS_FILE,
@ -255,10 +271,22 @@ function normalizeConnections(data) {
}
function saveConnections(data) {
// ★ 原子写:先写同目录临时文件,再 rename 覆盖。
//
// 为什么必须:原实现直接 writeFileSync 截断重写,落地过程中存在
// 「已截断、内容未写完」的窗口。若此刻另一个实例(或本实例另一次
// loadConnections)读到那份半截内容,JSON.parse 抛错就走损坏分支 ——
// 于是「一次并发读」变成「配置被清空」。rename 在同一文件系统内
// 是原子的:读者只会看到改写前或改写后的完整文件。
const tmp = CONNECTIONS_FILE + ".tmp-" + process.pid;
try {
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), "utf-8");
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
fs.renameSync(tmp, CONNECTIONS_FILE);
} catch (e) {
console.error("Failed to save connections:", e);
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch (_) {}
}
}