首页
/ superpowers 条件式等待(Condition-Based Waiting):用条件轮询根治测试中的任意定时竞态

superpowers 条件式等待(Condition-Based Waiting):用条件轮询根治测试中的任意定时竞态

2026-09-06 14:05:48作者:虞亚竹Luna

在 superpowers 技能框架的 systematic-debugging(系统化调试)技能体系中,条件式等待是一项专门针对"时序依赖型缺陷"的支撑技术:它主张用"等待你真正关心的条件成立"来替代"猜测一段固定时间",从而消除由 setTimeout/sleep 造成的 flaky(不稳定)测试。读完本文,你将掌握完整的决策准则、可直接复制的通用 waitFor 轮询实现、三个领域特化等待辅助函数的完整源码,以及一次真实调试会话中"15 个 flaky 测试从 60% 通过率提升到 100%"的修复证据。

背景:任意延迟如何制造竞态条件

flaky 测试的典型病灶是用任意延迟去猜时序condition-based-waiting.md 概述原文):测试代码里写 setTimeout(r, 300) "希望工具 300ms 内启动",结果测试在快速机器上通过、在负载高或 CI 环境下失败,形成只在特定条件下复现的竞态。

这项技术在 superpowers 中的定位并非孤立技巧。在 systematic-debugging/SKILL.md 的"Supporting Techniques"一节中,它与 root-cause-tracing.md(根因回溯)、defense-in-depth.md(纵深防御)并列为系统化调试的三大支撑技术,职责描述为一句话:"Replace arbitrary timeouts with condition polling"(用条件轮询替换任意超时)。这与 SKILL.md 中"当流程调查显示问题确属时序依赖(timing-dependent)时,应实施恰当的处理(retry、timeout、error message)"的规则一脉相承——条件式等待正是"时序问题"这一分支的落地手段。

核心原则(Core principle):等待你真正关心的实际条件,而不是猜测它需要多长时间。

决策准则:什么时候该用条件式等待

原文档给出了一张决策流程图,判断路径非常明确:先看测试是否使用了 setTimeout/sleep;如果是,再判断你测的本身是不是时序行为(如防抖、节流间隔):

digraph when_to_use {
    "Test uses setTimeout/sleep?" [shape=diamond];
    "Testing timing behavior?" [shape=diamond];
    "Document WHY timeout needed" [shape=box];
    "Use condition-based waiting" [shape=box];

    "Test uses setTimeout/sleep?" -> "Testing timing behavior?" [label="yes"];
    "Testing timing behavior?" -> "Document WHY timeout needed" [label="yes"];
    "Testing timing behavior?" -> "Use condition-based waiting" [label="no"];
}

应当使用的场景:

  • 测试中出现了任意延迟(setTimeoutsleeptime.sleep()
  • 测试是 flaky 的(有时通过、有时在高负载下失败)
  • 测试在并行运行时超时
  • 需要等待某个异步操作完成

不应当使用的场景:

  • 你正在测试的就是时序行为本身(debounce 防抖、throttle 节流的触发间隔)——此时固定时长就是被测对象,不能替换
  • 即便必须使用任意超时,也永远要写注释记录 WHY(为什么)

核心模式:从"猜时间"到"等条件"

模式转换只需一步,原文档的对照示例:

// ❌ BEFORE: Guessing at timing
await new Promise(r => setTimeout(r, 50));
const result = getResult();
expect(result).toBeDefined();

// ✅ AFTER: Waiting for condition
await waitFor(() => getResult() !== undefined);
const result = getResult();
expect(result).toBeDefined();

改造前后的本质区别:改造前,50ms 是一个对时长的赌注——条件在 49ms 成立时会白白浪费剩余等待,条件在 51ms 才成立时测试直接失败;改造后,代码等待的对象变成了状态本身getResult() !== undefined),条件一旦成立立即返回,与"实际耗时"彻底解耦。

快速模式参考表

场景 模式
等待事件 waitFor(() => events.find(e => e.type === 'DONE'))
等待状态 waitFor(() => machine.state === 'ready')
等待数量 waitFor(() => items.length >= 5)
等待文件 waitFor(() => fs.existsSync(path))
复合条件 waitFor(() => obj.ready && obj.value > 10)

这张表的隐含规律值得注意:waitFor 的条件函数直接返回目标值本身(事件对象、state 字符串、布尔结果),而非仅返回 true。这使得等待完成后可以直接取回数据,避免二次查询。

通用实现:一个可复制的轮询函数

原文档给出了通用的 waitFor 轮询实现,共三个参数:condition(条件函数)、description(供超时错误信息使用的人类可读描述)、timeoutMs(超时上限,默认 5000ms):

async function waitFor<T>(
  condition: () => T | undefined | null | false,
  description: string,
  timeoutMs = 5000
): Promise<T> {
  const startTime = Date.now();

  while (true) {
    const result = condition();
    if (result) return result;

    if (Date.now() - startTime > timeoutMs) {
      throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
    }

    await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
  }
}

几个实现细节的设计意图:

  • 轮询间隔 10ms:在"及时响应"与"CPU 占用"之间取平衡,后文"常见错误"一节会解释为什么 1ms 是错误答案;
  • 超时不是可选项timeoutMs 参与每次循环判断,超时抛出携带 description 和实际毫秒数的 Error,让失败信息能直接指向"在等什么、等了多久";
  • description 参数的价值:轮询失败时 Timeout waiting for TOOL_RESULT with id=call_123 after 5000ms 远比裸的 Timeout 可诊断,这也是领域特化辅助函数全部携带该参数的原因;
  • 条件函数返回真值即返回该值Promise<T> 的泛型 T 正是条件函数的返回类型,等待与取值一步完成。

领域扩展:三个事件等待辅助函数

通用 waitFor 解决"等一个布尔条件",但在事件驱动的系统中,更常见的诉求是"等某个事件出现""等 N 个事件""等某个满足谓词的事件"。同目录下的 condition-based-waiting-example.ts 提供了来自真实调试会话(Lace 测试基础设施改进,2025-10-03)的三个完整实现:waitForEventwaitForEventCountwaitForEventMatch

waitForEvent:等待特定类型的事件首次出现

完整实现见 condition-based-waiting-example.ts#L20-L44。其核心检查闭包:

const check = () => {
  const events = threadManager.getEvents(threadId);
  const event = events.find((e) => e.type === eventType);

  if (event) {
    resolve(event);
  } else if (Date.now() - startTime > timeoutMs) {
    reject(new Error(`Timeout waiting for ${eventType} event after ${timeoutMs}ms`));
  } else {
    setTimeout(check, 10); // Poll every 10ms for efficiency
  }
};

check();

签名与用法(源自文件内 JSDoc 注释):

export function waitForEvent(
  threadManager: ThreadManager,
  threadId: string,
  eventType: LaceEventType,
  timeoutMs = 5000
): Promise<LaceEvent> { /* ... */ }

// Example:
await waitForEvent(threadManager, agentThreadId, 'TOOL_RESULT');

waitForEventCount:等待某类型事件达到指定数量

完整实现见 condition-based-waiting-example.ts#L60-L89。与 waitForEvent 的差异只有两点:用 filter 收集全部匹配事件、以 matchingEvents.length >= count 作为满足条件;超时错误里额外带上了"当前已收到几个",诊断信息更完整:

const matchingEvents = events.filter((e) => e.type === eventType);

if (matchingEvents.length >= count) {
  resolve(matchingEvents);
} else if (Date.now() - startTime > timeoutMs) {
  reject(
    new Error(
      `Timeout waiting for ${count} ${eventType} events after ${timeoutMs}ms (got ${matchingEvents.length})`
    )
  );
} else {
  setTimeout(check, 10);
}

典型用法(JSDoc 原文示例):等待 2 个 AGENT_MESSAGE 事件(初始响应 + 续写),await waitForEventCount(threadManager, agentThreadId, 'AGENT_MESSAGE', 2)

waitForEventMatch:等待满足自定义谓词的事件

完整实现见 condition-based-waiting-example.ts#L111-L136。当前两个函数只能按 type 匹配,而很多场景需要检查事件数据本身,例如等待某个 id 特定的 TOOL_RESULT

// Wait for TOOL_RESULT with specific ID
await waitForEventMatch(
  threadManager,
  agentThreadId,
  (e) => e.type === 'TOOL_RESULT' && e.data.id === 'call_123',
  'TOOL_RESULT with id=call_123'
);

predicate: (event: LaceEvent) => boolean 加上独立的 description 参数(用于超时错误信息),使同一套 10ms 轮询骨架可以覆盖任意匹配逻辑——这正是把"轮询 + 超时"抽成通用模式的价值:换谓词不换骨架

三个函数的共同结构

从源码结构看,三个辅助函数共享同一骨架:Date.now() 记录起点 → 递归 check() 闭包内先查条件 → 满足则 resolve,超守则 reject,否则 setTimeout(check, 10) 自调度。每次轮询都在循环内重新调用 threadManager.getEvents(threadId) 取新鲜数据,这正是后文"Stale data(陈旧数据)"反模式的直接对策。

真实修复前后对照

文件末尾(condition-based-waiting-example.ts#L138-L158)保留了那次调试会话中一段测试的完整改造记录,是理解这套模式价值最直观的样本:

// BEFORE (flaky):
// ---------------
// const messagePromise = agent.sendMessage('Execute tools');
// await new Promise(r => setTimeout(r, 300)); // Hope tools start in 300ms
// agent.abort();
// await messagePromise;
// await new Promise(r => setTimeout(r, 50));  // Hope results arrive in 50ms
// expect(toolResults.length).toBe(2);         // Fails randomly
//
// AFTER (reliable):
// ----------------
// const messagePromise = agent.sendMessage('Execute tools');
// await waitForEventCount(threadManager, threadId, 'TOOL_CALL', 2); // Wait for tools to start
// agent.abort();
// await messagePromise;
// await waitForEventCount(threadManager, threadId, 'TOOL_RESULT', 2); // Wait for results
// expect(toolResults.length).toBe(2); // Always succeeds

注意改造后的时序链条:先用条件等待确认"2 个工具调用已启动",再做 abort(),再等"2 个工具结果已到"——每一步都锚定在可观测状态上,而不是三个赌时长。abort() 夹在两个条件等待之间,说明这种模式还顺带修正了操作顺序依赖(原写法中 abort() 可能在工具还没真正启动时就执行,行为取决于 300ms 猜得准不准)。

三个经典错误及其修正

原文档"Common Mistakes"一节给出三组 ❌/✅ 对照,每一条都对应 waitFor 实现里的一个具体设计:

❌ 轮询过快setTimeout(check, 1) —— 白白消耗 CPU。 ✅ 修正:每 10ms 轮询一次。

❌ 没有超时:条件永不满足时死循环。 ✅ 修正:永远带超时,并让错误信息清晰(Timeout waiting for ${description} after ${timeoutMs}ms)。

❌ 陈旧数据:在循环开始前把状态缓存到变量里,之后一直检查的是旧快照。 ✅ 修正:在循环内部调用 getter 取新鲜数据。

对照 waitFor 的实现可以一一验证:Date.now()timeoutMs 的组合消除了第二种错误,循环内 const result = condition() 每次重新求值消除了第三种错误(waitForEvent 系列每轮重新 getEvents 同理)。

什么时候任意超时反而是正确的

条件式等待不是教条。原文档给出"任意超时正当"的范例——验证一个已知节拍的工具在部分输出后的行为:

// Tool ticks every 100ms - need 2 ticks to verify partial output
await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition
await new Promise(r => setTimeout(r, 200));   // Then: wait for timed behavior
// 200ms = 2 ticks at 100ms intervals - documented and justified

这里的 200ms 不是猜测:工具本身以 100ms 为节拍 tick,验证"部分输出"客观上需要 2 个节拍,时长是由被测系统的已知时钟推导出来的。原文档同时给出了三个硬性要求:

  1. 先等待触发条件(先 waitForEvent(manager, 'TOOL_STARTED'),再进入计时窗口);
  2. 时长基于已知时序推导,而非拍脑袋;
  3. 附上解释 WHY 的注释(200ms = 2 ticks at 100ms intervals)。

这条规则把"决策流程图"里的分支("Testing timing behavior?" 为 yes)落到了可操作的检查清单上:只有当固定时长是被测对象的一部分时才允许保留,且必须以注释固化推导过程。

实战成效与仓库内的佐证

原文档记录了那次调试会话(2025-10-03)的量化结果:修复跨 3 个文件的 15 个 flaky 测试,通过率从 60% 提升到 100%,执行时间缩短 40%,竞态条件彻底消失。执行时间缩短的机理也符合预期:任意延迟是"固定成本"(哪怕条件 10ms 就满足了也要等满 300ms),条件轮询则在条件成立瞬间返回。

这套模式并非只存在于示例文件中——从 superpowers 仓库自身的测试代码看,同目录体系的 brainstorm-server 测试套件就采用了同一轮询骨架。例如 tests/brainstorm-server/lifecycle.test.js 中的 waitForFilewaitForStartedOutput

async function waitForFile(file, timeoutMs = 3000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (fs.existsSync(file)) return true;
    await sleep(50);
  }
  return fs.existsSync(file);
}

以及 tests/brainstorm-server/lifecycle.test.js#L87-L99 中轮询子进程 stdout 直到出现 server-started 标记的 waitForStartedOutput。两者与本文的 waitFor 是同一模式的变体(deadline 循环 + 固定间隔轮询 + 超时兜底),差别只在轮询间隔(50ms vs 10ms)与满足条件的表达形式——这印证了该模式在不同测试栈(Node 子进程生命周期、事件流系统)中的可移植性:骨架不变,只替换条件函数与轮询粒度。

小结:在系统化调试体系中的位置

条件式等待在 superpowers 的技术栈中承担一个明确的分工:当系统化调试的四阶段流程走到"问题确属时序依赖"这一分支时,它给出标准解法。三句话可以概括全文要点:

  • 原则:等待实际条件,不猜时长;固定延迟只保留给"时序本身即被测对象"的场景,且必须注释 WHY;
  • 实现waitFor 骨架 = 循环内取新鲜数据 + 10ms 轮询 + 带描述与毫秒数的超时错误,waitForEvent/waitForEventCount/waitForEventMatch 是该骨架在事件流上的三个特化;
  • 证据:真实会话中 15 个 flaky 测试通过率 60% → 100%,本仓库 tests/brainstorm-server/ 的测试同样以轮询条件(文件存在、stdout 标记)代替盲目 sleep 来锚定时序。

配合同目录的 root-cause-tracing.md(把 bug 回溯到最初触发点)与 defense-in-depth.md(在找到根因后分层加校验),三个文件共同构成 systematic-debugging/SKILL.md 声明的支撑技术组合;而整个技能框架对"测试质量"的底线则定义在其 Iron Law 中:NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST——条件式等待正是这条铁律在时序类缺陷上的具体展开。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388