Files
HomeAgent/cmd/gui/connections-io.test.mjs
JianFeeeee e3c81269b6 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 条)。
2026-10-01 23:16:49 +08:00

180 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// connections.json 加载/保存的行为判据。
//
// ## 要判的是什么
//
// loadConnections() 的损坏恢复分支曾把「读到空/半截文件」当成
// 「配置损坏」,于是**把文件改写成空配置**。而 saveConnections() 用
// 非原子的 writeFileSync 截断重写,制造了产生半截内容的窗口。
// 两者叠加 = 一次并发读就能让用户的连接列表永久消失。
//
// 实测事故(2026-10-01 17:21):connections.json 变成
// {"connections":[],"currentId":null},连接全丢。
//
// ## 为什么用「跑真实函数」而不是检查源码文本
//
// 判据在 node:vm 沙箱里抽出 main.js 的**真实函数**执行,
// 断言的是文件最终内容与返回值 —— 与线上走同一条代码。
//
// 运行:node connections-io.test.mjs
import { readFileSync, writeFileSync, existsSync, mkdtempSync, readdirSync, unlinkSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import vm from "node:vm";
const here = dirname(fileURLToPath(import.meta.url));
const mainSrc = readFileSync(join(here, "main.js"), "utf8");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
// 抽出真实实现(从 loadConnections 到 findHomed,含 normalize/save)
function extractImpl() {
const start = mainSrc.indexOf("function loadConnections() {");
const end = mainSrc.indexOf("function findHomed() {");
if (start < 0 || end < 0) throw new Error("无法从 main.js 抽出 load/save 实现");
return mainSrc.slice(start, end);
}
function makeSandbox(file) {
// 本地 stub:Node 的 renameSync 在同卷内会移动(源消失),
// 但为了让沙箱能在临时目录里跑,这里显式模拟真实语义。
const realUnlink = (p) => {
try {
unlinkSync(p);
} catch (e) {
/* ignore */
}
};
const fsStub = {
existsSync: (p) => existsSync(p),
readFileSync: (p) => readFileSync(p, "utf8"),
writeFileSync: (p, c) => writeFileSync(p, c, "utf8"),
copyFileSync: (a, b) => writeFileSync(b, readFileSync(a)),
renameSync: (a, b) => {
writeFileSync(b, readFileSync(a));
realUnlink(a);
},
unlinkSync: realUnlink,
};
const ctx = {
CONNECTIONS_FILE: file,
fs: fsStub,
console: { log() {}, error() {} },
process: { pid: 1234 },
__dirname: here,
Date,
JSON,
console: { log() {}, error() {}, warn() {} },
};
vm.createContext(ctx);
vm.runInContext(extractImpl(), ctx);
return ctx;
}
function scenario(name, initialContent) {
const dir = mkdtempSync(join(tmpdir(), "conn-"));
const file = join(dir, "connections.json");
if (initialContent !== null) writeFileSync(file, initialContent, "utf8");
const ctx = makeSandbox(file);
const ret = vm.runInContext("loadConnections()", ctx);
return { file, ret, after: readFileSync(file, "utf8") };
}
const REAL = JSON.stringify({
connections: [{ id: "abc", name: "local", url: "http://192.168.2.60:8080" }],
currentId: "abc",
});
// ── 1) 正常配置:原样返回,不改写文件 ─────────────────────────────────
{
const r = scenario("normal", REAL);
check(
"合法 JSON 原样返回",
r.ret.connections.length === 1 && r.ret.connections[0].id === "abc",
JSON.stringify(r.ret).slice(0, 80),
);
check("合法 JSON 不被改写", r.after === REAL, "文件内容被动了");
}
// ── 2) ★ 核心判据:空文件不得被改写成空配置 ──────────────────────────
{
const r = scenario("empty", "");
check(
"空文件:返回空配置",
r.ret.connections.length === 0 && r.ret.currentId === null,
JSON.stringify(r.ret).slice(0, 80),
);
check(
"★ 空文件不被覆写(用户配置不被销毁)",
r.after === "",
"空文件被改写成:" + r.after.slice(0, 80),
);
}
// ── 3) 纯空白(BOM / 换行 / 空格)同样不得覆写 ────────────────────────
{
const r = scenario("whitespace", " \r\n ");
check(
"★ 纯空白不被覆写",
r.after === " \r\n ",
"被改写成:" + JSON.stringify(r.after).slice(0, 60),
);
}
// ── 4) 真损坏(半截 JSON):该备份重建,且备份带时间戳 ────────────────
{
const r = scenario("corrupt", '{"connections":[{"id":"abc"');
const dir = r.file.replace(/connections\.json$/, "");
const backups = existsSync(dir) ? readdirSync(dir).filter((f) => f.includes("corrupt")) : [];
check(
"损坏:备份文件已生成",
backups.length === 1,
"目录里没有 .corrupt-* 备份:" + JSON.stringify(backups),
);
check(
"★ 备份带时间戳(反复损坏不覆盖唯一退路)",
backups.length === 1 && /corrupt-\d{4}-\d{2}-\d{2}T.*\.bak$/.test(backups[0]),
"备份名不是带时间戳的形式:" + (backups[0] || "(无)"),
);
check(
"损坏:重建为空配置(应用不卡在坏状态)",
/"connections"\s*:\s*\[\s*\]/.test(r.after),
r.after.slice(0, 60),
);
}
// ── 5) saveConnections 必须是原子写(不留 .tmp 残留) ────────────────
{
const dir = mkdtempSync(join(tmpdir(), "conn-save-"));
const file = join(dir, "connections.json");
const ctx = makeSandbox(file);
vm.runInContext("saveConnections(" + JSON.stringify({ connections: [{ id: "z" }], currentId: "z" }) + ")", ctx);
const left = readdirSync(dir);
check(
"saveConnections 写出了目标文件",
existsSync(file),
"文件不存在",
);
check(
"★ saveConnections 不留 .tmp 残留",
!left.some((f) => f.includes(".tmp-")),
"残留:" + JSON.stringify(left),
);
const back = JSON.parse(readFileSync(file, "utf8"));
check("saveConnections 内容正确", back.connections[0].id === "z", JSON.stringify(back));
}
console.log("");
if (failures > 0) {
console.log(`全部失败:${failures} 条`);
process.exit(1);
}
console.log("全部通过");