Skip to content

test(metadata): pin the watcher ordering guarantee itself, not get()'s microtask depth (#6043) - #6547

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-6043-watcher-test-real-invariant
Aug 8, 2026
Merged

test(metadata): pin the watcher ordering guarantee itself, not get()'s microtask depth (#6043)#6547
baozhoutao merged 1 commit into
mainfrom
claude/issue-6043-watcher-test-real-invariant

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6043

What this changes

packages/metadata/src/register-notifies-watchers.test.ts had a case named
"announces AFTER the write lands, so a subscriber that re-reads sees the new body".
It asserted an ordering guarantee by awaiting get() inside the watcher callback
and reading a variable back after await register(...) resolved.

notifyWatchersLocal dispatches with void callback(event) and never awaits its
handlers, so readBack was only ever populated because get()'s await hop count
happened to be smaller than the microtasks await register(...) yields. The case was
pinning this method's microtask depth, not the ordering it named.

Per the triage ruling, this implements option (b) — synchronously read the registry
inside the callback and assert the write has already landed at broadcast time — and
keeps option (a) alongside it so the "a subscriber that re-reads sees the new body"
claim stays covered without frame-dependence (the subscriber's promise is captured and
awaited by the test instead of assumed settled).

One case became three:

  1. first registration — at broadcast time the registry already has the entry and
    holds the new body. Entirely synchronous, so no consumer method's await hops can
    move the verdict.
  2. overwrite — added so the failure is readable. On a first registration both "the
    announcement came too early" and "the promise had not settled" surface as undefined;
    with a distinct pre-write body the early announcement fails as V1 where V2 was
    required, and names the defect on sight.
  3. re-read through get() — the (a) half, awaited rather than raced.

Verification — both failure modes of the old case, measured

Direction was decided before running each, per the reverse-verification discipline.

1. Pure equivalent refactor of get() must stay GREEN (the old false positive)

Applying the delegation form #5840 abandoned — return (await this.getDiagnosed(type, name)).data;
semantics unchanged, one extra async frame.

Old case, before this PR — RED:

AssertionError: expected undefined to deeply equal { name: 'account', label: 'Fresh' }
 ❯ src/register-notifies-watchers.test.ts:82:24
 Test Files  1 failed (1)
      Tests  1 failed | 14 passed (15)

New cases, with this PR — GREEN, and so is the whole package:

 Test Files  26 passed (26)
      Tests  519 passed (519)

Predicted green: (b) never calls get() at all, and (a) awaits the captured promise.

2. Broadcast moved before the write lands must go RED (the regression it guards)

Hoisting the notifyWatchers(...) block above the registry write in register():

 ❯ src/register-notifies-watchers.test.ts (17 tests | 3 failed)
   × announces AFTER the write lands — the registry already holds the new body at broadcast time
   × announces AFTER the write lands on an OVERWRITE too — never with the pre-write body still in place
   × a subscriber that re-reads through get() sees the new body — awaited, not raced

AssertionError: expected false to be true // Object.is equality
AssertionError: expected { name: 'account', label: 'V1' } to deeply equal { name: 'account', label: 'V2' }
AssertionError: expected undefined to deeply equal { name: 'account', label: 'Fresh' }

All three go red, and the overwrite case names the defect in its message.

3. Correction to the issue's stated false-negative

The issue predicted the old case would stay green when the broadcast moved before the
write. Measured, it goes red — but for the wrong reason: the subscriber misses the
registry, falls through to the loaders, and reports the same bare undefined at the same
line as the equivalent-refactor case above. The old assertion carries one bit where the
defect has two, so its red could not distinguish a real ordering regression from a
harmless refactor.

The genuine false negative sits one notch finer. Hoisting the announcement above the
realtime publish and the writable-loader save loop — still a violation of the ordering
register() documents — leaves the old case fully green:

 Test Files  1 passed (1)
      Tests  15 passed (15)

All sabotage was reverted before committing; the committed diff to metadata-manager.ts
is comment-only.

The get() TSDoc note

PR #6051 left a standing note on get() recording the delegation as unsafe. That reason
is now stale — the test it cited no longer observes the frame count — so the note is
rewritten to state current reality, as the ruling directs. get() itself is not
refactored here (production change, out of scope for this card).

The delegation now looks viable: the whole @objectstack/metadata suite was re-measured
on the delegating version and stayed green (519/519). Offered as a follow-up suggestion,
not taken — and the note records the caveat that get()'s callers outside this package
were never surveyed for timing sensitivity, only this package's tests.

Scope

Option (c) from the issue — a "drained" observability point on notifyWatchers — is out
of scope and not attempted.

Only the registry half of register()'s ordering claim is pinned. register() documents
that the announcement follows the write into the registry and every writable loader,
but this fixture's MemoryLoader declares memory: and register() persists to
datasource: loaders only, so no loader in this file is ever written to and the second
half is not observable from it. Recorded in the file header and filed separately as a
finding; it needs a writable-datasource fixture, not another assertion.

Changeset

None — this is a test-only change plus a comment-only touch, so it releases nothing. The
PM will apply the skip-changeset label at acceptance.


Generated by Claude Code

`register-notifies-watchers.test.ts` 的「announces AFTER the write lands」
用例,通过在回调里 `await manager.get(...)`、并在 `await register(...)`
之后读取变量来断言顺序。`notifyWatchers` 以 `void callback(event)` 派发、
从不 await 回调,所以这个写法实际测的是 `get()` 有几个 await 帧,而不是
它声称的顺序保证。

实测两个方向都成立:
* 把 `get()` 换成语义等价的 `(await this.getDiagnosed(...)).data` 委托写法
  ——纯等价重构——用例立刻变红(#5840 因此放弃该写法,保留三行重复);
* 把广播挪到 registry 写入之后、realtime 发布与 writable loader 保存之前
  ——真实违反 `register()` 文档的顺序——用例 15/15 全绿。

改为按 issue 的 (b) 方案:在回调里**同步**读 registry,断言广播那一刻写已
落地;并保留 (a) 方案覆盖「订阅者重读看得见新 body」——捕获订阅者的 promise
并由测试 await,而不是假定它已结算。新增 overwrite 变体,使早广播的失败信息
直接读作 V1/V2,而不是与「promise 未结算」同形的 undefined。

同步复核 #6051 留在 `get()` 上的 TSDoc:该常驻理由已过期,改为陈述现状
(仅注释改动,不重构 `get()`)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 4:01am

Request Review

@github-actions github-actions Bot added the size/m label Aug 8, 2026
@baozhoutao baozhoutao added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 8, 2026 — with Claude
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata)
  • content/docs/kernel/cluster.mdx (via packages/metadata)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/metadata)
  • content/docs/releases/v12.mdx (via @objectstack/metadata)
  • content/docs/releases/v9.mdx (via @objectstack/metadata)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants