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

@ -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("全部通过");

View File

@ -209,6 +209,19 @@ function loadConnections() {
const raw = fs const raw = fs
.readFileSync(CONNECTIONS_FILE, "utf-8") .readFileSync(CONNECTIONS_FILE, "utf-8")
.replace(/^\uFEFF/, ""); .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); const data = JSON.parse(raw);
normalizeConnections(data); normalizeConnections(data);
return data; return data;
@ -217,7 +230,10 @@ function loadConnections() {
console.error("Failed to load connections:", e); console.error("Failed to load connections:", e);
// 配置损坏:备份后重建,避免应用一直处于"无连接"状态 // 配置损坏:备份后重建,避免应用一直处于"无连接"状态
try { 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.copyFileSync(CONNECTIONS_FILE, backup);
fs.writeFileSync( fs.writeFileSync(
CONNECTIONS_FILE, CONNECTIONS_FILE,
@ -255,10 +271,22 @@ function normalizeConnections(data) {
} }
function saveConnections(data) { function saveConnections(data) {
// ★ 原子写:先写同目录临时文件,再 rename 覆盖。
//
// 为什么必须:原实现直接 writeFileSync 截断重写,落地过程中存在
// 「已截断、内容未写完」的窗口。若此刻另一个实例(或本实例另一次
// loadConnections)读到那份半截内容,JSON.parse 抛错就走损坏分支 ——
// 于是「一次并发读」变成「配置被清空」。rename 在同一文件系统内
// 是原子的:读者只会看到改写前或改写后的完整文件。
const tmp = CONNECTIONS_FILE + ".tmp-" + process.pid;
try { 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) { } catch (e) {
console.error("Failed to save connections:", e); console.error("Failed to save connections:", e);
try {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
} catch (_) {}
} }
} }

View File

@ -8,7 +8,7 @@
"scripts": { "scripts": {
"start": "electron . --no-sandbox", "start": "electron . --no-sandbox",
"dev": "electron . --no-sandbox --dev", "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" "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs"
}, },
"dependencies": { "dependencies": {