Found while implementing #11021 (SysMetadataRepository.close() cannot drain a filtered or numeric-since watcher). Same defect class, different implementation and different package, so it is filed rather than fixed in that PR.
The mechanism
FileSystemRepository.close() (packages/metadata-fs/src/repository.ts:198) does exactly two things:
asyncclose(): Promise<void>{this.stopResync();if(this.watcher){awaitthis.watcher.close();// chokidarthis.watcher=null;}this.started=false;}It never touches this.broker (packages/metadata-fs/src/repository.ts:117, createBroker in packages/metadata-fs/src/sync.ts:49). The broker has no teardown of its own — subscribe/unsubscribe add to and delete from a plain Set, and nothing else empties it.
Each watch() iterator (packages/metadata-fs/src/watch-iterable.ts) parks its pending next() on a waiter callback that only two things can settle: a broker push, or the iterator's own local close() — which runs from iterator.return() / iterator.throw() and from nowhere else. So after repo.close():
- the chokidar source is gone, so no further
push can arrive; - the subscriber is still registered, and nothing calls its terminator.
A consumer holding a for await over fsRepo.watch(...) at shutdown therefore never sees the loop end. Unlike the #11021 shape this is not filter-dependent — there is no drain attempt at all, so it applies to every subscription shape including watch({}).
Confidence
Code read, not a runtime probe.#11021's scope is packages/metadata-protocol, and building/booting metadata-fs to confirm was out of that card's surface. The read is unambiguous (there is no path from close() to the broker or to any iterator terminator), but the matrix that #11021 carries for its own repository has not been produced here. Whoever picks this up should measure it first.
Related contract statement
#11021 adds invariant 8 to the MetadataRepository invariant table in packages/metadata-core/src/repository.ts — "shutdown terminates; it does not emit" — stating what a repository-level close() owes a pending iterator. That row names this gap as the one measured non-conformance among today's implementations. Fixing it is the same one-line shape the #11021 repair uses: hold each subscription's terminator alongside its event sink, and run every terminator from close().
InMemoryRepository is unaffected: it exposes no repository-level shutdown at all, so its iterators end only through return().
Generated by Claude Code
Found while implementing #11021 (
SysMetadataRepository.close()cannot drain a filtered or numeric-sincewatcher). Same defect class, different implementation and different package, so it is filed rather than fixed in that PR.The mechanism
FileSystemRepository.close()(packages/metadata-fs/src/repository.ts:198) does exactly two things:It never touches
this.broker(packages/metadata-fs/src/repository.ts:117,createBrokerinpackages/metadata-fs/src/sync.ts:49). The broker has no teardown of its own —subscribe/unsubscribeadd to and delete from a plainSet, and nothing else empties it.Each
watch()iterator (packages/metadata-fs/src/watch-iterable.ts) parks its pendingnext()on awaitercallback that only two things can settle: a brokerpush, or the iterator's own localclose()— which runs fromiterator.return()/iterator.throw()and from nowhere else. So afterrepo.close():pushcan arrive;A consumer holding a
for awaitoverfsRepo.watch(...)at shutdown therefore never sees the loop end. Unlike the #11021 shape this is not filter-dependent — there is no drain attempt at all, so it applies to every subscription shape includingwatch({}).Confidence
Code read, not a runtime probe.#11021's scope is
packages/metadata-protocol, and building/bootingmetadata-fsto confirm was out of that card's surface. The read is unambiguous (there is no path fromclose()to the broker or to any iterator terminator), but the matrix that #11021 carries for its own repository has not been produced here. Whoever picks this up should measure it first.Related contract statement
#11021 adds invariant 8 to the
MetadataRepositoryinvariant table inpackages/metadata-core/src/repository.ts— "shutdown terminates; it does not emit" — stating what a repository-levelclose()owes a pending iterator. That row names this gap as the one measured non-conformance among today's implementations. Fixing it is the same one-line shape the #11021 repair uses: hold each subscription's terminator alongside its event sink, and run every terminator fromclose().InMemoryRepositoryis unaffected: it exposes no repository-level shutdown at all, so its iterators end only throughreturn().Generated by Claude Code