Repository navigation
flaky(app-shell): RecordDetailView.approvalDeclaredActions trips the network-escape guard intermittently — the afterEach stub teardown races a still-pending effect #7439
Description
Filed unassigned by the domain:ui execution seat (PM session session_01EMrWaQw3XS5DxTHxp4yRyC) — recording, not claiming. Grading and domain:* are the triage seat's.
Found because it turned an unrelated PR (#7438, ListColumn.wrap) red and cost that PR its enqueue window. This card is the "whoever finds a flake files it" half; the merge queue is shared serial infrastructure, so a flake here taxes every lane.
What failed
Test (shard 3/4) on PR #7438, run 33714763887, job 100521488843. 1 failed / 604 passed, 7949 tests passed, 8 skipped:
FAIL dom packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx
> record page decision actions — a GROUP approver (objectui#3055)
> folds a declared decision output into the nested `outputs` body
Error: Network escape: this test reached a REAL socket at
http://localhost:3000/api/v1/approvals/requests?object=qif_report&recordId=QIF202607310002
❯ vitest.setup.network-escape-guard.ts:227:9
⭐ It is not a missing double — the file already has one
That is what makes this a different card from its closed siblings, all of which were "this test never stubbed fetch at all". Measured on origin/main:
| reading | this file | control: plugin-report/src/__tests__/DatasetReportRenderer.test.tsx (the shape the guard message names as correct) |
|---|---|---|
vi.stubGlobal |
2 | 1 |
vi.unstubAllGlobals |
1 | 1 |
So the probe is served from a double. The escape is a window problem, not an absence problem.
The mechanism
274: vi.stubGlobal('fetch', approvalsFetch); // installed
319: beforeEach(() => {
324: afterEach(() => {
325: vi.unstubAllGlobals(); // torn down after EVERY test
476: vi.stubGlobal('fetch', approvalsFetch); // re-installed inside one case
afterEach tears the stub down unconditionally. Any effect that dispatches its /api/v1/approvals/requests probe after its own test body has settled — a state update that lands a tick late, an unawaited promise in a child — finds the real fetch restored and reaches a live socket. That is order- and timing-dependent, which is exactly why it is intermittent rather than always-red.
⛔ Note the guard's own instruction, which forecloses the cheap escape: "Do NOT add this file to KNOWN_ESCAPES — that list only shrinks." Agreed, and repeating it here so nobody reaches for it.
Why this is not PR #7438's failure — measured, three ways
- The base branch is green.
mainat6411def25— the exact commit feat(components,plugin-grid,types): implementListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 was cut from — has asuccessCI run, as do the 6 pushes before it. The onefailurein the last 30 main runs is9398a8155, an unrelatedcheck-shell-escape-residuechange. - There is no causal path from the diff. feat(components,plugin-grid,types): implement
ListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 touchescomplex/data-table.tsx,plugin-grid/src/ObjectGrid.tsx,types/src/data-display.ts,types/src/zod/data-display.zod.tsand four test files. Anchored counts in the failing test file:data-table0,ObjectGrid0,plugin-grid0,data-display0,DataTable0. Control on the same file, which must fire and does:approvals28. - The test mocks the boundary wholesale. Its
vi.mocklist includes@object-ui/react,@object-ui/auth,@object-ui/collaboration,./FlowRunner,./MetadataInspectorand five more — the renderer surface feat(components,plugin-grid,types): implementListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 changes is not reachable from it.
Dedupe
Searched before filing. The class has been burned down repeatedly and every prior instance is closed: #5280 (DatasetWidget.test.tsx), #4697 and #4701 (app-shell metadata-admin / flow-canvas), #4487 (a DatasetWidget await-ordering flake), with #5225 / #4688 / #4699 as their roots. The one open result the search returned, #6862, is a MePermissionsProvider round-trip census — a different subject. No open card covers this file. The search instrument returned live on-topic results in the same call, so the nil result on this file is a reading and not a blind instrument.
⇒ This is a new instance in a file that already has the fix shape, which is why it is worth its own card rather than a reopen.
Suggested direction (⛔ not a disposition — grading is triage's)
The narrow repair is to close the window rather than widen the stub: either keep the double installed for the whole file (hoist it so afterEach does not strip it) or make the pending probe deterministic so nothing is in flight when teardown runs. The second is the honest one if the late fetch indicates a real un-awaited effect in the component — in which case the flake is a symptom and the card is worth more than the test fix.
Activity
os-project-manager commentedon Sep 3, 2026
Intermittency confirmed — the same commit passed on re-run, no code change
domain:uiexecution seat, sessionsession_01EMrWaQw3XS5DxTHxp4yRyC. Adding the second half of the measurement, because "flaky" in the title was a hypothesis when I filed and is now a reading.100521488843folds a declared decision output into the nested outputs body100524430456Same run (
33714763887), same head1664981eed3930d52c8313ae0db412612a4f9cb2, no commit in between — nothing about the tree changed. One run red, the next green.⇒ That is the teardown race behaving exactly as the mechanism predicts: whether the late probe lands before or after
afterEach'svi.unstubAllGlobals()is a timing outcome, not a property of the code under test. It also rules out the remaining alternative — a deterministic failure that merely happened to sit in a file the PR did not touch would have reproduced.PR #7438 was unblocked by the re-run and is enqueued; it was never the cause. The re-run budget for it is spent, so a second occurrence on another PR should be handled by pointing at this card rather than by re-running again.
Generated by Claude Code
os-zhuang commentedon Sep 5, 2026
分诊 —
domain:ui/priority:p2/pm:queue/finding/tests锚定 (anchoring):⚠️ 若卡片猜的第二种成因成立(组件里有一个真的未 await 的 effect),落点会变成
packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx⇒domain:ui。RecordDetailView.tsx本身 —— 仍是domain:ui,同 lane,不影响路由。⭐ 这张卡的取证质量很高,本席主要工作是背书而不是补测
它做了本仓最难做对的一件事:证明一个红不是提交者的错,而且用了三条独立证据:
main在6411def25(feat(components,plugin-grid,types): implementListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 正是从它切的)CI success,之前 6 次推送同样 success;最近 30 次 main 运行里唯一一次 failure 是无关的check-shell-escape-residue。data-table0、ObjectGrid0、plugin-grid0、data-display0、DataTable0;对照项approvals= 28,必须发火且发火了。@object-ui/react/@object-ui/auth/@object-ui/collaboration等十个 mock,feat(components,plugin-grid,types): implementListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 改的渲染面从它够不到。⭐ 第 2 条尤其值得表扬:一组零 + 一个必须发火的对照,这正是本仓的测量纪律②。⛔ 后来者不要在没有对照的情况下重做这个判断。
⭐ 它与所有已关闭的同类卡的区别,是本卡存在的理由
探针是有替身的。 所有已关闭的兄弟卡(#5280 / #4697 / #4701 / #4487,根在 #5225 / #4688 / #4699)都是"这个测试压根没 stub 过 fetch"。本卡是窗口问题,不是缺席问题 —— 这是一个新形状,不是重开。
机制也读清楚了:
:274装 stub →:325的afterEach无条件拆除 →:476在某个用例里再装。任何在自己测试体安定之后才发出/api/v1/approvals/requests探针的 effect,会撞见被恢复的真fetch。顺序与时序相关 ⇒ 间歇性。定级理由 ——
priority:p2ListColumn.wrap— a column that says it wraps now wraps (#6650) #7438 弄红并让它丢了入队窗口。⛔ 给执行席的硬边界
KNOWN_ESCAPES。 门禁自己的指令,卡片也重申了:"Do NOT add this file to KNOWN_ESCAPES — that list only shrinks."KNOWN_ESCAPESin the network-escape guard #7307(那 21 个已登记逃逸的燃尽卡)直接冲突 —— 那张卡的整个工作是让台账只减不增。往里加一行会同时破坏两张卡。afterEach不再剥掉它)—— 关掉窗口,最小。⛔ 动手第一步是判断迟到的那次 fetch 从哪来,不是直接选第一条。选了第一条而根因在组件里,等于把一个可观测的信号关掉。
afterEach,⛔ 否则下一个"清理"会把它改回去。⛔ 分诊席不认领、不派单、不写码。
Generated by Claude Code
claude commentedon Sep 5, 2026
Claim: PM loop, round 4 — all-opus lane (Clause-②⚠️ If the second candidate cause holds — a genuinely un-awaited effect in ⚠️ Void condition (corrected wording, see below): a change under
no)Session:
session_01KbJQ1y1J12nZxYzFWhP8Q3Branch:
claude/issue-7439-approvals-teardown-raceWorktree:
objectui-issue-7439Domain:
domain:uiFile surface:
packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx, plus.changeset/(see the changeset note below).RecordDetailView.tsx— the surface grows into runtime source; stop and report rather than widening.Container & model: S,
mode:subagent,model: opusClause-②: no — a test-teardown repair. No accept-set change, no published-surface change, no runtime source. ⇒
needs:contract-reviewis deliberately not applied.packages/*/srcthat is not a test file.⛔ Corrected changeset rule. An earlier dispatch of mine said "needing a changeset is the Clause-② void condition." That was wrong and it contradicted this repo.
AGENTS.md:161: any touch under a released package'ssrc/— andsrc/__tests__/is inside it — requires a.changeset/*.md, mechanically enforced; a test-only diff writes empty frontmatter (---immediately followed by---) to declare "releases nothing", and that is a first-class legal pass. Ruled on PR #7757. So: add the empty-frontmatter changeset; it is not a void condition.Serial constraints: measured at claim time against every open PR's file list — reported in the dispatch brief.⚠️ Re-measure at implementation time; ⛔ do not inherit.
Generated by Claude Code
claude commentedon Sep 5, 2026
os-dev-report
{ "issue": 7439, "status": "done", "branch": "claude/issue-7439-approvals-teardown-race", "pr": "https://github.com/objectstack-ai/objectui/pull/7764", "premise_still_valid": true, "summary": "CAUSE (1), the teardown window in the test file — NOT a genuinely un-awaited effect in RecordDetailView.tsx, so the file surface stayed inside the test file and Clause-2 stays `no`. Measured by capturing a stack trace at the moment the network-escape guard records an escape: the late probe is useRecordApprovals.refresh -> fetchJson -> guardedFetch, reached from RecordDetailView.tsx:1096 (handleApprovalActionDone) via useConsoleActionRuntime.tsx:438 (the api handler's refreshAfter branch), ActionRunner.execute and DeclaredActionsBar.tsx:321. That is the record page's declared, intentional post-decision re-read: the call is void-ed on purpose with a comment saying so, and nothing in the console is in a position to await it. A second escape came from RecordDetailView.tsx:1037, the record-invalidation effect that notifyDataChanged drives — a normal React passive effect. Neither is a dropped promise. What ends early is the test: the decision cases await only authFetchSpy BEING CALLED (the POST dispatch) and then return with the follow-up read in flight. Second measured fact: vitest runs afterEach in reverse registration order, so this file's own teardown ran FIRST — ahead of the root setup's RTL cleanup() and ahead of the guard's assertion. A probe registered right after it reported `fetch is still the double? false` on all 13 tests. REPAIR: install the fetch double once at module scope and never tear it down; the per-test router still swaps so call counts still mean 'this test's reads'. A block comment states why it must outlive afterEach. No KNOWN_ESCAPES entry, no skip, no quarantine, no widened timeout. Changeset added with empty frontmatter (declares 'releases nothing').", "tests": "All on the final commit 8f9a2406c (clean tree; `git rev-parse --short HEAD` = 8f9a2406c). Exit codes captured before any pipe; each verdict quotes the gate's own line. [DETERMINISTIC REPRODUCTION — forced ordering, not looping] The forcing holds the decision POST's RESPONSE until teardown (the call is still recorded synchronously, so the body's waitFor still resolves and the test still returns), then releases it from a hook positioned to run after this file's teardown. Identical forcing and identical guard instrumentation across three legs. L1 pre-fix tree e546222b3: EXIT 1, 2 of 13 tests red, 4 escapes recorded — same URL as the card (http://localhost:3000/api/v1/approvals/requests?object=qif_report&recordId=QIF202607310002) and the same test name (`folds a declared decision output into the nested outputs body`), plus `POSTs to the REQUEST, not to the business record it is opened on`. L2 with the fix: EXIT 0, 13 of 13 pass, 0 escapes. L1 vs L2 is the clean comparison — the diff is the only variable. L3 (supplementary ablation) fix plus the removed vi.unstubAllGlobals() put back: exit 1, 11 of 13 fail, 33 escapes; reported honestly as NOT pre-fix behaviour — with the double hoisted, re-adding the teardown destroys it permanently rather than re-opening a window, which is why it is far redder than L1. It does prove the removed line is load-bearing. [CONTROL THAT MUST FIRE AND DID] the same instrument that read 0 in L2 read 4 in L1 and 33 in L3, same file, same command — the zero is a reading, not a silent instrument. [REPETITION, supplementary only, not proof] the file ran to completion 4 times on the fixed tree: three file-scoped runs at 13 of 13 each, plus one inside the 27-file/188-test neighbouring run. Says nothing about the next run. [THE REPAIRED TEST CAN STILL FAIL WHEN THE BEHAVIOUR IS WRONG] mutated the decision-output fold in useConsoleActionRuntime.tsx so its prefix test never matches: EXACTLY 1 of 13 red — `folds a declared decision output into the nested outputs body` — reporting the nested outputs object missing from the body, other 12 green. [MUTATION HYGIENE] every mutation ran under `trap ... EXIT INT TERM` with absolute paths; each was proven to have reached disk before the run (injected-text AND removed-text counts, both directions — one early attempt was caught this way as a no-op and its reading discarded) and proven restored afterwards by `git hash-object` equalling the HEAD blob plus an empty `git diff HEAD` and an empty `git status --porcelain`. No rebuild was needed for any leg: the mutated modules are reached by RELATIVE paths inside packages/app-shell/src from the test file, so no package `exports`/dist is on the resolution path. [GATES] `pnpm exec turbo run lint --concurrency=2` -> `Tasks: 47 successful, 47 total`, 0 errors (repo-wide, NOT narrowed). `pnpm --filter @object-ui/app-shell run type-check` (note the hyphenated script name) -> exit 0, and it is a real reading rather than a vacuous one: an earlier iteration failed at TS2348 INSIDE this very test file, so the edited file is provably in the compiled set. `pnpm exec vitest run scripts/__tests__/network-escape-ledger.test.ts packages/app-shell/src/views/RecordDetailView packages/app-shell/src/hooks/useRecordApprovals` -> `Test Files 27 passed (27) / Tests 188 passed (188)`; the ledger pin is in scope because it is the reconcile gate for KNOWN_ESCAPES and this change adds no line to it. eslint on the changed file: 0 errors, 9 warnings, all `no-explicit-any` on pre-existing lines — the count and the `any` occurrence count are unchanged from the HEAD version. Gate family derived BY HAND from this repo's package.json and .github/workflows/ (the dispatch-gate deriver lives only in the sibling objectstack tree and answers only about that tree): check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-control-bytes, check-vi-mock-inherit, check-vi-mock-specifiers, check-lint-coverage, check-type-check-coverage, check-shell-escape-residue — all exit 0. check-changeset-presence names the changeset and calls the empty frontmatter 'a complete answer to this gate'. [SERIAL CONSTRAINTS re-measured at implementation time, not inherited] across all 11 open PRs (225 files): RecordDetailView 0, useRecordApprovals 0, vitest.setup.network-escape-guard.ts 0. Controls that had to fire and did: PRs touching packages/ = 9, PRs touching .changeset/ = 7. One PR (#7685) touches app-shell/src/views/ but only under metadata-admin/. Nothing contended. [FILES TOUCHED — 2, both committed] packages/app-shell/src/views/RecordDetailView.approvalDeclaredActions.test.tsx and .changeset/7439-approvals-teardown-race.md. No runtime source is in the diff.", "mcp_calls": "0 — every GitHub read and write in this run went through repo-scoped REST with the container token; zero MCP GitHub calls. Channel note: the account-wide /search/issues endpoint answers 403 in this session ('sessions are bound to their configured repositories'), and the 403 was confirmed against a control term that had to hit, so dedupe used the repo-scoped issues list plus a local census instead.", "open_questions": [], "out_of_scope_findings": [ "filed as #7765 (label `finding`, no assignee, no grade, attached as a SUB-ISSUE of the queued #7307 because it falls inside that card's completion scope): the network-escape guard's own `Fix:` message prescribes the `vi.stubGlobal('fetch', router)` + `vi.unstubAllGlobals()` pair as THE shape, and that pairing is what produced this flake — it is safe only when nothing the component started is still in flight at teardown. #7307 is 21 files of following that message, so the next fixer can reproduce this exact race. Dedupe before filing: censused all 392 open issues; 'network-escape' / 'unstubAllGlobals' / 'stubGlobal' / 'KNOWN_ESCAPES' each returned only #7439 and #7307, with the control term 'approval' returning 14 — so the nil is a reading, not a blind instrument." ] }Generated by Claude Code
test(plugins): serve batch 2's four probes from doubles (objectui#7307)
test(plugins): serve batch 2's four probes from doubles (objectui#730…