This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -180,3 +180,144 @@ describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d
});
});
// audit R7a P1-19:fs-watcher 轮询改为「自适应间隔 + 不 drop」pending
// 单测覆盖三件事:
// 1. getPollInterval:lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s
// 2. markActivity 刷新 lastChangeAt → 立刻切到 active
// 3. pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完成后立即补一次
describe('fs-watcher 自适应轮询 (audit R7a P1-19)', () => {
it('getPollInterval:初始(lastChangeAt=0,距 now 远)→ QUIET 5000ms', () => {
const w = makeWatcher();
expect(w._test_getPollInterval()).toBe(5000);
});
it('getPollInterval:markActivity 后立即 → ACTIVE 500ms', () => {
const w = makeWatcher();
w._test_markActivity();
expect(w._test_getPollInterval()).toBe(500);
});
it('getPollInterval:markActivity 后等到 ACTIVE_WINDOW 之外 → QUIET', async () => {
const w = makeWatcher();
w._test_markActivity();
expect(w._test_getPollInterval()).toBe(500);
// 模拟时间推进 POLL_ACTIVE_WINDOW_MS + 1:用 setTimeout 等真实时间。
// 2100ms 等得起,测试只跑一次。备选用 vi.useFakeTimers + setSystemTime
// 也行,但 fake timer + markActivity 内部 Date.now() 兼容性有边角问题。
await new Promise((r) => setTimeout(r, 2100));
expect(w._test_getPollInterval()).toBe(5000);
});
it('pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完后立即补', async () => {
// 设计要点:pendingPoll 是**幂等**标志,不是队列。
// - in-flight 期间任何 tick 都只把 pendingPoll 置 true(不重复累计)
// - in-flight 完成后 finally 检查 pendingPoll:true → scheduleNextPoll(0)
// - 紧跟的「补」是 1 次新 scanDir,不是 N 次
// 所以「3 次 tick + resolve」→ scanCalls 期望 2(1 原 + 1 补),不是 3。
//
// 旧版(fixed 2s + pollInFlight drop)的语义是:3 次 tick 期间全部被 drop,
// scanCalls 期望 1(且永远不会补)。这条测试是回归锚点:
// 拿掉 pendingPoll 行为 → scanCalls 在 resolve 后仍为 1,测试失败。
//
// 时序坑:test 里 `await new Promise(r => setTimeout(r, 0))` 是先于
// runPollTick finally 调度的;Node 同 delay 的 setTimeout 严格按调度顺序
// 触发,test 的会先 fire,导致 scanCalls 还在 1 时 expect(2) 失败。
// 修:用一个 polling 等待「scanCalls 达到 2」,让事件循环多跑几个 tick。
let resolveScan;
const scanCalls = [];
const w = createFsWatcher({
getMainWindow: () => null,
resolveDataDir: () => '/data',
scanDir: async () => {
scanCalls.push(Date.now());
// 慢:等我们手动 resolve
await new Promise((r) => { resolveScan = r; });
return { ok: true, entries: [] };
},
});
// 跳过 fs.watch 订阅(测试不依赖真实目录事件)
w._test_setWatchedDir('/data');
// 第一次 tick:in-flight 启动,scanDir 进入 await
w._test_runPollTick();
// 第二次 tick:in-flight 还在,pendingPoll = true,scanDir 不再调用
w._test_runPollTick();
// 第三次 tick:同样 pendingPoll = true(幂等)
w._test_runPollTick();
// 期望 1 次 in-flight scanDir,pendingPoll 期间 0 次额外调用
expect(scanCalls.length).toBe(1);
// 解除 in-flight:第一次 scanDir 完成 → runPollTick finally 触发「补一次」
resolveScan({ ok: true, entries: [] });
// 等「补」的 scanDir 真的发起(scanCalls 长度变 2)。最坏情况:
// in-flight 微任务 + emitFilesChanged 微任务 + setTimeout(0) 调度 +
// setTimeout(0) 触发 + 新 runPollTick 进入 + 新 scanDir push —— 几十 ms 内。
await new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (scanCalls.length >= 2) return resolve();
if (Date.now() - start > 200) return resolve(); // 兜底
setTimeout(tick, 5);
};
tick();
});
expect(scanCalls.length).toBe(2);
// 把「补」触发的第 2 次 scanDir 也 resolve 掉,不让它的 in-flight
// 干扰其它测试
if (resolveScan) resolveScan({ ok: true, entries: [] });
// 关键:runPollTick 的 finally 走「无 pending → scheduleNextPoll(
// getPollInterval())」会挂 5s 后的 timer。不显式清掉的话,timer 触发时
// 又会调一次 scanDir(scanCalls 变 3),且新 scanDir 等到没人 resolve
// 会一直挂着 —— watcher + scanCalls 闭包泄漏到进程结束,vitest 报告
// "tests passed" 但实际上 open handle 没释放。pause 清 dirPollTimer +
// pollPaused,让 watcher 真正失活。
w._test_pauseDirPolling();
});
// audit R7a P1-19 bugfix:pauseDirPolling 必须真正停止轮询。回归锚点:
// - in-flight tick 还在 await scanDir 时调 pause
// - scanDir 完成后 runPollTick finally 走 scheduleNextPoll(...)
// - 旧版(无 pollPaused 闸)→ 仍挂 5s 后才退到的定时器
// - 修后(有 pollPaused 闸)→ 不挂任何新 timer,轮询真停
// 拿掉 pollPaused 检查 → _test_hasPollTimer() 在 resolveScan 后仍返回 true,
// 本测试失败。仅查 scanCalls 长度不够 —— 5s 间隔下 50ms 等不出新 tick,
// 必须直接断言 dirPollTimer 是否被挂。
it('pause 期间 in-flight runPollTick finally 不 re-schedule', async () => {
let resolveScan;
const scanCalls = [];
const w = createFsWatcher({
getMainWindow: () => null,
resolveDataDir: () => '/data',
scanDir: async () => {
scanCalls.push(Date.now());
await new Promise((r) => { resolveScan = r; });
return { ok: true, entries: [] };
},
});
w._test_setWatchedDir('/data');
// 启动一个 in-flight tick(scanDir 进入 await)→ runPollTick 被 setTimeout
// 调度时 dirPollTimer 已被清,所以这一步后 hasPollTimer() = false
w._test_runPollTick();
expect(scanCalls.length).toBe(1);
expect(w._test_hasPollTimer()).toBe(false);
// 暂停:模拟窗口最小化 → pollPaused = true
w._test_pauseDirPolling();
// in-flight scanDir 完成 → runPollTick finally 执行:
// 旧版:scheduleNextPoll(getPollInterval()) 挂 5s 后的 timer
// 修后:pollPaused 短路,dirPollTimer 保持 null
resolveScan({ ok: true, entries: [] });
// 等 microtask + setTimeout(0) 都跑过。50ms 足够覆盖几轮事件循环。
await new Promise((r) => setTimeout(r, 50));
// 核心断言:pause 之后不应该有新的 poll timer 被挂上
expect(w._test_hasPollTimer()).toBe(false);
});
// 配对测试:resume 后必须能再跑轮询。验证 pollPaused 不会让恢复路径失活。
it('resume 后轮询可重新启动', () => {
const w = makeWatcher();
w._test_setWatchedDir('/data');
w._test_pauseDirPolling();
// pause 后调 resume —— 不报错、watchedDir 还在即可(startDirPoll 在
// makeWatcher 里没有真实 scanDir,所以不验证 emit 次数)。
expect(() => w._test_resumeDirPolling()).not.toThrow();
});
});