Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): close() terminates watch iterators instead of emitting a drain event - #11136
Conversation
… emitting a drain event (#11021) `SysMetadataRepository.close()` modelled shutdown as a metadata event — `{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }` broadcast through the same dispatch closure real events pass, then `watchers.clear()`. Both of that closure's guards reject it: `matchesFilter` drops it for any subscription naming an org (the synthetic ref's org is the empty string), a type other than `view`, or a name; and `evt.seq <= since` holds for -1 against every real seq. Dropped and then unsubscribed, nothing could settle the parked promise and the consumer's `for await` never returned. The subscriptions that passed both guards were no better off: they received the synthetic event as a real one — a `view` named `_close`, deleted, at seq -1, which MetadataManager turns into a cache invalidation and re-emits to Studio's HMR stream — and hung on the next pull anyway, because delivering an event does not end an iterator. The watcher registry now holds each subscription's terminator next to its event sink, and `close()` runs the terminator — the same routine `iterator.return()` runs. Invariant 8 in metadata-core's repository.ts states the contract that was unstated, and records FileSystemRepository's non-conformance (#11127). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e4287723823872204fbdea54646d09489f6e68f6 && git checkout e4287723823872204fbdea54646d09489f6e68f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin afe1c4e0a1794a56817acbfd5f4f6e957ea023ce 2314ff6f97e00a893091215c8cc83b745875b228 && git checkout -B drift-repro afe1c4e0a1794a56817acbfd5f4f6e957ea023ce && git merge --no-ff 2314ff6f97e00a893091215c8cc83b745875b228
node scripts/docs-audit/affected-docs.mjs --json afe1c4e0a1794a56817acbfd5f4f6e957ea023ce
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32596788489 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#11021
SysMetadataRepository.close()modelled shutdown as a metadata event, and an event is exactly what a filtered or numeric-sincesubscriber is entitled to drop. It broadcast a synthetic{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }through the samedispatchclosure real events pass, then cleared the watcher registry. Both of that closure's guards reject it, and once the registry is cleared nothing else can settle the parked promise.The design fork, decided by measurement
Triage recommended shape 2 (
close()resolves pending iterators withdone: truedirectly) as a route to verify, with an explicit stop-condition: if a real consumer depends on receiving the synthetic drain event, shape 1 (exempt it from the filters) is the compatible answer instead. Nothing depends on receiving it:_closesentinelsys-metadata-repository.ts:1228. No consumer names it, counts it, or branches on it.MetadataManager.startRepositoryWatch()(repo.watch({}), the drained subscriber)applyRepoEvent(), which cannot tell it from a real one:invalidateForForeignWrite('view', '_close'), thennotifyWatchers()re-emits it downstream as adeletedviewnamed_closeatseq: -1— into the HMR SSE route and Studio's status badge. It is misread, not depended on. Its loop condition iswhile (!this.repoWatchClosed), which the event does not change, so receiving it does not end the loop either.MetadataCache.start()applyEvent()invalidates the cache key for a ref that never existed, then loops. Same shape.InMemoryRepository's iteratorclose()andmetadata-fs'screateWatchIterableclose()both settle the waiter with{ value: undefined, done: true }. There is no synthetic event anywhere else in the codebase.LayeredRepository.multiplexWatchdone: trueas "that layer finished" and ends when all children are done — shape 2 composes through it; shape 1 would forward the phantom_closeto a multiplexed consumer wearing a layer label.So shape 2, for the reason triage gave: giving shutdown a
seqof-1is what makes it collide with thesincecomparison in the first place.The matrix, measured before and after
Pinned in
sys-metadata-repository.contract.test.ts. "before" is the same three cases run against unmodifiedclose().watch({org:'system'}, a.seq)next()still unsettled 500ms afterclose(){ value: undefined, done: true }watch({org:'system'})— nosinceat all{ value: undefined, done: true }watch({}){ done: false, value: { seq: -1, op: 'delete', ref: { org: '', type: 'view', name: '_close' }, source: 'sys-metadata-repo-close' } }— and the next pull then hung{ value: undefined, done: true }, and so does every later pull⭐ The middle row is the one that proves the org-filter half bites on its own: no
sinceis involved, and it is the shapeMetadataCache.start()takes for any non-emptywatchFilter. A fix tested only against thesincehalf would look complete and leave it hanging.What changed
The watcher registry holds each subscription's terminator next to its event sink (
WatchSubscription), andclose()runs the terminator — the identical routineiterator.return()runs. A consumer that breaks its own loop and a consumer whose repository shut down under it now observe the same thing, so neither has to special-case the other.On the "both dispatch paths" instruction — measured, and the premise refines.
:1077-1078(inreplayFromHistory) and:1144-1145(indispatch) do both apply thesincedrop andmatchesFilter, but only the second is on the drain event's path:replayFromHistoryfilters rows read out ofsys_metadata_history, and the synthetic event was never written there — it went straight fromclose()intodispatch. Under shape 2 the question dissolves, because there is no event to filter on any path. Both filter sites are deliberately untouched and still apply to real events, which the existing invariant-6 cases pin.The contract, now stated
Invariant 8 in
packages/metadata-core/src/repository.ts— "shutdown terminates; it does not emit" — says what a repository-levelclose()owes a pending iterator: end every live iterator withdone: trueand no value, the same observationreturn()produces; queued or unreplayed events MAY be dropped; shutdown MUST NOT be delivered as an event, with both measured reasons written down. It is conditional becauseclose()is not on theMetadataRepositoryinterface, and it records where each of today's three implementations stands.Out of scope, filed not fixed
#11127 —
FileSystemRepository.close()retires the chokidar watcher and the resync sweep but never reaches its event broker, so a parkedwatch()iterator stays parked. Same defect class, different package; filed with its confidence stated (code read, no runtime probe) rather than fixed here. Invariant 8 names it as the one measured non-conformance rather than quietly omitting it. Also left alone:listDraftsand the org-scope path in this file, which belong to the neighbour queued behind this card.Verification
All at
2314ff6f9, the final commit.pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata-core --filter @objectstack/metadata test—1859 passed | 10 skipped,165 passed,615 passed. The two consumer packages are here because this changes what their watch loops observe at shutdown.pnpm --filter @objectstack/metadata-core typecheck—Done.@objectstack/metadata-protocoland@objectstack/metadatadeclare notypecheckscript (a--filterthat matches no script exits 0 having run nothing); they are covered instead by the ratchet below.pnpm check:type-check-debton the built closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) —OK — 33 ledger entr(ies) re-measured in 338.6s, 1895 raw tsc error(s) total, none above its recorded number.@objectstack/metadata-protocolis a ledger entry (frozen at 63), so the new test code was re-measured, not assumed.pnpm lint(eslint . --no-inline-config, repo-wide) — clean, 124s. No narrowing claimed.node scripts/pm/dispatch-gates.mjsre-derived against the real changed paths, all exit 0:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:durability-log-level,check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:nul-bytes,check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-ci-filter-parity.mjs,check-cross-package-test-inputs.mjs,check-empty-changeset.mjs,check-plugin-teardown-shape.mjs,docs-audit/check-affected-docs.mjs.Generated by Claude Code