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

293 lines
12 KiB
JavaScript
Raw Permalink 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.
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 回归测试:脏标记竞态 ——「await writeFile 期间用户又改了」必须保留 dirty
//
// v4 起去掉 800ms debounce 改为全实时同步:
// 用户改 A → _markDirty → 立即 _scheduleAutoSave → _doAutoSave 启动 →
// 序列化(含 A→ await writeFile期间用户改 B / C / D → _markDirty 只
// 设 dirty=true + 推 _dirtyVersion + 设 _needsResave=true因为有 in-flight
// → writeFile 返回 → 版本检查发现快照不含最新 → 不清 dirty
// finally 检测 _needsResave → 立即再启动一次 _doAutoSave 把累积改动写出去。
//
// 旧机制800ms debounce的关键回归仍是 v4 的核心不变:
// in-flight 期间的用户改动**必须**最终落盘,绝不能因为 dirty 被误清而丢。
//
// 跑法node scripts/check-dirty-race.mjs
// 桩出 window.api把 writeFile 换成可手动控制的 Promise
// 这样能精确卡在「await writeFile 期间」注入下一次 _markDirty。
let writeFileResolver;
const calls = { writeFile: 0 };
installApiStub({
writeFile: (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { writeFileResolver = resolve; });
}
});
const { TaskStore } = await import('../src/task-store.js');
const { CategoryKind } = await import('../src/markdown-parser.js');
// ============================================
// [1] _doAutoSave 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[1] _doAutoSave 路径下「await 期间修改」必须保留 dirty');
{
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-test.md';
// 直接 addTask 触发 _markDirty 路径
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 用内部 API 触发一次"修改",让 _markDirty 把 _dirtyVersion 推到 1
store.addTask('未分类', 'A');
ok('首次 addTask 后 dirty 为 true', store.dirty === true);
ok('首次 addTask 后 _dirtyVersion = 1', store._dirtyVersion === 1);
// 把 debounce 定时器立刻 flush 掉,让 _doAutoSave 走到 await writeFile 那一行
store._autoSave.flush();
// 此时 writeFile 已发起calls.writeFile 应为 1_doAutoSave 还在等 resolver
ok('flush 后 writeFile 被调用一次', calls.writeFile === 1);
// 「用户在 await 期间又改东西」:再次 addTask —— 只走 _markDirty不推 _writeGeneration
store.addTask('未分类', 'B');
ok('await 期间 addTask 后 dirty 仍为 true', store.dirty === true);
ok('await 期间 addTask 后 _dirtyVersion = 2', store._dirtyVersion === 2);
// 关键_writeGeneration 仍是 1没有新 save 启动)—— 这是 bug 触发条件
ok('_writeGeneration 仍为 1没有新 save 接力)', store._writeGeneration === 1);
// 释放 writeFile —— 让第一次 _doAutoSave 醒来
writeFileResolver({ ok: true });
// 给 microtask 几次 tick 让 await 后的代码跑完
await new Promise(r => setTimeout(r, 20));
// 关键断言dirty 必须仍为 true —— 否则 B 永远写不出去
if (store.dirty === true) {
ok('await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}, _dirtyVersion=${store._dirtyVersion}`);
}
}
// ============================================
// [2] saveNow 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[2] saveNow 路径下「await 期间修改」必须保留 dirty');
{
// 重置桩
calls.writeFile = 0;
let resolver2;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver2 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-saveNow.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 启动 saveNow不 await —— 让它走到 await writeFile 后挂起)
const savePromise = store.saveNow();
ok('saveNow 启动后 writeFile 被调用', calls.writeFile === 1);
// await 期间模拟用户改动
store.addTask('未分类', 'X');
ok('await 期间 addTask 后 dirty 为 true', store.dirty === true);
ok('await 期间 _dirtyVersion 已 +1', store._dirtyVersion === 1);
// 释放 + 等 saveNow 收尾
resolver2({ ok: true });
await savePromise;
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('saveNow await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('saveNow await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}`);
}
}
// ============================================
// [3] 正常路径await 期间无修改 → 仍正确清 dirty防止误伤
// ============================================
console.log('\n[3] 正常路径(无并发修改)必须正确清 dirty —— 别把修复写成永不清');
{
calls.writeFile = 0;
let resolver3;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver3 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-clean.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
let savedEmitted = false;
store.on('saved', () => { savedEmitted = true; });
store.addTask('未分类', 'Y');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 不再做任何修改,直接释放
resolver3({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === false) {
ok('正常路径下 dirty 被正确清掉', true);
} else {
bad('正常路径下 dirty 被正确清掉', `dirty=${store.dirty}`);
}
if (savedEmitted) {
ok('正常路径下 emit 了 saved 事件', true);
} else {
bad('正常路径下 emit 了 saved 事件');
}
}
// ============================================
// [4] cancelPendingSave 仍要让 in-flight 过期(回归测试现有行为)
// ============================================
console.log('\n[4] cancelPendingSave 让 in-flight 写过期dirty 保留');
{
calls.writeFile = 0;
let resolver4;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver4 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-cancel.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', 'Z');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 模拟「文件被外部改了」 → 取消 pending
store.cancelPendingSave();
// _writeGeneration 在 cancelPendingSave 里被 +1所以已不再是 1
ok('cancel 后 _writeGeneration 已被推过in-flight 失效)', store._writeGeneration > 1);
// 释放 writeFile —— 已取消dirty 必须保留
resolver4({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('cancel 后 dirty 被保留', true);
} else {
bad('cancel 后 dirty 被保留', `dirty=${store.dirty}`);
}
}
// ============================================
// [5] v4 全实时同步:单次 addTask 立即触发 writeFile无 800ms 等待)
// ============================================
console.log('\n[5] 单次 addTask 立即触发 writeFile无 debounce 延迟)');
{
calls.writeFile = 0;
let resolver5;
globalThis.window.api.writeFile = (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { resolver5 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/real-time-sync.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', '即时');
// 关键addTask 一返回 writeFile 就已经发起 —— 不再等 800ms
ok('addTask 后立即触发 writeFile无 debounce 延迟)', calls.writeFile === 1,
`calls.writeFile=${calls.writeFile}`);
// 收尾:释放 resolver 让 save 完成
resolver5({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('保存完成后 dirty 被清掉', store.dirty === false, `dirty=${store.dirty}`);
}
// ============================================
// [6] v4 尾随写in-flight 期间多次 addTaskfinally 自动补一次写覆盖最新状态
// ============================================
console.log('\n[6] in-flight 期间累积改动 → finally 自动补一次写(覆盖最新状态)');
{
calls.writeFile = 0;
// 用队列收集 resolver避免后续尾随写无法 release 导致悬挂
const resolvers = [];
globalThis.window.api.writeFile = (_path, content) => {
calls.writeFile++;
const capturedContent = content;
return new Promise((resolve) => {
resolvers.push({ resolve, capturedContent });
});
};
const store = new TaskStore();
store.filePath = '/tmp/trailing-save.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 第 1 次:直接 addTask 触发第一次 writeFile
store.addTask('未分类', 'A');
ok('addTask A 后 writeFile 被立即调用', calls.writeFile === 1);
// in-flight 期间:连续改 3 次 —— 应走 _needsResave 路径,**不**起新 writeFile
store.addTask('未分类', 'B');
store.addTask('未分类', 'C');
store.addTask('未分类', 'D');
ok('in-flight 期间 3 次 addTask 后 writeFile 仍 = 1被合并',
calls.writeFile === 1, `calls.writeFile=${calls.writeFile}`);
ok('in-flight 期间 _writeGeneration 仍 = 1没新 save 启动)',
store._writeGeneration === 1);
ok('in-flight 期间 _dirtyVersion = 4addTask 4 次)',
store._dirtyVersion === 4);
// 释放第一次写finally 应触发尾随写
resolvers[0].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('第一次释放后 writeFile 被尾随启动一次(共 2 次)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
ok('尾随写启动后 _writeGeneration = 2',
store._writeGeneration === 2);
ok('第一次释放后 dirty 仍 = true尾随写尚未完成',
store.dirty === true);
// 释放尾随写:这次应包含 ABCD 全部内容
const trailingContent = resolvers[1].capturedContent;
ok('尾随写的内容含 A/B/C/D 全部 4 条任务',
trailingContent.includes('A') &&
trailingContent.includes('B') &&
trailingContent.includes('C') &&
trailingContent.includes('D'),
`内容预览: ${trailingContent.slice(0, 200)}`);
resolvers[1].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('尾随写完成后 dirty 被清掉', store.dirty === false,
`dirty=${store.dirty}`);
ok('无更多 _needsResave → writeFile 共 2 次(不多次重复)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);