Skip to content

register-notifies-watchers 的「订阅者重读看得见新 body」用例,实际钉住的是 get() 里的 microtask 深度,不是它声称的顺序保证 #6043

Closed

Description

@baozhoutao

做 #5840 时被这条用例真实拦下一次,按 PD #10 单独记录。观察类:今天没有用户碰得到的行为缺陷,红的是测试的健壮性,不是产品。

事实

packages/metadata/src/register-notifies-watchers.test.ts:

it('announces AFTER the write lands, so a subscriber that re-reads sees the new body', async () => {
  let readBack: unknown;
  manager.subscribe('object', async () => {
    readBack = await manager.get('object', 'account');
  });

  await manager.register('object', 'account', { name: 'account', label: 'Fresh' });

  expect(readBack).toEqual({ name: 'account', label: 'Fresh' });
});

notifyWatchersLocal 用 void callback(event) 派发(metadata-manager.ts,private notifyWatchersLocal 一带),从不 await 回调。所以 register() 返回时,订阅者那句 await manager.get(...) 还没结算;readBack 能在断言前被赋值,靠的是「get() 内部的 await 跳数」恰好少于 await manager.register(...) 让出的微任务数。

为什么这是个 finding

它声称钉的顺序保证(先写落地、后广播)是真的,也确实值得钉。但这个写法测不到它:真正决定成败的是 get() 里有几个 await 帧。

实测证据(#5840 开发过程中):把 get() 从

async get(type, name) { /* registry 命中直接 return */ ... const result = await this.load(type, name); ... }

改写成等价的 return (await this.getDiagnosed(type, name)).data; —— 语义逐字不变、registry 优先不变、返回值不变,只多了一个 async 帧 —— 这条用例即刻变红,readBack 为 undefined。#5840 因此放弃了那个(更整洁的)委托写法,保留三行重复以维持逐帧一致,并把原因写进了 get() 的 TSDoc。

也就是说:一次纯粹的等价重构会让它红,而它声称保护的顺序保证毫发无损。反过来,若真把广播挪到写入之前(它要防的那个回归),这条用例大概率照样绿——订阅者读到的仍是同一个 registry map,只是内容早晚问题,readBack 该有值还是有值。假阴性与假阳性都在。

可能的修法(未择,留给承接座位)

  • (a) 让订阅者把 promise 记下来,断言前 await 它——最小改动,测的仍是「重读看得见」,但不再依赖跳数;
  • (b) 直接钉真正的不变量:在回调里同步读 (manager as any).registry.get('object').get('account'),断言广播那一刻写已落地——这才是「AFTER the write lands」的字面意思;
  • (c) 顺带考虑 notifyWatchers 是否该给一个「已排空」的可观测点,供测试与集群桥接共用(范围更大,多半不值)。

倾向 (b):它把断言对准了方法名承诺的那件事,且对 get() 的实现细节完全免疫。

关联

#5840(触发点,PR 里 get() TSDoc 常驻记了这条)、#3112(这批用例的来源单)。

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 6, 2026
  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 6, 2026
  3. claude commented on Aug 6, 2026

    @claude
    Contributor

    发现分诊(#4949 纪律):晋级 —— 摘 finding,改 pm:queue + domain:engine-core

    过时前提检查(origin/main 9e3709a):packages/metadata/src/register-notifies-watchers.test.ts 仍在;metadata-manager.ts 的 notifyWatchersLocal(:2665)确实以 void callback(event)(:2671)派发、从不 await,两个调用点在 :2643 / :2748 ⇒ 立单事实全部成立。

    域:落点包 packages/metadata ⇒ domain:engine-core(按域表,不按「测试」词汇归 devx)。

    为什么晋级而不是持有:三条都满足自动入队的形状 —— 落点唯一、修法已点名(倾向 (b):回调里同步读 registry,断言「广播那一刻写已落地」)、且是 test-only 改动。更关键的是它已经产生了真实成本:#5840 因这条用例放弃了等价且更整洁的 getDiagnosed 委托写法,保留三行重复,即一条测试的偶然实现细节正在反向约束产品代码的形状。假阴性(把广播挪到写入前它照样绿)与假阳性(纯等价重构即红)同时存在,属「闸门没有钉住它声称钉住的东西」一族,与本轮前例 #5916 同形。

    串行(硬):在飞 PR #6051(#5840)改的是同包 metadata-manager.ts(实测 diff 命中,但不含本单的测试文件)⇒ 无文件冲突,但顺序有意义:本单应排在 #6051 落地之后,并在同一次改动里复核该 PR 留在 get() TSDoc 里的常驻说明(「为保持逐帧一致故不委托」)是否随本单修好而过期 —— 若过期而不改,下一个作者会照着一条已失效的理由继续保留重复代码。

    范围:⛔ 只做 (b)(必要时加 (a));正文的 (c)(给 notifyWatchers 增设「已排空」可观测点)明确划在范围外,它是接口扩展,需另行取 appetite。

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


    Generated by Claude Code

  4. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    迁移:domain:engine-core → domain:metadata(维护者 2026-08-07 批准拆分;座位贴 #6367,SKILL PR #6370)。落点证据:packages/metadata 的 register-notifies-watchers.test.ts 用例实际钉住 MetadataManager.get 的 microtask 深度而非声称的顺序保证 —— metadata 通知接缝。全量分类审计 49 卡三判,本卡 MOVE/high。分诊会话:session_01BickTBKm2JYSNnrtPT8ysa。


    Generated by Claude Code

  5. self-assigned this
    on Aug 7, 2026
  6. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    ContributorAuthor

    Claim: PM loop round 1 (domain:metadata seat, sticker #6367)
    Session: session_01KDU3qAuJyajAQm3GkUXfdA
    Branch: claude/issue-6043-watcher-test-real-invariant
    Worktree: objectstack-issue-6043
    Domain: domain:metadata
    File surface: packages/metadata/src/register-notifies-watchers.test.ts + the get() TSDoc block in packages/metadata/src/metadata-manager.ts (comment-only touch, per the triage promotion comment's instruction to re-verify the note PR #6051 left there). Stop on breach; explain in the report.
    Serial constraints cleared: PR #6051 (#5840) — the hard predecessor named at triage — merged 2026-08-07 01:49Z; no open claim touches these files. #5309 (future round, also lands on metadata-manager.ts) deliberately sequenced after this card.
    Container class: S — mode:subagent, shared container.


    Generated by Claude Code

  7. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    ContributorAuthor

    Correction to the claim above: the session ID contains a transposition typo. The correct ID is session_01KDU3qAuJyajAQm3GkUXdfA (matches the seat takeover audit on #6367). Branch, worktree, and all other claim fields are unchanged.


    Generated by Claude Code

  8. baozhoutao commented on Aug 8, 2026

    @baozhoutao
    ContributorAuthor

    ACCEPT (PM review, domain:metadata seat, session session_01KDU3qAuJyajAQm3GkUXdfA) — PR #6547.

    Shipped: the "announces AFTER the write lands" case now pins the ordering guarantee itself — ruling option (b) (synchronous registry peek inside the watcher callback, with an overwrite variant so an early broadcast fails readably as V1-vs-V2) plus (a) (the subscriber's get() promise captured and awaited). PR #6051's standing TSDoc note on get() is rewritten to current reality (comment-only; get() not refactored — correctly left as a follow-up suggestion).

    Review verified against GitHub: two files exactly on the claimed surface; the metadata-manager.ts hunk is comment-only (read line-by-line); ESLint and TypeScript Type Check jobs both success, 30 checks zero failures. Reverse verification measured both failure modes with directions fixed before running, and honestly falsified the issue's own prediction — the old case does go red on an early broadcast, but with an undefined indistinguishable from a harmless refactor; the one-notch-finer true false negative (hoisting past the realtime publish + writable-loader save loop stays 15/15 green) is recorded and filed as #6548 (finding). Tests + comment only ⇒ skip-changeset (on and read back).

    PR flipped ready and queued (auto-merge); the issue closes on merge via the Fixes line.


    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

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions