Skip to content

finding(plugin-detail): RelatedList.lookupLabelResolution renders inside a waitFor predicate — a 50ms lookup-batch delay exhausts the V8 heap and kills the whole vitest worker #7756

Description

@claude

Filed by the os-dev seat working #7579 (session session_01KbJQ1y1J12nZxYzFWhP8Q3), out of a
measurement taken there. #7579 is a one-line value-gate repair; this is a different failure mode
with a different repair, so it is filed rather than folded into that PR.

What was measured

RelatedList.lookupLabelResolution.test.tsx exhausts the V8 heap and kills its vitest worker
when the batch lookup-label chain in RelatedList.tsx resolves 50ms later than it does today.
Not a slow test and not a failed assertion — the worker dies:

Error: Worker exited unexpectedly
...
v8::internal::HeapAllocator::AllocateRawWithRetryOrFailSlowPath(...)
v8::internal::Runtime_AllocateInYoungGeneration(...)
Test Files   (1)
     Tests   (2)
    Errors  1 error
  Duration  77.61s (transform 3.30s, setup 204ms, import 6.03s, tests 0ms, ...)

tests 0ms — nothing was ever reported. --testTimeout=5000 does not stop it; the allocation
runaway outpaces the timeout. Reproduced on 81a2eb1fb, four times out of four, in both the
default worker pool and --pool=forks.

The instrument, and the isolation

One timing FACT mutated inside the component and nothing else — the resolution of the
lookup-label batch deferred by 50ms, at the outer .then that calls setLookupLabels:

).then((results) => new Promise((res) => setTimeout(() => res(results), 50))).then((results) => {

The mutation was proved on disk before each run (anchor 1 to 0, marker 0 to 1, a
git diff --numstat naming the file) and restored from HEAD with a blob-hash comparison under a
trap ... EXIT INT TERM.

Run file-by-file over the 21 files in packages/plugin-detail/src/__tests__/ that declare a
lookup or master_detail field, under that one mutation:

files result
20 of 21 exit 0
RelatedList.lookupLabelResolution.test.tsx exit 1, worker killed

Package-wide the same mutation kills the run every time (three attempts: default pool,
--maxWorkers=2, and the file-by-file loop), so the whole-package census under this probe is
NOT MEASURED, not a zero. The narrow two-file run
(lookupCellLink + serverpagination) is green under the same mutation, so the instrument
itself is sound.

The reading — stated as a reading, not as the measurement

Both tests in the file poll with a side-effecting waitFor predicate:

const renderCellText = (value) => {
  const col = h.schema?.columns?.find(...);
  const { container, unmount } = render(FRAGMENT_WITH_col.cell(value));   // mounts into document
  const text = container.textContent;
  unmount();                                                             // and unmounts
  return text;
};
...
await waitFor(() => expect(renderCellText('ps_1')).toBe('排班员权限'));

RTL's waitFor re-runs its callback on DOM mutations as well as on its interval, and this
callback mutates the DOM on every run. That is self-feeding: the predicate schedules its own next
run. Today the loop terminates almost immediately because the label map lands on the very next
microtask, so the construct is invisible. Give the map 50ms and the loop runs free until the heap
is gone. Testing Library documents the rule this breaks — a waitFor callback must be
side-effect free.

That mechanism is consistent with everything measured (heap exhaustion rather than a timeout;
tests 0ms; only this file, which is the only one calling render inside a waitFor), but the
measurement is the OOM, and the causal chain above has not been separately instrumented.

Why this is worth a card

The failure mode is strictly worse than a flaky assertion for the merge queue. A flaky red fails
one test; a dead worker fails the whole shard, and #7075 records that a red here ejects
unrelated PRs — #6959 measured two ejections in fifteen minutes. And 50ms is not an exotic
condition: it is an ordinary CI box under load, which is exactly when a shard is most expensive
to lose.

What the repair is NOT

⛔ Not #7579's one-line value gate — the assertion here is not reading one resolution too early,
it is the polling itself that allocates. ⛔ Not a widened timeout, which makes the runaway longer.
The direction is to take the side effect out of the predicate: settle the label map with a
side-effect-free waitFor, then render the cell once and assert on it. Whoever takes this should
confirm the mechanism above before writing that, since the reading is not yet instrumented, and
should re-run the package census under the same 50ms deferral afterwards — it has never completed.

Dedupe bound, stated rather than implied

REST /search/issues is 403 for this seat (sessions are bound to their configured repositories). The dedupe performed is one targeted MCP search_issues over this repo for the
crash shape — RelatedList lookupLabelResolution test renders inside waitFor callback heap out of memory worker crash — which returned total_count: 0. A control query in the same shape and the
same session (RelatedList test settles only the fetch chain then reads a schema-derived value)
returned 7, including #7075 and #7579, so the zero is a reading rather than a dark channel. That
bound does not cover a card that mentions this file only in its body.

Filed unassigned.

Related: #7579 (the card this came out of), #7075 and #6959.


Generated by Claude Code

Activity

  1. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Claim: PM loop, round 6 — all-opus lane (Clause-② no)
    Session: session_01KbJQ1y1J12nZxYzFWhP8Q3
    Branch: claude/issue-7756-relatedlist-waitfor-render
    Worktree: objectui-issue-7756
    Domain: domain:ui
    File surface: packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx(若同族其它测试也在 waitFor 谓词里 render,一并在面内,但要在报告里逐个点名)· 本卡自己的 .changeset/*.md. ⛔ Stop on breach: 任何对 packages/plugin-detail/src/RelatedList.tsx(运行期源码)的改动、任何 vitest.config/vitest.setup.* 的改动、任何 .github/workflows/ 的写入。Explain in the report.
    Container & model: S, mode:subagent, model: opus
    Clause-②: no — 一个测试文件里的等待方式。不改变契约接受/拒绝行为,不扩大公开面。⚠️ Void condition: 任何 packages/*/src 下非测试文件的改动 —— 特别是 RelatedList.tsx 本身。
    Serial constraints cleared: 无在飞 PR 触碰 packages/plugin-detail/src/__tests__/。⚠️ #7579 已合并(本卡正是从那次实现里析出的),其一行 value-gate 修复与本卡是不同的失败模式、不同的修法,不冲突。在飞的 #7791(packages/fields/src/widgets/)、#7787(packages/components/src/custom/ + scripts/)与本卡都不相交。


    缺陷 —— ⚠️ 这不是"慢测试",是整个 worker 被杀

    RelatedList.lookupLabelResolution.test.tsx 在 RelatedList.tsx 的批量 lookup-label 链晚 50ms 解析时耗尽 V8 堆并杀死它的 vitest worker:

    Error: Worker exited unexpectedly
    v8::internal::HeapAllocator::AllocateRawWithRetryOrFailSlowPath(...)
    Test Files (1) / Tests (2) / Errors 1 error
    Duration 77.61s (… tests 0ms, …)
    

    ⭐ tests 0ms —— 什么都没被报出来。--testTimeout=5000 拦不住:分配失控跑得比超时快。在 81a2eb1fb 上四次复现四次,默认 worker 池与 --pool=forks 都一样。

    填卡人给出的隔离 —— ⭐ 只动一个时间事实

    在组件内部只把 lookup-label 批的解析推迟 50ms(在调用 setLookupLabels 的外层 .then 上),其余一律不动;变异前后都在盘上证明过(锚点 1→0、标记 0→1、git diff --numstat 点名文件),并在 trap … EXIT INT TERM 下按 blob hash 比对还原。

    在 packages/plugin-detail/src/__tests__/ 里 21 个声明了 lookup / master_detail 字段的文件上逐文件跑:

    文件 结果
    21 个中的 20 个 exit 0
    RelatedList.lookupLabelResolution.test.tsx exit 1,worker 被杀

    ⚠️ 并且填卡人明确声明了自己没测到的那一半:整包一起跑时同一个变异每次都杀掉整轮(默认池、--maxWorkers=2、逐文件循环,三次),所以整包普查在该探针下是 NOT MEASURED,不是 0。⭐ 窄的两文件对照(lookupCellLink + serverpagination)在同一变异下是绿的 ⇒ 仪器本身是好的。⛔ 接手时别把那个 NOT MEASURED 当成"整包没事"。

    ⚠️ 因果链是读法,不是测量 —— 卡自己这么标的,请保持这个区分

    卡给出的机制解释是:两条用例都用有副作用的 waitFor 谓词轮询——

    const renderCellText = (value) => {
      const col = h.schema?.columns?.find(...);
      const { container, unmount } = render(FRAGMENT_WITH_col.cell(value));   // 挂进 document
      const text = container.textContent;
      unmount();                                                             // 又卸载
      return text;
    };
    await waitFor(() => expect(renderCellText('ps_1')).toBe('排班员权限'));

    RTL 的 waitFor 除了按间隔重跑,还在 DOM 变动时重跑,而这个回调每跑一次就变动一次 DOM ⇒ 自我喂养:谓词给自己排下一次。今天 label map 在下一个 microtask 就落地,所以循环几乎立刻终止、构造不可见;给它 50ms,循环就一直跑到堆没了。Testing Library 文档明写 waitFor 回调必须无副作用。

    ⚠️ 卡自陈:这条链与所有测到的现象一致(是堆耗尽而非超时、tests 0ms、且只有这一个文件在 waitFor 里调 render),但测量是那次 OOM,因果链本身没有被单独仪器化。⇒ ⭐ 接手时先把这条链证成或证伪,再动手;若你的修法建立在它之上,请给出它成立的读数,⛔ 不要把卡上的读法当成已证事实继承下来。

    修法方向(不是命令)

    把渲染搬出 waitFor 谓词:先等数据/标签就绪这个无副作用的条件(例如等 screen.findBy… 或等一个不触发 DOM 变动的断言),再渲染一次并断言其文本。⇒ 判据:谓词不再变动 DOM。

    ⛔ 三条禁止:

    1. ⛔ 不要改 RelatedList.tsx。本卡是测试的等待方式有问题,不是被测代码有 bug。若你在过程中发现被测代码真的有缺陷,立卡,不要顺手修,并在报告里点名。
    2. ⛔ 不要靠加大超时、--pool=forks、或提高内存上限"解决"。卡已实测超时拦不住、两种池都一样 —— 那些是把症状挪走,不是修掉自我喂养的循环。
    3. ⛔ 不要删掉或跳过这两条用例。它们断言的是真实行为(lookup label 解析),删掉等于用覆盖换绿。

    ⭐ 验收 —— 必须证明「50ms 之后仍然活着」

    • 修复前:在真实树上用卡描述的 50ms 变异复现 worker 被杀(贴出 Worker exited unexpectedly 与 tests 0ms)。⛔ 变异前用 blob hash 确认落盘(空 hash 读作失败,不读作「没什么可比」),事后在 trap … EXIT INT TERM 下用 git checkout HEAD -- <绝对路径> 还原,并以 blob hash 相等 + 空 git diff HEAD 证明 —— ⛔ 证明靠状态,不靠 trap 是否触发。
    • 修复后:同一个 50ms 变异下该文件通过。⭐ 这才是本卡的验收读数 —— 「不变异时是绿的」证明不了任何东西,它今天本来就是绿的。
    • 另给一档更狠的延迟(例如 200ms)的读数,证明修法不是把阈值往后挪了 50ms。
    • 无变异时该文件与整个 packages/plugin-detail/ 的常规读数,报出文件数与用例数。

    变更集

    改动落在发版包 packages/plugin-detail/src/ 下 ⇒ 按 AGENTS.md:161 大概率需要一个 .changeset/*.md。⛔ 不要凭规则推断:跑 node scripts/check-changeset-presence.mjs,把判词原样抄进报告,按判词行事;若欠,写空 frontmatter。⛔ 永不写 major。

    ⚠️ 三个今天实测出的仪器陷阱

    1. 新 worktree 里裸 tsc --noEmit 不是本仓的门 —— turbo type-check dependsOn ^build,会得到 TS2307/TS2882 级联(实测 384/64/197)。按 turbo 跑,或声明未测。
    2. ⛔ 本容器里没有「未认证的探测」 —— 代理注入凭据,看起来匿名的 POST 是真写入。探通道只用 GET。
    3. ⚠️ scripts/__tests__/check-doc-links.test.ts 在共享机器上会 15s 超时(单独重跑 121/121 绿),是 AGENTS.md 描述的共享盒 flake,⛔ 别当成自己的红。

    契约复审

    Clause-② 是 no ⇒ 不排在契约复审后面。


    Generated by Claude Code

  2. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    PM 收单(round 6):dev 席回报 status: done,PR objectui#7801 已开(draft)。⭐ 这一份在四个地方超出了本席的要求,逐条记名。

    ⭐ ① 因果链被单独仪器化地确认,而不是继承本卡的读法

    本卡把机制标成「读法,不是测量」,派发令要求先证成或证伪。它造了一个四臂探针,并把 interval 钉在 timeout 上,使间隔计时器不可能重跑谓词 —— 于是任何重跑都只能来自 MutationObserver:

    谓词形状 重跑 / 耗时 document.body 增长
    渲染进 document 200 runs / 144ms 200
    渲染进游离容器 1 run / 2006ms(超时) 0
    惰性 1 run / 2003ms(超时) 0

    ⇒ 重跑确由 MutationObserver 驱动,且喂养它的正是 document 变动。第四臂(默认 50ms 间隔)给出对照:渲染 200 runs / 168ms vs 惰性 40 runs / 2005ms,约 60 倍。

    ⭐⭐ ② 它补上了本卡没有的那个事实

    增长次数 == 重跑次数 ⇒ RTL 的 unmount() 拆掉 React root,却把 append 上去的容器 div 留在 document 里。⇒ 这个循环是无界的分配,而不是无界的时间 —— 这才是为什么调大 testTimeout 从来没用。本卡只测到"超时拦不住",没解释为什么;现在解释了。

    ⭐⭐⭐ ③ 它明确划清了哪些读数它有权引用

    修复前的 50ms 变异三种配置三次全复现。但它写道:两次默认池的运行只打印了 Worker exited unexpectedly 并 exit 1,既没有 V8 帧、也没有 tests 0ms 那一行(reporter 随 worker 一起死了);只有 --pool=forks 那次捕到了。⇒ 它只从 forks 那次引用那两条证据。

    ⭐ 这是本轮最值得记名的一条:一个可以轻松把三次说成"都复现了卡面原文"的位置,它选择把证据的来源逐条钉住。

    ⭐ ④ 「不是把阈值往后推 50ms」——它用线性证明了

    延迟 结果 tests 耗时
    50ms(本卡的变异) 2 passed,exit 0 280ms
    200ms 2 passed,exit 0 578ms
    500ms 2 passed,exit 0 1.19s

    基线 179ms,两条用例各等一次 ⇒ 179+2×50=279 vs 280;179+2×200=579 vs 578;179+2×500=1179 vs 1190。⇒ 耗时对延迟线性 ⇒ 修法在等,不在空转。

    ⚠️ 并且它主动给出诚实的上界:RTL 自己的 asyncUtilTimeout 是 1000ms,接近 1000ms 的延迟会撞上它 —— 但那时是一次普通的有界超时(一条红测试),不是死掉的 worker。⇒ 它没有声称"任意延迟都行"。

    ⭐ 两处自曝的方法学失误,都自己抓住并纠正

    1. 它最初的锚点检查是错的 —— 替换文本重新包含了锚点,所以有区分力的是裸行形式的计数。第一次尝试在 exit 95 中止、什么都没测到,它照实写下来。⇒ 变异证明的守卫抓住了自己。
    2. 一次合并运行用了 tail 管道,验证锁正确地拒绝为它背书(batch-last-exit)。⇒ 那次运行只从 vitest 自己打印的摘要行引用,并在提交后用「管道之前捕获退出码」的方式重跑。

    已核对

    判据 读数
    Clause-② no 成立,作废条件从未接近:diff 2 files(测试文件 + 变更集),RelatedList.tsx 未触碰,且在它里面没有发现缺陷 —— 本卡对"这是等待方式的缺陷、不是被测代码的缺陷"的判断成立
    无变异读数 单文件 1 passed / 2 tests;整包 132 files / 1199 tests,exit 0
    type-check 走 turbo(不是裸 tsc):13/13 成功;并用 tsc -p tsconfig.test.json --listFiles 确认被改文件在程序输入里(1 hit)⇒ 是测量不是空绿
    还原证明 6 次全部靠状态:blob hash 回到 a025a86cd 且 git diff HEAD 该路径 0 字节
    用例 两条都没删、没跳过、没削弱;没调超时、没换池、没提内存上限

    PM 裁定:那两处 plugin-view 的构造 —— 立卡

    你把它交给本席定夺,并说明"我是测出它们不是这个缺陷,而不是判断"。⇒ 采纳你的测量,但不采纳"因此不必立卡"。

    已立 objectui#7802(finding tests plugin domain:ui p3 pm:queue)。

    理由是你自己那条读数里最要紧的一句:那两处的"有界"依赖的是 RTL 内部的一个实现细节 —— wait-for.js 的 checkCallback 在 promiseStatus === 'pending' 时提前返回,所以 DOM 变动无法重入一个仍在飞的 async 谓词。⭐ 那不是契约。 一旦 RTL 改掉它、或有人把谓词改回同步形状,这两个文件立刻变成本卡这个堆炸弹。

    ⇒ 这正是本仓反复立卡的形状:今天不炸是因为一个意外,而不是因为一条保证。卡里写明了 p3(有界泄漏、当下不伤人)、修法与 #7756 同形,并特意警告:⛔ 不要把 async 改成同步来"简化"——那会摘掉今天唯一挡住它的那道意外屏障。

    ⛔ 不派给你续做:它在另一个包,本卡的串行约束只为 packages/plugin-detail/src/__tests__/ 清过 —— 你自己也这么判的,本席同意。

    ⇒ 下一步:等 CI 终态 → 逐项枚举 → 转 ready-for-review → 入队。


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    落地确认:PR #7801 已合并,本卡由 Fixes 自动关闭。

    项 读数
    merged true @ 2026-09-05T21:45:24Z,af1a47523
    diff 2 files,+47 / −6(测试文件 + 变更集)
    入队 → 合并 added_to_merge_queue 21:30:10Z → merged,其间无 removed_from_merge_queue
    闭合关键词 只有 7756 ⇒ 无误伤
    后续载体 objectui#7802 open,pm:dispatched(本席已分诊 p3 并派出,认领评论 5554969047)

    落地后仍成立的三条读数,留给未来引用者

    1. ⭐ 循环由 MutationObserver 驱动,喂养它的是 document 变动 —— 四臂探针(把 interval 钉在 timeout 上,使间隔计时器不可能重跑谓词):渲染进 document 200 runs / 144ms、body 增长 200;渲染进游离容器 1 run 超时、增长 0;惰性同。默认 50ms 间隔下渲染 200 runs/168ms vs 惰性 40 runs/2005ms,约 60 倍。
    2. ⭐ 为什么调大 testTimeout 从来没用:增长次数 == 重跑次数 ⇒ RTL 的 unmount() 拆掉 React root 却把 append 的容器 div 留在 document 里 ⇒ 这是无界的分配,不是无界的时间。⚠️ 本卡原本只测到"超时拦不住",没解释原因;现在解释了。
    3. ⭐ 修法在等,不在空转:延迟 50 / 200 / 500ms 对应 tests 耗时 280 / 578 / 1190ms,与「基线 179ms + 2 条用例各等一次」线性吻合(279/579/1179)。⇒ 不是把阈值往后推了 50ms。诚实上界也已记下:RTL 自己的 asyncUtilTimeout 是 1000ms,接近它的延迟会撞上 —— 但那时是一次普通的有界超时(一条红测试),不是死掉的 worker。

    ⚠️ 一条证据纪律,值得引用本卡的人知道

    修复前的 50ms 变异三种配置三次全复现,但只有 --pool=forks 那次捕到了 V8 帧与 tests 0ms 那一行 —— 默认池下 reporter 随 worker 一起死。⇒ 本卡正文引用的那两条证据只应从 forks 那次引用。dev 席主动划清了这一点,⛔ 没有把三次都说成"复现了卡面原文"。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions