mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
180
cmd/gui/connections-io.test.mjs
Normal file
180
cmd/gui/connections-io.test.mjs
Normal file
@ -0,0 +1,180 @@
|
||||
// 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("全部通过");
|
||||
@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"start": "electron . --no-sandbox",
|
||||
"dev": "electron . --no-sandbox --dev",
|
||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs",
|
||||
"test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs",
|
||||
"test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user