mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 现象
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 条)。
180 lines
6.1 KiB
JavaScript
180 lines
6.1 KiB
JavaScript
// 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("全部通过"); |