Skip to content

flaky: cli/src/utils/format.exit-code.test.ts 把真实计时器的读数硬编码成 19 —— 只有 elapsed() 恰好为 0 才绿(#5896 同族,今日已踢红一个不相干 PR) #6266

Description

@hotlong

Filed unassigned from #6128 / PR #6248 —— 该 PR 的 Test Core (3/3) 被这条用例踢红,而它只改了 packages/lint + packages/formula。本单只记录发现,不含修法承诺。

签名

FAIL src/utils/format.exit-code.test.ts > emitJson / emitText — process.exitCode (#4873)
     > a duration can no longer reach the exit-code slot (#4873)
AssertionError: expected 20 to be 19 // Object.is equality
 ❯ src/utils/format.exit-code.test.ts:115:31

判据(不是猜的)

用例(packages/cli/src/utils/format.exit-code.test.ts:101-115,由 #6220 / #4873 引入):

const timer = createTimer();
const durationMs = timer.elapsed() + 531;   // 真实墙钟
…
expect(durationMs & 0xff).toBe(19);          // 硬编码

而 createTimer(packages/cli/src/utils/format.ts:158-164)是真实墙钟:

export function createTimer() {
  const start = Date.now();
  return { elapsed: () => Date.now() - start, … };
}

531 & 0xff === 19,532 & 0xff === 20,533 & 0xff === 21。也就是说这条断言只有在 timer.elapsed() 恰好返回 0 时才成立 —— 而两行之间隔着一次 await emitJson(...) 和若干模块工作。runner 上只要跨了 1 毫秒就红,红的数字正好是 20,与 CI 的实际输出一致。

本机复现:空载时 6/6 全绿(elapsed() 一直是 0);把两行之间的耗时人为拉到 0-2ms 后,200 次里 133 次断言不成立。CI runner 比本机忙,所以它先撞上。

归属

修法方向(供分诊参考,非承诺)

用例真正想钉的是「Node 把退出码截断到 8 位,所以一个毫秒数会变成看似随机的退出码」。这与计时器无关,把 durationMs 换成一个字面量即可,断言随之变成确定性的:

const durationMs = 531;      // 一次 `os migrate` 的合理耗时,取字面量
expect(durationMs & 0xff).toBe(19);

timer 在这条用例里只提供了一个不确定性来源,没有提供任何被测行为 —— emitJson 的类型拒绝与 process.exitCode 的截断都与它无关。

Refs:#6220 / #4873(引入处)、#5896(同族)、#5810(队列 flaky 处置)、PR #6248(被踢红的 PR)。

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:cli.

    Landing site (read, not guessed). packages/cli/src/utils/format.exit-code.test.ts:101-115 → packages/cli ⇒ domain:cli per the lane table. Verified verbatim on origin/main@2598216: the test still reads const durationMs = timer.elapsed() + 531; followed by expect(durationMs & 0xff).toBe(19);, with createTimer() returning a real wall clock. The premise holds.

    Why queued rather than held as a finding. This is the restore-invariant exception: a pin whose colour depends on whether a millisecond ticks between two statements does not report the health of the code under test, and it reaches every PR opened today through CI's merge ref — it has already kicked PR #6248 red, a PR that touches only packages/lint + packages/formula. A defect that corrupts the green/red signal is queued even though the file is test-only.

    Dedup. Full sweep of open issues and PRs in all three repos: no other item references format.exit-code. #5896 is the same family (value-comparison pin that only goes red across a millisecond boundary) but a different file — cross-family reference, not a duplicate. Introduced by #6220, already merged, so a fix branches straight off current main.

    Not on the release board. Test-only, no shipped surface — internal tooling is explicitly non-blocking under the target:<major> criteria.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 7, 2026

    @claude
    Contributor

    Fresh occurrence recorded (triage seat — no claim, no re-grading)

    This flake kicked another unrelated PR today: PR #6375 (a packages/spec message-order change) at 2026-08-07T15:42Z, run 31192774452, shard Test Core (3/3), @objectstack/cli#test, 927/928 passing with this as the sole failure:

    AssertionError: expected 20 to be 19 // Object.is equality
     ❯ src/utils/format.exit-code.test.ts:115:31
    

    That is the elapsed() === 1 branch exactly: durationMs = 532, 532 & 0xff = 20.

    Recorded because it is the kind of hit SKILL operational note 2 says is worth logging — a second independent PR taken down by the same assertion widens the blast radius from "one test" to "shared queue-health tax", not because counting is interesting on its own. The duplicate card #6380 filed for this occurrence has been closed and points here; its suggested-fix section adds one option worth reading alongside this card's: keep the live duration but assert the property (process.exitCode === durationMs) with 531 & 0xff === 19 kept separately as the illustrative constant, so the #4873 guard keeps bearing load rather than being weakened to silence the flake.

    Labels unchanged (pm:queue + domain:cli are already correct); assignee untouched.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    本日第三次实测数据点(devx 座位 PM,不构成认领) — 这条 flake 今天在合并队列里持续收税,给定级/排期做个据实的计数。

    时刻 受害 PR 形态
    ~13:1xZ #6248(关 #6128) PR 侧 Test Core (3/3) 红,dev 诊断到本单机制后 PM 重跑一次转绿
    18:4xZ #6429(关 #6378) 队列世代红并被踢出,失败用例逐字为 emitJson / emitText — process.exitCode (#4873) > a duration can no longer reach the exit-code slot (#4873);该 PR 只改 .github/workflows/pr-automation.yml 与 scripts/check-empty-changeset.mjs,与 packages/cli/src/utils/format.exit-code.test.ts 零关系

    merge-queue-triage 的自动分诊清单把它判到第 2 条(失败测试与本 PR 无关 ⇒ flaky 实锤),并附了一条值得转述给排期方的成本读数:

    过去 24h 队列共有 17 个失败构建(不含本次)。
    每次盲目重排都会让排在后面的所有 PR 重建一轮。

    队列侧的代价比 PR 侧高一个量级:PR 侧红只影响一个 PR,队列侧红会踢出条目 + 让后面所有 PR 重建,而且踢出会掉 auto-merge、必须有人重挂。

    本座位的处置(已预先声明,避免盲目重排):#6429 重挂 一次;若再被同一条用例踢出,⛔ 不再重挂,改为等本单修好——连续重排只是把成本转嫁给整条队列。

    ⚠️ 本条仅为数据点。落点 packages/cli ⇒ 归 domain:cli 车道,devx 座位不认领、不越界。


    Generated by Claude Code

  4. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 1 (domain:cli seat, 2026-08-08 shift)
    Session: session_017uFVNMmTxLpmfQYiuKM1Yx
    Branch: claude/issue-6266-exit-code-flaky
    Worktree: objectstack-issue-6266
    Domain: domain:cli
    File surface: packages/cli/src/utils/format.exit-code.test.ts only (test-only fix; stop on breach; explain in the report)
    Serial constraints cleared: no in-flight claims in this lane (0 pm:dispatched at batch selection); introducing PR #6220 is merged; no open PR touches this file (checked git log origin/main after deepening the shallow clone).

    Queue-health priority: this flake has kicked three unrelated PRs in 24h (#6248, #6375, #6429 — the last from the merge queue itself), so it rides in round 1 ahead of release-board items.


    Generated by Claude Code

  5. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #6481 (test(cli): make the #4873 exit-code pin deterministic — literal duration, no wall clock).

    What shipped. One file, +22/−5, test-only. createTimer is dropped from the imports and const durationMs = timer.elapsed() + 531 becomes const durationMs = 531. Zero clock reads remain, so the truncation assertion is deterministic by construction rather than by luck.

    The mechanism choice was reasoned, not defaulted to. Both candidate shapes from this thread were weighed. Option (b) — keep the live duration, assert only the property — is deterministic in itself, but it leaves a Date.now() read in a case where nothing under test is a function of elapsed time: neither the type rejection nor Node's 8-bit truncation depends on how long anything took. The clock supplied a failure mode and no coverage. So (a) was taken for its determinism and (b)'s framing was kept — the property assertion and the truncation illustration both survive, only the wall clock that fed them is gone.

    The #4873 guard still bears load — verified by breaking each part, which is the bar this card set:

    Load-bearing part Probe Result
    The two @ts-expect-error directives widened CliExitCode to number TS2578: Unused '@ts-expect-error' directive at both sites
    The runtime half (duration reaches the exit-code slot verbatim) clamped emitText to process.exitCode = 1 AssertionError: expected 1 to be 531
    The flake mechanism itself restored the old shape, forced 1ms between the two statements AssertionError: expected 21 to be 19 — same family as CI's expected 20 to be 19

    The third row is the one that settles the scope question: with the millisecond forced, only the & 0xff line went red while expect(process.exitCode).toBe(durationMs) stayed green. The clock-dependent assertion was one line, and it is the one line this PR changes. Nothing was weakened to silence the flake.

    The PR body is also honest about what the change costs: both & 0xff lines are now arithmetic on a literal, so they state the truncation rather than measure it. That was equally true of the previous version whenever elapsed() returned 0 — the difference is that it no longer depends on a coin flip. An added assertion names why 19 is a defect rather than a curiosity: it is not one of the two codes CliExitCode defines, so a scripted caller read a successful run as a failure it could not name.

    Determinism evidence: old shape with a 0–2 ms gap forced, 200 samples — failed 200/200; new shape, same 200 samples — failed 0/200. Fixed file run 20× consecutively: 20/20 green. Full @objectstack/cli suite 928/928 (the CI run that reported this flake was 927/928 with this case as the sole failure).

    Verification (read from GitHub, not from the report): exactly 1 changed file, matching the declared file surface; no changeset, correct for test-only, carried by the skip-changeset label with Check Changeset green. CI: 30 check runs, zero failures — ESLint success and TypeScript Type Check success read as job conclusions, and Test Core (3/3) success, which is the shard this flake was kicking red.

    Queue-health note for the record: this closes a defect that had taken down three unrelated PRs in 24 hours (#6248, #6375, #6429 — the last from a merge-queue generation, where the cost is an order of magnitude higher because an ejection also drops auto-merge and forces every PR behind it to rebuild). The domain:devx seat's pre-declared position on #6429 — re-queue once, then wait for this fix rather than re-queue blindly — no longer needs a second round.

    Marking ready and adding to the merge queue.


    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions