fast-check 4.2.0 新特性:用 waitIdle 与 waitNext 让竞态条件检测更可预测
fast-check 4.2.0 新特性:用 waitIdle 与 waitNext 让竞态条件检测更可预测
fast-check 4.2.0 围绕 fc.scheduler 引入了两项关键新原语:waitIdle(更完善的 waitAll)与 waitNext(更完善的 waitOne),用于在竞态条件检测中更稳定地等待调度器真正完成。本文结合仓库源码与测试用例,深入讲解这两个 API 的用法、底层实现原理、与旧 API 的差异,以及从 4.1.0 到 4.2.0 的完整变更清单。
背景:为什么 waitOne / waitAll 变得难以驾驭
自 fast-check 4.0.0 起,官方决定让 waitOne 和 waitAll 的行为变得可预测:移除了它们原先实现中一个微妙的"陷阱(gotcha)"——旧实现的结果会依赖"调度下一个任务之前需要等待多少个微任务(micro-task)"。这一改动消除了阈值效应,但副作用是这两个 API 变得更难使用,竞态条件检测的测试代码也因此更难编写。
相比之下,waitFor 一直保持易用——只是它要求使用者自己构造一个"可以等待的东西"(一个目标 Promise)传进去。4.2.0 正是要在"易用"与"可预测"之间重新取得平衡,于是引入了 waitIdle 与 waitNext 两个新原语。这两个原语在 Scheduler 接口中的完整定义可见于 Scheduler 接口源码,其中 waitIdle 与 waitNext 均标注为 @remarks Since 4.2.0。
waitIdle:一个更好的 waitAll
API 签名
waitIdle: (customAct?: SchedulerAct) => Promise<void>;
waitIdle 提供了一种简单、高效且可预测的方式来等待 fc.scheduler 调度出的所有任务执行完毕。它没有旧版本 waitAll 的那些"陷阱",同时 API 用法几乎一样。官方对该方法的语义描述(见 Scheduler 接口源码)要点如下:
- 等待调度器进入空闲状态:所有已调度且可达(reachable)的任务都已完成;
- 它会递归地包含其他任务新调度出来的任务;
- 注意:由不可控来源(如
fetch或外部事件)触发的任务无法被检测或等待,可能导致等待不完整; - 若想等待某个精确事件,应优先使用
waitFor或waitNext,因为它们对"究竟在等待什么"提供更细粒度的控制。
用官方示例对比 waitAll 与 waitIdle
原文档给出了同一份测试在两种写法下的对比。先看自 4.2.0 起推荐的写法(waitIdle):
import fc from 'fast-check';
import { test, expect } from 'vitest';
test('our test', async () => {
await fc.assert(
fc.property(fc.scheduler(), async (s) => {
const fetchIdFor = s.scheduleFunction(async (name) => `id:${name}`);
const { doStuff, warmup } = buildDoStuff(fetchIdFor);
await warmup();
let done = false;
doStuff('name').then(() => (done = true));
await s.waitIdle();
expect(done).toBe(true);
}),
);
});
// 在真实项目中,下面的函数通常会定义在测试文件之外
// 的独立模块里,这里为便于演示放在同一文件。
function buildDoStuff(fetchIdFor) {
return {
doStuff: async function doStuff(name) {
const { default: executeTaskOnId } = await import('./executor');
await executeTaskOnId(await fetchIdFor(name));
},
warmup: async function warmup(name) {
await import('./executor');
},
};
}
再看 4.x 早期版本的写法(waitAll,注意当时异步属性还是 fc.asyncProperty):
import fc from 'fast-check';
import { test, expect } from 'vitest';
test('our test', async () => {
await fc.assert(
fc.asyncProperty(fc.scheduler(), async (s) => {
const fetchIdFor = s.scheduleFunction(async (name) => `id:${name}`);
const { doStuff, warmup } = buildDoStuff(fetchIdFor);
await warmup();
let done = false;
doStuff('name').then(() => (done = true));
await s.waitAll();
expect(done).toBe(true);
}),
);
});
function buildDoStuff(fetchIdFor) {
return {
doStuff: async function doStuff(name) {
const { default: executeTaskOnId } = await import('./executor');
await executeTaskOnId(await fetchIdFor(name));
},
warmup: async function warmup(name) {
await import('./executor');
},
};
}
原文档明确指出:上面这个测试在 waitAll 版本下无法通过。原因在于:doStuff 中的 fetchIdFor 调用被延迟得太久,waitAll 根本看不到它——实际上没有任何任务在期限内被调度,于是 waitAll 立即返回。此时若在 waitAll 执行后调用 s.report() 检查输出,可以确认:调度器尚未释放任何任务、也尚未"见过"任何任务。整体来看,waitAll 让测试变得更难推理:它可能因为依赖微任务数量的、不明显的原因而失败。
换成 waitIdle 后测试就能通过:调用会被真正触发,调度器会调度出包括 fetchIdFor 那次调用在内的全部预期任务。
底层实现:waitIdle 是如何做到的
两个新原语都建立在内部方法 internalWaitFor 之上(见 SchedulerImplem 实现)。internalWaitFor 内部维护一个"等待者(awaiter)"循环:每当调度器有新任务被注册(通过 scheduledWatchers 通知机制),就会启动一轮等待者;等待者会先消耗固定数量的微任务(源码中 numTicksBeforeScheduling = 50,见 SchedulerImplem.ts#L12),给后续异步调度留出时间,然后再释放一个已调度的任务,如此反复。
waitIdle 的实现(SchedulerImplem.ts#L387-L396)与 waitFor 的关键差异在于两个参数:
waitIdle(customAct?: SchedulerAct): Promise<void> {
let resolver: (() => void) | undefined = undefined;
const awaited = new Promise<void>((r) => (resolver = r));
return this.internalWaitFor(awaited, {
customAct,
onWaitStart: undefined,
onWaitIdle: resolver, // ← 空闲时触发
launchAwaiterOnInit: true, // ← 一开始就启动等待者
});
}
onWaitIdle: resolver:当等待者确认"目标 Promise 尚未解决、且当前没有已调度任务可释放"时,会回调onWaitIdle(见internalWaitFor中if (!taskResolved && onWaitIdle !== undefined) onWaitIdle();),从而结束waitIdle的等待;launchAwaiterOnInit: true:即使调用时刻没有任何已调度任务,也立即启动等待者循环,这正是它比waitAll更能"等到后到任务"的关键——waitAll的实现只是简单的while (this.scheduledTasks.length > 0) await this.waitOne(...)(SchedulerImplem.ts#L239-L243),一旦调度队列为空就立刻结束,不会给"迟到的调度"留机会。
测试用例佐证 waitIdle 的行为边界
仓库在 SchedulerImplem.spec.ts 中为 waitIdle 设计了五类测试,恰好勾勒出它的能力边界:
- 没有任务可调度时:
await s.waitIdle()应正常返回,s.count()为 0; - 立即调度的任务:5 个
s.schedule(Promise.resolve(i))全部执行完毕; - 经由多层微任务才调度的任务:任务经过 4 个
.then(v => v)微任务后才被s.schedule注册,waitIdle依然能全部等到——这正是它优于waitAll的典型场景; - 递归调度的任务:每个任务完成后又调度下一个任务(
previous = previous.then(...)),waitIdle会递归等待到底; - 不可控的 Promise:一个不被调度器控制的
uncontrollablePromise 在 resolve 后才触发s.schedule(...),waitIdle不会傻等它——测试断言waitIdle返回时count()为 0,随后手动 resolve 该 Promise 并再等一个 tick 后count()才变为 1。这印证了接口文档中"由不可控来源触发的任务可能无法被等待"的提示。
waitNext:一个更好的 waitOne
与 waitIdle 思路一致,官方同时创造了 waitNext。它不是等待"全部完成",而是精确等待恰好 N 个任务被调度执行——无论具体是哪些任务。可以把它理解为 waitOne 的增强版:既增加了"等待任务真正被调度"的能力,又支持按数量等待。
API 签名
waitNext: (count: number, customAct?: SchedulerAct) => Promise<void>;
底层实现
waitNext 的实现(SchedulerImplem.ts#L366-L385)复用同一套 internalWaitFor 机制,但回调方向相反:
waitNext(count: number, customAct?: SchedulerAct): Promise<void> {
let resolver: (() => void) | undefined = undefined;
let remaining = count;
const awaited =
remaining <= 0
? Promise.resolve()
: new Promise<void>((r) => {
resolver = () => {
if (--remaining <= 0) {
r();
}
};
});
return this.internalWaitFor(awaited, {
customAct,
onWaitStart: resolver, // ← 每释放一个任务就计数一次
onWaitIdle: undefined,
launchAwaiterOnInit: false,
});
}
onWaitStart: resolver:等待者每释放一个已调度任务(internalWaitFor中调用waitOne之前回调onWaitStart),就将remaining减一,直到归零时结束等待;- 若
count <= 0,直接返回已解决的 Promise(立即结束); launchAwaiterOnInit: false:与waitIdle不同,它不需要"预先占位",因为它的目标是明确数量的任务。
测试用例佐证
SchedulerImplem.spec.ts 中的 waitNext 测试 覆盖了两个关键行为:
- 精确释放 N 个:预先调度 10 个任务后调用
await s.waitNext(n),断言s.count()等于10 - n——仍然有10 - n个任务处于 pending; - 等待迟到的任务:任务经由
queue.then(() => delay(0)).then(() => s.schedule(...))链条逐个注册(并非调用时全部就绪),waitNext(n)仍能等到第 n 个任务被调度并执行,断言seenValues等于[0..n-1]——说明它与waitIdle一样,会为"后到任务"留出微任务窗口。
迁移指南:waitOne / waitAll 已弃用
4.2.0 起,waitOne 与 waitAll 被标记为废弃(@deprecated)。接口源码中的弃用说明(见 Scheduler 接口源码)分别是:
waitOne:@deprecated Use waitNext(1) instead, it comes with a more predictable behavior;waitAll:@deprecated Use waitIdle() instead, it comes with a more predictable behavior awaiting all scheduled and reachable tasks to be completed。
迁移文档 from-3.x-to-4.x.md 中给出了明确的选型建议:
- 任务数量已知 →
waitNext(n); - 等待所有任务落定 →
waitIdle(); - 等待某个特定任务(哪怕它尚未被调度)→
waitFor(promise)。
waitFor 与两个新原语一样,都能应对"调用时任务尚未调度"的场景。进阶竞态检测指南 race-conditions.md 对此有完整说明:例如 supertest 这类测试框架只有在用户真正调用 then 时才会触发后续请求,waitFor 就是为了等待这类"尚未被调度"的调用而设计。
使用技巧:与 act 结合
fc.scheduler 的约束项 act 用于保证所有被调度的任务都在正确的上下文中执行(例如 React 测试中的 act),定义见 scheduler.ts。新的 waitIdle 与 waitNext 都保留了可选的 customAct 参数,可以按需覆盖等待层级的行为。
一个典型模式(摘自 race-conditions.md):给 fc.scheduler({ act }) 传入全局 act,然后在属性内部用 await s.waitIdle() 等待全部任务;当被测代码依赖 setTimeout / setInterval 等原生定时器时,还可以构造一个专门的 buildWrapWithTimersAct(s) 包装器,把"推进定时器"也变成可调度的任务,并通过 await s.waitIdle(buildWrapWithTimersAct(s)) 一并等待(完整实现见 race-conditions.md 的定时器示例)。
4.2.0 的其他变化
除了两个新原语,4.2.0 还包含以下值得关注的变化(对应 PR 编号见原文档 Changelog):
act中弹出的错误不再被静默吞掉:此前包裹任务的act若抛错可能被悄悄忽略,此版本起错误会被正确传播(PR#5953);- 修复调度器执行期间任务重叠:避免同一时刻释放多个任务(PR#5900);
- 性能优化:
scheduler通过显式undefined检查略微提速(PR#5901); - 测试与 CI 调整:停止在 Node 18 上测试(PR#6019)、为 Windows runner 分片 Vitest 执行、在 Mac OS 上测量覆盖率等。
从 4.1.0 到 4.2.0 的完整变更清单
4.2.0 基于 4.1.1 发布,以下是相对 4.1.0 的完整变更(PR 编号与原文档一致):
Features
- (PR#5953) 不再静默
act中弹出的错误 - (PR#5890) 为
scheduler引入新的等待器(awaiter) - (PR#6016) 引入
waitIdle,作为scheduler上重构版的waitAll - (PR#6026) 弃用
waitOne与waitAll
Fixes
- (PR#5900) Bug:避免
scheduler执行期间任务重叠 - (PR#5903) CI:仅在 Ubuntu + Node 22 上运行覆盖率
- (PR#5904) CI:在 Windows runner 上分片 Vitest 执行
- (PR#5907) CI:始终发布到 pkg-pr-new
- (PR#5935) CI:移除 LFS 存储
- (PR#5936) CI:通过哈希校验实现更安全、更快的静态资源
- (PR#5943) CI:阻止 stale 机器人关闭已确认的想法
- (PR#5954) CI:使 poisoning 测试兼容 Node 24
- (PR#5969) CI:在 Mac OS 上测量覆盖率
- (PR#5971) CI:为 Vitest 提供更好的排除列表
- (PR#5989) CI:尝试稳定测试
- (PR#5894) Doc:4.1.0 发布说明
- (PR#5937) Doc:在博客作者页引用社交媒体链接
- (PR#5938) Doc:修复 /docs 与 /blog 的社交图片
- (PR#5965) Doc:更新 stringMatching 文档
- (PR#5966) Doc:修复 model-based-testing.md 中的笔误
- (PR#6015) Doc:新增贡献者 matthyk
- (PR#5973) Test:移除测试中未使用的检查
- (PR#6019) Test:停止针对 Node 18 测试
- (PR#5901) Performance:通过显式
undefined检查略微提升scheduler速度
总结
4.2.0 的核心价值在于:把"等待调度器"这件事从玄学变成工程。waitIdle 与 waitNext 基于同一套 internalWaitFor 微任务感知机制,让测试不再依赖"恰好多少个微任务之后才轮到下一次调度",从而大幅降低竞态条件检测测试的脆弱性。对于新代码,直接使用 waitIdle() / waitNext(n) / waitFor(promise) 即可;对于存量代码,按迁移文档将 waitAll 替换为 waitIdle、将 waitOne 替换为 waitNext(1),即可获得更稳定、更易推理的竞态检测体验。如需完整理解这三个原语与 schedule / scheduleFunction / scheduleSequence 的配合方式,可继续阅读 竞态条件检测进阶指南 与 3.x 到 4.x 迁移文档。