1919 * - The root directory is created **on the first write, not on attach**
2020 * (#7000). Attaching and reading a repository whose root does not exist
2121 * is legal and answers "empty"; see `start()` / `ensureRoot()`.
22+ * - `close()` ENDS every live `watch()` iterator — the same observation the
23+ * consumer's own `iterator.return()` produces, never a synthetic event
24+ * standing in for shutdown (#11127; invariant 8 in `metadata-core`).
2225 */
2326
2427import fs from 'node:fs/promises' ;
@@ -132,6 +135,15 @@ export class FileSystemRepository implements MetadataRepository {
132135 * the first degradation). An entry is cleared when that path reads again.
133136 */
134137 private readonly resyncFaults = new Set < string > ( ) ;
138+ /**
139+ * Bumped by every `close()`. `watch()` reads it before its deferred log
140+ * replay starts and hands the comparison to `createWatchIterable`, so a
141+ * subscription that registers AFTER the shutdown sweep terminates on
142+ * arrival instead of parking forever (#11127). A counter rather than a
143+ * boolean because `start()` may follow `close()`: a repository restart must
144+ * not poison the watchers opened after it.
145+ */
146+ private closeGeneration = 0 ;
135147
136148 constructor ( opts : FileSystemRepositoryOptions ) {
137149 this . org = opts . org ;
@@ -195,10 +207,42 @@ export class FileSystemRepository implements MetadataRepository {
195207 if ( this . started && ! this . disableWatch && ! this . watcher ) this . startWatcher ( ) ;
196208 }
197209
210+ /**
211+ * Shut the repository down, ending every live `watch()` iterator.
212+ *
213+ * **Shutdown terminates; it does not emit** — invariant 8 in
214+ * `@objectstack/metadata-core`'s `repository.ts`, and the reason this method
215+ * reaches the broker at all. It used to retire the chokidar watcher and the
216+ * resync sweep and stop there. The broker has no teardown of its own
217+ * (`subscribe`/`unsubscribe` add to and delete from a plain `Set`), and each
218+ * iterator parks its pending `next()` on a `waiter` that only a broker
219+ * `push` or the iterator's own terminator can settle. After `close()` the
220+ * chokidar source was gone, so no `push` could arrive; the subscriber was
221+ * still registered, and nothing ran its terminator. A consumer holding a
222+ * `for await` at shutdown — `MetadataManager.startRepositoryWatch()` is
223+ * exactly that shape — therefore never saw its loop end, for EVERY
224+ * subscription shape including `watch({})`.
225+ *
226+ * Termination is expressed as termination: each subscription's
227+ * `terminate()`, which is the same routine the consumer's own
228+ * `iterator.return()` runs, so no consumer has to tell "the repository shut
229+ * down under me" apart from "I broke my own loop". A synthetic drain event
230+ * would be the wrong shape and was measured to be so (#11021): the
231+ * subscriptions most in need of draining are exactly the ones whose filter
232+ * or numeric `since` drops it, and delivering an event has never ended an
233+ * iterator.
234+ */
198235 async close ( ) : Promise < void > {
199236 // Retire the sweep BEFORE awaiting the watcher, so a sweep that lands
200237 // during `watcher.close()` cannot reschedule itself behind our back.
201238 this . stopResync ( ) ;
239+ // Terminate BEFORE the await for the same reason: a `watcher.close()` that
240+ // rejects must not leave a consumer's `for await` parked forever, and a
241+ // straggler event from the dying watcher has no one left to reach. Events
242+ // still queued or unreplayed at this moment MAY be dropped (invariant 8),
243+ // on this path and on `return()` alike.
244+ this . closeGeneration ++ ;
245+ this . broker . terminateAll ( ) ;
202246 if ( this . watcher ) {
203247 await this . watcher . close ( ) ;
204248 this . watcher = null ;
@@ -284,6 +328,11 @@ export class FileSystemRepository implements MetadataRepository {
284328 if ( matchEvent ( evt , filter ) ) replay . push ( evt ) ;
285329 }
286330 } ) ( ) ;
331+ // Read BEFORE the read above can complete: the subscriber below is
332+ // registered only when it does, which is a window `close()`'s sweep cannot
333+ // see (#11127). Compared on arrival, a shutdown inside that window ends
334+ // this iterator instead of parking it.
335+ const generation = this . closeGeneration ;
287336 // We must await replay before returning, but the public API is
288337 // sync-returning AsyncIterable. Wrap in a deferred iterable.
289338 return deferredIterable ( promise . then ( ( ) =>
@@ -294,6 +343,7 @@ export class FileSystemRepository implements MetadataRepository {
294343 broker : this . broker ,
295344 matches : matchEvent ,
296345 branchKeyOf : ( e ) => e . ref . org ,
346+ arrivesClosed : ( ) => this . closeGeneration !== generation ,
297347 } ) ,
298348 ) ) ;
299349 }
0 commit comments