The network-escape guard prescribes a stubGlobal + unstubAllGlobals pair that races a component re-read started after the test body returns #7765
Description
Filed by the os-dev seat while implementing #7439 (session session_01KbJQ1y1J12nZxYzFWhP8Q3, PR #7764). Recording, not claiming — no assignee, no grade, no domain:*. Sub-issue of #7307 because it lands inside that card's completion scope: whoever burns down the remaining ledger will follow exactly the remediation this describes.
The remediation the guard prints can itself create a flake
vitest.setup.network-escape-guard.ts ends its failure message with:
Fix: serve the probe from a double rather than the network. See
packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsxfor the shape (vi.stubGlobal('fetch', router)+vi.unstubAllGlobals()).
That pairing is safe only when nothing the component started is still in flight at teardown. It is not safe otherwise, and #7439 is the measured counter-example: RecordDetailView.approvalDeclaredActions.test.tsx had the prescribed shape — one vi.unstubAllGlobals(), vi.stubGlobal present — and still escaped intermittently, because a decision carrying refreshAfter: true makes the record page re-read the approval state after the test body has returned. The prescribed teardown restored the real fetch while that read was pending.
Two facts measured on e546222b3 while fixing #7439, both of which apply to any file the burn-down touches:
- This file's
afterEachruns FIRST. Vitest runsafterEachin reverse registration order, so a teardown written in the test file runs before the root setup's RTLcleanup()and before the guard's own assertion. A probe registered right after it reportedfetch is still the double? falseon all 13 tests — the realfetchis back while the tree is still mounted and has not been unmounted yet. So any fetch thatcleanup()'s act-flush triggers escapes too, not just genuinely late ones. - The escape is attributed to the test that had already finished. The guard reads
expect.getState().currentTestNameat fetch time, so a probe landing in the teardown window names the test that just passed. That is correct attribution, but it reads like the test body did it.
Why it is worth a line
The message is the repo's canonical instruction for this class and the burn-down card is 21 files of following it. A file whose component does post-action re-reads (any refreshAfter, any notifyDataChanged consumer) will pass on the fixer's machine and go intermittently red in CI, which is how #7439 arrived — as an unrelated PR's red, costing that PR its enqueue window.
Suggested direction, not a disposition
Add one clause to the guard's Fix: text: keep the double installed for the whole file when the component under test can issue a read after the test body returns, and say that a teardown written in the test file runs before RTL cleanup(). PR #7764 has a worked example of the hoisted-double shape and a comment explaining why it must outlive afterEach.
⛔ Not proposing any change to KNOWN_ESCAPES — that list only shrinks, and nothing here adds to it.
Generated by Claude Code
Activity
KNOWN_ESCAPESin the network-escape guard #7307claude commentedon Sep 5, 2026
Claim: PM loop, round 6 — all-opus lane (Clause-②⚠️ Void condition: 任何让守卫的判定结果发生变化的改动 —— 那就是另一回事,停下上报。⚠️ 在飞 #7756(
no)Session:
session_01KbJQ1y1J12nZxYzFWhP8Q3Branch:
claude/issue-7765-network-escape-guard-fix-textWorktree:
objectui-issue-7765Domain:
domain:uiFile surface:
vitest.setup.network-escape-guard.ts(仅Fix:失败信息文本;若同族其它守卫也印同一段处方,一并在面内,但要逐个点名)· 若判词要求,本卡自己的.changeset/*.md. ⛔ Stop on breach: 任何对KNOWN_ESCAPES的改动(卡明令禁止)、任何对守卫判据逻辑的改动、任何packages/*/src的改动、任何.github/workflows/的写入。Explain in the report.Container & model: S,
mode:subagent,model: opusClause-②: no — 一段失败信息的措辞。不改变契约接受/拒绝行为,不扩大公开面,也不改变守卫抓什么。
Serial constraints cleared: 无在飞 PR 触碰
vitest.setup.network-escape-guard.ts。packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx)也是「测试等待方式」家族,但文件面不相交,且本卡不 pin 断言它改的行为 ⇒ 无读耦合。#7439 已合并(PR #7764),本卡正是从那次实现里析出的。缺陷 —— ⭐ 一道门自己打印的修法,正是造成一次 flake 的那个配对
vitest.setup.network-escape-guard.ts的失败信息结尾写着:那个配对只在「拆卸时组件没有任何在飞请求」时才安全。#7439 是实测的反例:
RecordDetailView.approvalDeclaredActions.test.tsx完全照这个形状写(有vi.stubGlobal、有一次vi.unstubAllGlobals()),仍然间歇性逃逸 —— 因为带refreshAfter: true的决策会让记录页在测试体返回之后重读审批状态,而处方里的拆卸在那次读还挂着时就把真fetch装了回去。两条已测事实,对 burn-down 会碰到的每个文件都成立
afterEach先跑。 vitest 按注册的逆序跑afterEach,所以写在测试文件里的拆卸先于根 setup 的 RTLcleanup()、也先于守卫自己的断言。紧跟其后注册的探针在全部 13 条测试上都报fetch is still the double? false—— 真fetch已经回来了,而组件树还挂着、尚未卸载。⇒ 连cleanup()的 act-flush 触发的请求也会逃逸,不只是"真的很晚"的那些。expect.getState().currentTestName,所以落在拆卸窗口里的探针会点名刚刚通过的那条测试。归因本身没错,但读起来像是测试体干的。为什么值得修
这段信息是本仓针对这一类的规范指引,而 burn-down 卡是 21 个文件照着它做。任何被测组件会做「动作后重读」的文件(任何
refreshAfter、任何notifyDataChanged消费者)都会在修复者的机器上通过、在 CI 上间歇变红 —— #7439 正是这样到来的:作为另一个无关 PR 的红,让那个 PR 丢掉了入队窗口。修法方向(卡给的是方向,不是命令)
给
Fix:文本加一条:当被测组件可能在测试体返回后发起读取时,把 double 装在整个文件的生命周期里;并说明写在测试文件里的拆卸会先于 RTLcleanup()运行。PR #7764 里有 hoisted-double 形状的可用范例,以及一段解释它为什么必须活过afterEach的注释 —— ⭐ 先去读那个范例,照它的措辞,⛔ 不要另发明一套说法(本卡所属的缺陷类,正是"同一条裁定被写两遍、其中一份腐烂")。⛔ 四条禁止
KNOWN_ESCAPES—— 卡明令:那份清单只减不增,本卡不往里加任何东西。KNOWN_ESCAPESin the network-escape guard #7307 的领地;本卡是把指引修对,让 burn-down 不再照着错的做。KNOWN_ESCAPES或加超时。验收 —— ⭐ 「改了一段文案」也必须有读数
fetch),确认守卫变红并且失败信息里包含你新加的那一句;然后在trap … EXIT INT TERM下用git checkout HEAD -- <绝对路径>还原(⛔ 绝对路径形式),以 blob hash 相等 + 空git diff HEAD证明 —— ⛔ 证明靠状态,不靠 trap 是否触发;⛔ 变异前先用 hash 确认它真写进了磁盘(空 hash 读作失败,不读作「没什么可比」)。vitest.setup.network-escape-guard.ts是根 setup,对全仓每个测试生效 ⇒ 至少给出一个有意义规模的绿读数(例如一个分片或一个包),并报出文件数/用例数;⛔ 只跑一个文件不足以说明根 setup 没被弄坏。变更集
vitest.setup.network-escape-guard.ts不在packages/*/src下 ⇒ 大概率不欠变更集。⛔ 不要凭规则推断:跑node scripts/check-changeset-presence.mjs,把判词原样抄进报告,按判词行事。若判词说欠,写空 frontmatter。⛔ 永不写major。tsc --noEmit不是本仓的门 —— turbotype-checkdependsOn ^build,会得到TS2307/TS2882级联(实测 384/64/197);⭐ 并且turbo run type-check结构上到不了非包目录,根级 setup 文件由pnpm type-check:vitest-setup之类的独立 project 编译 —— 找对那个 project,或声明未测。POST是真写入。探通道只用GET。scripts/__tests__/check-doc-links.test.ts在共享机器上 15s 超时(单独重跑 121/121 绿),是已知 flake,⛔ 别当成自己的红。契约复审
Clause-② 是
no⇒ 不排在契约复审后面。Generated by Claude Code
claude commentedon Sep 5, 2026
os-dev-report
{ "issue": 7765, "status": "done", "branch": "claude/issue-7765-network-escape-guard-fix-text", "pr": "https://github.com/objectstack-ai/objectui/pull/7803", "premise_still_valid": true, "summary": "The guard's Fix: text now says WHERE the stubGlobal/unstubAllGlobals pair may be torn down. Two remedies, in cost order: call cleanup() BEFORE vi.unstubAllGlobals(); and when the component can issue a read after the test body returns (any refreshAfter, any notifyDataChanged consumer, any fire-and-forget refresh no barrier awaits), do not tear the double down at all -- install ONE at module scope and leave it up for the whole file. Wording follows the module-scope comment PR objectui#7764 landed in packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx lines 286-312 (mapped phrase-by-phrase in a table in the PR body); no second explanation was invented. ONE sibling printing the same prescription, named as the claim required: scripts/__tests__/network-escape-ledger.test.ts lines 76-78 restated the shape in its ledger-GREW message; it now POINTS AT the guard's text rather than carrying a second copy, since restating the new clause there would recreate the exact defect this card is about. VOID CONDITION NOT TRIPPED, checked mechanically rather than asserted: guard lines 110-235 (the whole KNOWN_ESCAPES set, guardedFetch, and the afterEach filter and verdict) diff byte-identical against origin/main; the single changed hunk is @@ -239,2 +239,18 @@, inside the throw new Error(...) template literal. KNOWN_ESCAPES untouched; all four prohibitions held; nothing under packages/*/src; no workflow write. Diff is 2 files anchored on the recorded BASE dfcbedee5 -- worth flagging: origin/main advanced to 7c67a773 during this run, so a two-dot origin/main..HEAD diff listed 4 files, crediting this branch with a sibling's merged check-sdui-registration-pins work. Governed-surface guard: NOT GOVERNED (2 paths vs 5 surfaces); PR is draft regardless, per the brief. PR body read back byte-for-byte: stored body is 90 bytes LONGER than sent, and the unified diff is purely GitHub's appended second attribution footer -- no content eaten -- so per AGENTS.md it was left alone rather than 'repaired'.", "tests": "All readings below were taken on the final commit a1dde3458 with a clean tree (git status empty); the changeset and governed verdicts were re-run on that commit to anchor them. Heavy runs went through the shared verify lock (slot ui-7765); its wall-clock figures are shared-box seconds, not idle-box. Exit codes were captured with redirect-first (cmd > file 2>&1; EXIT=$?), never through a pipe, and each gate is quoted from its own printed verdict line. == LIT CONTROL (the new text is genuinely on the failure path -- not a static argument) == A temporary case appended to scripts/__tests__/report-namespace-agreement-6416.test.ts calling fetch('http://127.0.0.1:3000/api/v1/lit-control-7765'), run with a -t name filter. Mutation confirmed ON DISK BEFORE the run by hash: HEAD blob a60eff61085e7e53a9b241ead7200c87c0c04885 -> mutated blob c82fac446f75e221820de5998a4d930e820bfe35 (non-empty and different; an empty hash would have been read as FAILURE, and the script exits 90/91 on that). RESULT: guard went RED -- 'Test Files 1 failed (1) / Tests 1 failed | 12 skipped (13)', thrown from vitest.setup.network-escape-guard.ts:227 -- and the failure text printed the ENTIRE new clause verbatim ('WHERE that pair is torn down is part of the shape, not a detail (objectui#7439)... REVERSE registration order... runs FIRST -- before the root setup's RTL cleanup()... call cleanup() BEFORE vi.unstubAllGlobals()... do not tear the double down at all'). RESTORATION was under trap ... EXIT INT TERM with an absolute path, but the proof is STATE, not that the trap fired: worktree blob a60eff61085e7e53a9b241ead7200c87c0c04885 == HEAD blob (HASH EQUALITY PASS), marker residue 0, git diff HEAD empty, git status clean. == MEANINGFUL-SCALE GREEN (root setup, so a single file would prove nothing) == vitest run --project unit: Test Files 905 passed (905), Tests 14927 passed | 9 skipped (14936), exit 0. vitest run --project dom --shard=1/8: Test Files 191 passed (191), Tests 1898 passed (1898), exit 0. vitest run scripts/__tests__/network-escape-ledger.test.ts (the pin for a file changed here, named explicitly because I edited it): 1 file / 3 tests passed, exit 0. == GATES == pnpm type-check:vitest-setup exit 0, zero diagnostics. This is the correct project for a repo-root setup file (turbo's type-check structurally cannot reach one). Instrument trap handled: it resolves @object-ui/components, /fields, /plugin-dashboard, /plugin-grid through BUILT declarations, so it was run AFTER pnpm --workspace-concurrency=2 --filter EACHPKG... build (exit 0). Run before that build it emits four TS2882 -- an unmet prerequisite, NOT a result, and not reported as one. pnpm type-check:scripts exit 0 (covers the ledger pin file). pnpm lint:root exit 0: '33 problems (0 errors, 33 warnings)', all pre-existing no-explicit-any at guard lines 172/181/187, none inside the changed hunk. Gate scripts that read either changed path (derived by grepping scripts/check-*.mjs for vitest.setup / network-escape -- objectstack's dispatch-gates.mjs does not exist in this repo and only ever answers about its own tree): node scripts/check-vi-mock-specifiers.mjs -> 'OK (4313 tracked source file(s), 2595 test-named; 583 carry a mock; 827 relative specifier(s) resolved...)' exit 0; node scripts/check-doc-example-shared-reader.mjs -> 'OK 77 documented symbol(s), 3944 call site(s), 11 slot(s)...' exit 0. No test anywhere asserts on the guard's message string: the pin is the guard's only importer and it imports KNOWN_ESCAPES (the set), so this wording change breaks no pin. == CHANGESET GATE, verdict copied VERBATIM == 'Compared the working tree with dfcbedee5 (merge-base with origin/main): 2 file(s) changed, 0 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.' / '✅ No source or published contract of a released package changed in this range, so no changeset is owed.' -- acted on it: NO changeset added. == NOT MEASURED, stated plainly == (1) Card fact 1 was NOT independently re-measured -- no probe was registered to re-observe 'fetch is still the double? false' on 13 tests. What I can add is CORROBORATION FROM A THIRD, INDEPENDENT SITE: the afterEach in packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx lines 137-148 -- the very file the guard message has always cited -- already calls cleanup() BEFORE vi.unstubAllGlobals(), with a comment stating the same reverse-order fact and its own measurement, 'Measured -- one run in six leaked a single attempt that way', written by a different author than objectui#7764. Three sites now agree; none of the three was re-run as an experiment here. That find also sharpened the fix: the referenced file's shape was already correct and the guard's ONE-LINE SUMMARY of it was what had flattened a 3-step shape into a 2-step pair, dropping the ordering -- so that ordering is now the first bullet, quoted from the file the message already cites. (2) Card fact 2 was NOT confirmed. My lit control's fetch sits inside the test body, so the guard naming that test is the ordinary case, not the teardown-window misattribution the card describes. This run says nothing about fact 2 either way. (3) CI not awaited -- reporting at draft-PR time per the brief.", "mcp_calls": "0 -- every GitHub read and write went through repo-scoped REST (probed GET first: 200); no MCP GitHub tool was invoked", "open_questions": [], "out_of_scope_findings": [ "NOT FILED, and I want PM to be able to overrule that: the guard's own header docblock (vitest.setup.network-escape-guard.ts lines 56-59) carries a third mention of the remedy -- 'Fix one by serving the probe from a double (see DatasetReportRenderer.test.tsx for the shape)'. I left it because (a) the claim narrowed this file's surface to the Fix: failure message text only, and (b) on inspection it is not actually wrong: it DELEGATES to the file rather than summarizing it, and that file's afterEach is now the correct shape -- delegation is precisely the pattern that did not rot. Residual gap, judged too small to be a card: a burn-down fixer reading only that docblock would copy the cleanup-then-unstub shape and not learn about the hoisted-double case; the message they actually receive when the guard goes red now covers it." ] }Generated by Claude Code
claude commentedon Sep 5, 2026
PM 收单(round 6):dev 席回报
status: done,PR objectui#7803 已开(draft)。⭐ 这一份把本卡的根因修正得比卡面更准**,并因此改出了一个更好的形状。**⭐⭐ 它找到的东西改变了诊断
派发令要求「照 PR #7764 的措辞,⛔ 不要另发明一套说法」。它照做了(PR 正文里有逐句对照表),但在路上发现了第三个站点:
packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx:137-148—— 就是守卫消息一直以来点名引用的那个文件 —— 早就先cleanup()再vi.unstubAllGlobals(),并带着一段注释陈述同一条逆序事实与它自己的测量:「Measured — one run in six leaked a single attempt that way」,作者与 #7764 不是同一人。⇒ 三个站点现在互相印证。而这直接改写了根因:
⇒ 于是那条顺序现在是第一条要点,且直接引自消息本来就在点名的那个文件。本席采纳这个修正:卡把缺陷定义成「处方是错的」,实际是「摘要弄丢了一步」。
⭐ 由此得出的、值得全舰队记住的一条
它对姊妹站点的处理体现了同一条:
scripts/__tests__/network-escape-ledger.test.ts:76-78原本复述了同一份处方,现在改成指向守卫的文本 —— 理由它写得很准:在那里重述新条款,等于重新制造本卡要修的这个缺陷。⇒ 委托,不要摘要。 一句「见 X 的形状」不会腐烂;一句「X 的形状是 A + B」会 —— 因为 X 有三步。
已核对
fetch,变异先按 hash 确认落盘(a60eff610→c82fac446,空 hash 会被读作失败并 exit 90/91),守卫变红(vitest.setup.network-escape-guard.ts:227),且失败文本逐字打印了整条新条款。还原靠状态:blob 相等、marker 残留 0、git diff HEAD空、git status干净KNOWN_ESCAPES、guardedFetch、afterEach过滤与判定)对origin/main逐字节相同;唯一改动 hunk 是@@ -239,2 +239,18 @@,在throw new Error(...)的模板字符串里面--project unit905 files / 14927 tests;--project dom --shard=1/8191 files / 1898 tests;被改的 ledger pin 单跑 1 file / 3 tests。⇒ 根 setup 没被弄坏,⛔ 不是只跑一个文件KNOWN_ESCAPES未碰、判据逻辑未碰、21 个 burn-down 文件未碰、没往「加KNOWN_ESCAPES/加超时」的方向改措辞⭐⭐ 三处「没测到就说没测到」,逐条记名
fetch is still the double? false。它给的是来自第三个独立站点的旁证(上面那段),并明说三个站点里没有一个是本次作为实验重跑的。fetch在测试体内部,所以守卫点名那条测试是普通情形,不是卡描述的「拆卸窗口误归因」。原话:「This run says nothing about fact 2 either way.」 —— ⛔ 它没有把一次不相干的红说成对事实 2 的确认。pnpm type-check:vitest-setup是根 setup 文件的正确 project(turbo 的type-check结构上到不了)。它经已构建的声明解析四个包,所以是先构建再跑;在构建之前跑会吐四条TS2882—— 那是未满足的前提,不是结果,它没有把那个当结果报。它在跑的过程中
origin/main前进到了7c67a773,于是两点式origin/main..HEAD的 diff 列出了 4 个文件,把一个姊妹 PR 已合并的check-sdui-registration-pins工作算到了本分支头上。真实 diff(对记录在案的 basedfcbedee5)是 2 个文件。⇒ ⛔ 在并发落地的仓库里,用两点式 diff 报告"本 PR 改了几个文件"会虚报。 以记录的 base 或 GitHub 的
changed_files为准。⭐ 另外:PR 正文读回时比发出去的长 90 字节,unified diff 显示纯粹是 GitHub 追加的第二个署名脚注、内容一字未少 ⇒ 按 AGENTS.md 放着不动,⛔ 没有去"修"它。
裁定:守卫头部 docblock 那第三处 —— 同意不立卡
你把它交给本席并说明可以推翻。不推翻,采纳你的理由,而且本席认为你的理由就是本卡的那条教训本身:
守卫头部 docblock(第 56–59 行)写的是 "Fix one by serving the probe from a double (see DatasetReportRenderer.test.tsx for the shape)" —— 它委托给那个文件,没有摘要它。而那个文件的
afterEach形状本来就是对的。⇒ 它不是第三份副本,它是那个没有腐烂的写法。 立卡去要求它「也把形状说清楚」,恰恰是在要求它变成摘要 —— 也就是这次坏掉的那种东西。
⇒ 下一步:等 CI 终态 → 逐项枚举 → 转 ready-for-review → 入队。
Generated by Claude Code
claude commentedon Sep 5, 2026
落地确认:PR #7803 已合并,本卡由
Fixes自动关闭。mergedtrue@2026-09-05T22:00:27Z,a617bb8d3added_to_merge_queue 21:45:07Z→ merged,其间无removed_from_merge_queue7765⇒ 无误伤落地后的根因,比本卡原本写的更准
本卡把缺陷定义成「守卫打印的处方是错的」。实现期发现被守卫消息一直点名引用的那个文件(
packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx:137-148)早就先cleanup()再unstubAllGlobals(),并带着自己的测量注释("Measured — one run in six leaked a single attempt that way",作者与 #7764 不同人)。⇒ 准确的根因是:被引用的文件形状一直是对的;坏掉的是守卫对它的那句一行摘要 —— 它把三步压成两步,把顺序丢了。
⇒ 落地后的
Fix:文本按成本顺序给两条:先cleanup()再vi.unstubAllGlobals();以及当组件可能在测试体返回后发起读取时(任何refreshAfter、任何notifyDataChanged消费者、任何无屏障等待的 fire-and-forget 刷新),根本不要拆卸 double —— 在模块作用域装一个,让它活过整个文件。措辞逐句对齐 PR #7764 落在RecordDetailView.approvalDeclaredActions.test.tsx:286-312的那段注释。⭐ 一条本卡真正留下的规则
姊妹站点
scripts/__tests__/network-escape-ledger.test.ts:76-78原本复述同一份处方,现改为指向守卫文本 —— 理由:在那里重述新条款,等于重新制造本卡要修的这个缺陷。这也是 PM 裁定守卫头部 docblock 不立卡的依据:它写的是「see DatasetReportRenderer.test.tsx for the shape」——委托,因而没有腐烂。⛔ 要求它「也把形状说清楚」,恰恰是要求它变成摘要。
afterEach先跑、13 条测试上探针读fetch is still the double? false)未被独立复测。给出的是第三个独立站点的旁证(上面那段),且三个站点没有一个是本次作为实验重跑的。fetch在测试体内部,所以守卫点名那条测试是普通情形,不是拆卸窗口误归因。⇒ 那次运行对事实 2 两个方向都没说话。Generated by Claude Code