Files
Todo-List/scripts/check-write-race.mjs
2026-09-12 13:55:57 +08:00

320 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// 测试 file:write 的 race 防护main.js v6 方案)
//
// 真实场景:
// T0 软件内存 V0磁盘 V0lastSeen = V0
// T1 用户在 UI 改任务 → dirty=true800ms autoSave 排队
// T1 外部编辑器原子保存 → 文件变成 V_ext
// T2 autoSave 触发 → serialize() → 走 writeFile IPC
//
// 旧实现writeFile 只做一次 statSync 检查pre-checkpre-check 通过后
// 写 tmp → rename 中间有 10-100ms 窗口,外部编辑器在窗口里又改 → rename
// 覆盖外部编辑(数据丢失)。
//
// v6 修复:在写 tmp 之后、rename 之前做第二次 statSync把 race 窗口压缩到
// 几乎不可见(< 1ms
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs';
import fs from 'fs';
import path from 'path';
import os from 'os';
const { dir: tmpDir, file: tmpFile } = makeTmpTodoFile({
prefix: 'write-race-',
content: '# 全部任务\n\n## 工作\n\n- [ ] V0\n'
});
// 复刻 main.js writeFile 的关键检查逻辑(隔离测试,不引入 electron
let lastSeenMtimeMs = 0;
let lastSeenSize = 0;
function recordFileStat(filePath) {
try {
const st = fs.statSync(filePath);
lastSeenMtimeMs = st.mtimeMs;
lastSeenSize = st.size;
} catch { /* ignore */ }
}
recordFileStat(tmpFile);
// ============================================================
// 复刻 v6 writeFile 的 mtime 双检查 + 二次确认逻辑
// ============================================================
async function v6WriteFile(absPath, content, options = {}) {
if (!options.force && fs.existsSync(absPath)) {
let preStat;
try { preStat = await fs.promises.stat(absPath); } catch { /* skip */ }
if (preStat && (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize)) {
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'pre-check' };
}
const tmpPath = absPath + '.tmp.' + Date.now();
await fs.promises.writeFile(tmpPath, content, 'utf-8');
if (preStat) {
try {
const confirmStat = await fs.promises.stat(absPath);
if (confirmStat.mtimeMs !== preStat.mtimeMs || confirmStat.size !== preStat.size) {
await fs.promises.unlink(tmpPath).catch(() => {});
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'race-window' };
}
} catch (e) {
await fs.promises.unlink(tmpPath).catch(() => {});
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'race-window-stat-failed' };
}
}
await fs.promises.rename(tmpPath, absPath);
} else {
const tmpPath = absPath + '.tmp.' + Date.now();
try {
await fs.promises.writeFile(tmpPath, content, 'utf-8');
await fs.promises.rename(tmpPath, absPath);
} catch (e) {
await fs.promises.unlink(tmpPath).catch(() => {});
throw e;
}
}
recordFileStat(absPath);
return { ok: true };
}
// ============================================================
// [1] 正常写盘:基线匹配,无 race
// ============================================================
console.log('\n[1] 正常写盘(无外部修改)');
{
const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] V1\n');
check('force=false + 无外部修改 → 写入成功',
result.ok === true, JSON.stringify(result));
}
// ============================================================
// [2] pre-check 拦截外部已修改(外部编辑发生在 pre-check 之前)
// ============================================================
console.log('\n[2] pre-check 拦截外部修改');
{
// 让 mtime 推进
await new Promise(r => setTimeout(r, 60));
// 模拟外部编辑器保存(更新 lastSeen 之后又来一次外部保存,
// 模拟 fs.watch 还没及时刷新的场景 —— pre-check 必须能拦下)
recordFileStat(tmpFile); // 先刷基线
await new Promise(r => setTimeout(r, 60));
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部V2\n');
// 不刷新 lastSeen模拟 polling 还没 tick / fs.watch 没触发的场景
const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave\n');
check('pre-check 检测到外部修改 → 拒绝写盘',
result.ok === false && result.reason === 'pre-check',
JSON.stringify(result));
}
// ============================================================
// [3] 二次确认拦截 race window 内的外部修改
// ============================================================
console.log('\n[3] 二次确认拦截 race window 内的外部修改');
{
// 让 mtime 推进
await new Promise(r => setTimeout(r, 60));
recordFileStat(tmpFile);
await new Promise(r => setTimeout(r, 60));
// 这里用 monkey-patch 让 statSync 在 writeFile 内部第二次调用时返回「新状态」
// —— 模拟「外部编辑发生在 pre-check 和二次 stat 之间」
let statCallCount = 0;
const realStat = fs.promises.stat;
fs.promises.stat = async (...args) => {
statCallCount++;
if (statCallCount === 1) {
// 第一次 statpre-check—— 返回旧状态(与 lastSeen 一致)
return realStat(...args);
}
// 第二次 stat二次确认—— 返回新状态(模拟外部编辑已发生)
// 直接调用底层 statSync 取最新值
const freshSt = fs.statSync(tmpFile);
// 但 mtime 必须不同 —— 如果还没改,我们手动改一下再读
if (freshSt.mtimeMs === lastSeenMtimeMs) {
// 没有外部编辑发生,主动触发一次以确保两次 mtime 不同
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] race-injected\n');
await new Promise(r => setTimeout(r, 10));
return fs.statSync(tmpFile);
}
return freshSt;
};
const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave-race\n');
fs.promises.stat = realStat; // 还原
check('二次确认检测到 race window 内的修改 → 拒绝写盘',
result.ok === false && result.reason === 'race-window',
JSON.stringify(result));
// .tmp 文件应被清理(避免数据目录膨胀)
const leftover = fs.readdirSync(tmpDir).filter(f => f.includes('.tmp.'));
check('被拒后 .tmp 文件被清掉', leftover.length === 0, `leftover=${leftover.join(',')}`);
}
// ============================================================
// [4] force=true 跳过所有检查
// ============================================================
console.log('\n[4] force=true 跳过 mtime 检查(用户明确表态)');
{
await new Promise(r => setTimeout(r, 60));
recordFileStat(tmpFile);
await new Promise(r => setTimeout(r, 60));
// 外部修改
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部V3\n');
const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 用户覆盖\n', { force: true });
check('force=true → 写入成功(用户明确覆盖外部编辑)',
result.ok === true, JSON.stringify(result));
const content = fs.readFileSync(tmpFile, 'utf-8');
check('磁盘上确实是用户内容', content.includes('用户覆盖'));
}
// ============================================================
// [5] 二次确认 stat 失败:按外部修改处理(保守策略)
// ============================================================
console.log('\n[5] 二次确认 stat 失败按外部修改处理');
{
await new Promise(r => setTimeout(r, 60));
recordFileStat(tmpFile);
await new Promise(r => setTimeout(r, 60));
// 让第二次 stat 抛错 —— 模拟文件被外部删除 / 锁住
let callCount = 0;
const realStat = fs.promises.stat;
fs.promises.stat = async (...args) => {
callCount++;
if (callCount === 1) return realStat(...args);
// 第二次抛错
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
};
const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave\n');
fs.promises.stat = realStat;
check('二次 stat 失败 → 按外部修改拒绝',
result.ok === false && result.reason === 'race-window-stat-failed',
JSON.stringify(result));
}
// ============================================================
// [6] .bak 轮转:保留两份 .bak不无限累积
// ============================================================
console.log('\n[6] .bak 轮转');
{
// 清理已有 .bak 文件以独立测试
for (const f of fs.readdirSync(tmpDir)) {
if (f.endsWith('.bak') || f.endsWith('.bak.1')) {
fs.unlinkSync(path.join(tmpDir, f));
}
}
await new Promise(r => setTimeout(r, 60));
fs.writeFileSync(tmpFile, 'version-A\n');
await new Promise(r => setTimeout(r, 60));
// 第一次写:磁盘 V0期望产生 .bak = V0
// 这里我们直接模拟 rotateBakFiles 的效果(不嵌入 writeFile 链路)
async function rotateBakFiles(absPath) {
const bakPath = absPath + '.bak';
const bak1Path = absPath + '.bak.1';
try {
if (fs.existsSync(bak1Path)) await fs.promises.unlink(bak1Path).catch(() => {});
if (fs.existsSync(bakPath)) await fs.promises.rename(bakPath, bak1Path).catch(() => {});
if (fs.existsSync(absPath)) await fs.promises.copyFile(absPath, bakPath).catch(() => {});
} catch {}
}
await rotateBakFiles(tmpFile);
check('第一次轮转:只有 .bak没有 .bak.1',
fs.existsSync(tmpFile + '.bak') && !fs.existsSync(tmpFile + '.bak.1'));
// 第二次写:磁盘 V1期望 .bak = V1最新.bak.1 = V0
await new Promise(r => setTimeout(r, 60));
fs.writeFileSync(tmpFile, 'version-B\n');
await rotateBakFiles(tmpFile);
check('第二次轮转:.bak = V1, .bak.1 = V0',
fs.existsSync(tmpFile + '.bak') && fs.existsSync(tmpFile + '.bak.1'));
// 第三次写:磁盘 V2期望 .bak = V2最新.bak.1 = V1次新V0 应被丢弃
await new Promise(r => setTimeout(r, 60));
fs.writeFileSync(tmpFile, 'version-C\n');
await rotateBakFiles(tmpFile);
const bakContent = fs.readFileSync(tmpFile + '.bak', 'utf-8');
const bak1Content = fs.readFileSync(tmpFile + '.bak.1', 'utf-8');
check('第三次轮转:.bak = version-C', bakContent === 'version-C\n', `bak=${bakContent}`);
check('第三次轮转:.bak.1 = version-B', bak1Content === 'version-B\n', `bak1=${bak1Content}`);
check('最多两份 .bak不无限累积',
fs.readdirSync(tmpDir).filter(f => f.includes('.bak')).length === 2);
}
// ============================================================
// [7] file:read 必须刷新 lastSeen —— 否则换文件后第一次 write
// 会拿陈旧 lastSeen 比对新文件 stat误判外部修改
// ============================================================
console.log('\n[7] file:read 路径下 lastSeen 必须刷新(与 file:write 对称)');
{
// 复刻两份不同的 todo 文件(模拟用户切文件场景)
const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'read-fresh-a-'));
const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'read-fresh-b-'));
const fileA = path.join(dirA, 'todo.md');
const fileB = path.join(dirB, 'todo.md');
fs.writeFileSync(fileA, '# A file\n');
fs.writeFileSync(fileB, '# B file\n');
// 复刻 main.js 状态lastSeen 是 module-level 全局变量(不是 per-file
let lastSeenMtimeMs = 0;
let lastSeenSize = 0;
function recordFileStat(filePath) {
try {
const st = fs.statSync(filePath);
lastSeenMtimeMs = st.mtimeMs;
lastSeenSize = st.size;
} catch { /* ignore */ }
}
// 模拟旧实现的 file:read只挂 fs.watch不刷 lastSeen
function oldFileRead(filePath) {
// 旧版startWatchingFile(filePath) 但不调 recordFileStat
// 这里用 stub 表示「什么也不做」
}
// 第一步:用户读 A → 旧实现挂上 fs.watch 但 lastSeen 没动(仍是 0
oldFileRead(fileA);
// 此时 lastSeen 仍是 0A 的 mtime 非零)
// 第二步:用户读 B → 同样挂 fs.watch 但 lastSeen 仍是 0
oldFileRead(fileB);
// 第三步:用户写 B无 force→ pre-check 用 B 的 mtime 比 lastSeen=0 → 误报
const preStat = fs.statSync(fileB);
const oldImplMismatch = (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize);
check('旧实现确实有 bugB 的 stat 与 lastSeen(0) 不等',
oldImplMismatch, '预期 oldImplMismatch=true');
// 第四步:修复后的 file:read 应在挂 watcher 后调 recordFileStat
// 模拟修复后行为:
recordFileStat(fileB);
// 第五步:再次跑 v6 pre-check
const preCheckPasses = (preStat.mtimeMs === lastSeenMtimeMs && preStat.size === lastSeenSize);
check('修复后B 写之前 lastSeen 已同步到 B 的 stat',
preCheckPasses,
`preStat=(${preStat.mtimeMs},${preStat.size}) lastSeen=(${lastSeenMtimeMs},${lastSeenSize})`);
// 清理
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
// 清理
cleanupTmpTodo({ dir: tmpDir });
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);