From e3c81269b6cb9bd2f9a63d41380f725c9351c39e Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:16:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E8=BF=9E=E6=8E=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8D=E5=86=8D=E8=A2=AB=E7=A9=BA/=E5=8D=8A?= =?UTF-8?q?=E6=88=AA=E6=96=87=E4=BB=B6=E8=A6=86=E5=86=99=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=94=B9=E5=8E=9F=E5=AD=90=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 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-, 再 renameSync 覆盖。rename 在同卷内原子,读者只会看到改写前 或改写后的完整文件,从根上消除产生半截内容的竞态;失败时清理 tmp。 3) 真损坏的备份名带时间戳(connections.json.corrupt-.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 条)。 --- cmd/gui/connections-io.test.mjs | 180 ++++++++++++++++++++++++++++++++ cmd/gui/main.js | 32 +++++- cmd/gui/package.json | 2 +- 3 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 cmd/gui/connections-io.test.mjs diff --git a/cmd/gui/connections-io.test.mjs b/cmd/gui/connections-io.test.mjs new file mode 100644 index 00000000..56e220fd --- /dev/null +++ b/cmd/gui/connections-io.test.mjs @@ -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("全部通过"); \ No newline at end of file diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 0d1ba83e..3c451c55 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -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 (_) {} } } diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 016ad13b..01a01a56 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -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": {