Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-fs): FileSystemRepository.close() terminates every live watch() iterator (#11127) - #11198
Conversation
📓 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 — 3 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 10783626122ea976d25f2944fc7cee9a847905bd && git checkout 10783626122ea976d25f2944fc7cee9a847905bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 52a41b72ee01bc24bdd6ac8eb1a37485eb2b4946 609147d823d39248d3da7536d80f5d8a7246aa54 && git checkout -B drift-repro 52a41b72ee01bc24bdd6ac8eb1a37485eb2b4946 && git merge --no-ff 609147d823d39248d3da7536d80f5d8a7246aa54
node scripts/docs-audit/affected-docs.mjs --json 52a41b72ee01bc24bdd6ac8eb1a37485eb2b4946
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11127
Before → after
Before.
FileSystemRepository.close()retired the chokidar watcher and the resync sweep and stopped there. It never reachedthis.broker, and the broker had no teardown of its own —subscribe/unsubscribeadd to and delete from a plainSet, and nothing else emptied it. Eachwatch()iterator parks its pendingnext()on awaiterthat only two things can settle: a brokerpush, or the iterator's own terminator, which ran fromiterator.return()/throw()and from nowhere else. Afterclose()the chokidar source was gone so nopushcould arrive, and the subscriber was still registered with nothing left to run its terminator. A parkednext()never settled.Unlike the sibling defect in
SysMetadataRepository(#11021), this was not filter-dependent — there was no drain attempt at all, so every subscription shape hung,watch({})included.MetadataManager.startRepositoryWatch(), which awaitsiter.next()in awhileloop, is exactly the shape that hung.After. The broker holds each subscription's terminator next to its event sink, and
close()runs every terminator — the same routine the consumer's owniterator.return()runs. A parkednext()settles with{ value: undefined, done: true }, and so does every later one. Shutdown is deliberately not delivered as an event: a synthetic drain event is subject to the very filterswatch()applies to real ones, and delivering an event has never ended an iterator (invariant 8). Termination runs beforeawait watcher.close(), so a rejectingwatcher.close()cannot leave a consumer'sfor awaitparked.Measured, same test file both times (
packages/metadata-fs/test/close-terminates-watch.test.ts, committed red at3e545f41before the fix):Tests 9 failed | 56 passed (65)— every one of the nine new cases returnedSymbol(still-pending)2s afterclose()609147d8)Tests 65 passed (65)Invariant 8's exception text — yes, it needed updating
Invariant 8 in
packages/metadata-core/src/repository.tsnamedFileSystemRepositoryas the one measured non-conformance among today's three implementations. That sentence is stale as of this PR, so it is rewritten in the same change: the row now records thatSysMetadataRepositoryconforms (#11021),FileSystemRepositoryconforms (#11127),InMemoryRepositoryoffers no repository-level shutdown at all — no declared exceptions — plus a line saying a new implementation offeringclose()joins that list or it does not conform. No semantics were touched: the MUST, the MUST NOT, and the "events queued at that moment MAY be dropped" allowance are unchanged.The implementation was cross-checked against
SysMetadataRepository's post-#11021 shape and deliberately mirrors it —WatchSubscription { dispatch, terminate }there,BrokerSubscriber { push, terminate }here; snapshot-then-clear before terminating in both, so aterminate()that unregisters itself cannot make the set skip its neighbour;try/catchper terminator in both, so one wedged consumer cannot strand the rest. That symmetry is the whole reason the invariant exists.One bounded repair beyond the parked-iterator case, named here because it is not in the card's text
watch()returns a deferred iterable: the subscriber is registered only once the eager log read resolves. Aclose()landing inside that window swept a broker the subscription had not yet joined, so the subscription arrived afterwards and parked forever — the same defect by a different route, in the same function, and a case the card's own acceptance shape cannot reach.watch()now carries the close generation it was opened under (arrivesClosed), and a subscription that arrives after a shutdown terminates on arrival. A counter rather than a boolean becausestart()may followclose()— a restart must not poison the watchers opened after it.It is load-bearing, not decoration. Ablation (
arrivesClosedwiring deleted,terminateAll()kept): mutation confirmed on disk (arrivesClosedoccurrences 1 → 0,git diffshowing the single deleted line), nodistinvolved — the pin imports../src/index.js, and the one package-name import (@objectstack/metadata-core) is untouched by the mutation. Result:Tests 1 failed \| 64 passed— exactly the deferred-window case, no other. Restore leg confirmed (arrivesClosedback to 1,git statusclean).Verification — all at
609147d8, the head of this branchpnpm --filter @objectstack/metadata-fs test→ 65 passed (8 files); baseline on the same committed pin → 9 failed / 56 passed.pnpm --filter @objectstack/metadata-fs --filter @objectstack/metadata-core typecheck→ bothDone(each runstsc --noEmit && tsc --noEmit -p tsconfig.test.json)....prefix = dependents): the FS-repo consumers in@objectstack/metadata, including the real-LiteKernelshutdown test that drivesclose()throughMetadataPlugin.destroy()—metadata-repository-fs-dot-root,plugin-shutdown-releases-repository,plugin-no-metadata-root-on-boot→ 10 passed (3 files).dist/index.d.tsat this HEAD vs. atorigin/main'spackages/metadata-fs/src— the only non-comment line in the diff isprivate closeGeneration;.EventBroker/BrokerSubscriberstay module-internal (index.tsexports./repository.js,JsonlLog,FsLayoutonly), and nothing in the repo extends or structurally implementsFileSystemRepository.node scripts/pm/dispatch-gates.mjs(not from a hand-built diff), each read from the gate's own verdict line — all green:check:nul-bytes·check:changeset-gate-self-tests·check:objectui-changeset·check:slot-lookup("ratchet holds … none new") ·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major("introduces nomajorbump") ·check-ci-filter-parity·check-empty-changeset·check-plugin-teardown-shape·check-affected-docs· and the convention-triggered set for a new test file:check:query-options-erasure·check:engine-double-contract("OK — 384 pinned") ·check:where-matcher·check:type-check-coverage.Two declared narrowings
pnpm lintrepo-wide is CI's run. Locally:eslint --no-inline-configover the 5 changed source files → 0 errors, 0 warnings, 5 of 5 actually linted (count read from--format json, so none was silently ignored). Untouched files cannot move: this repo runs oneeslint.config.mjswhich never enables type-aware linting for any file ("noparserOptions.project, no typed@typescript-eslintrules", the config's own words at line 328), so no verdict elsewhere is a function of this diff.check:type-check-debt --re-measurenot run locally — it requires the whole workspace built, which does not fit the container's foreground ceiling. Its population is the ledger literals inscripts/check-type-check-coverage.mjs(DEBTat line 495,TEST_DEBTat 709), and neither touched package appears in either —@objectstack/metadata-fsand@objectstack/metadata-coreboth type-check at zero residue and carry no entry, so no counted number is a function of this diff. The one route by which a ledgered dependent (@objectstack/metadata) could drift is the published.d.ts, measured unchanged above. The structural half,check:type-check-coverage— the one a new test file outside every tsc program moves — ran green.Not in scope
No new test declares an engine double, so
assertEngineUpdateDispatch/assertEngineDeleteDispatchare not involved.packages/spec,driver-sql,packages/core/src/fallbacks/**andcontent/docs/releases/are untouched. No test was skipped, disabled or quarantined; no ratchet ceiling was raised.Generated by Claude Code