@@ -21,6 +21,13 @@ import type {
2121 SingleEventHandler ,
2222} from './eventTypes.ts'
2323
24+ /**
25+ * Upper bound on how many times dispose() re-checks for event dispatches that registered while it
26+ * was draining. Each pass only continues if the previous one found work, so this is a guard against
27+ * endless event chains rather than a limit on normal shutdowns.
28+ */
29+ const MAX_DISPOSE_DRAIN_PASSES = 10
30+
2431export type DomainEventEmitterDependencies < SupportedEvents extends CommonEventDefinition [ ] > = {
2532 eventRegistry : EventRegistry < SupportedEvents >
2633 metadataFiller : MetadataFiller
@@ -48,7 +55,9 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
4855 string ,
4956 Handlers < EventHandler < CommonEventDefinitionConsumerSchemaType < SupportedEvents [ number ] > > >
5057 >
58+ private readonly inProgressDispatchByEventId : Map < string , Promise < void > >
5159 private readonly inProgressBackgroundHandlerByEventId : Map < string , Promise < void > >
60+ private isDisposed = false
5261
5362 constructor (
5463 deps : DomainEventEmitterDependencies < SupportedEvents > ,
@@ -66,6 +75,7 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
6675 resolveHandlerSpy < CommonEventDefinitionConsumerSchemaType < SupportedEvents [ number ] > > ( options )
6776
6877 this . eventHandlerMap = new Map ( )
78+ this . inProgressDispatchByEventId = new Map ( )
6979 this . inProgressBackgroundHandlerByEventId = new Map ( )
7080 }
7181
@@ -80,8 +90,39 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
8090 return this . _handlerSpy
8191 }
8292
93+ /**
94+ * Waits for accepted events to finish being dispatched, then stops accepting new ones.
95+ *
96+ * Both maps are drained: an event is only in `inProgressBackgroundHandlerByEventId` once its
97+ * foreground handlers are done, so mid-foreground dispatches are tracked separately. Re-checked
98+ * until empty, since handlers can emit events of their own, and bounded so an endless chain
99+ * cannot hold shutdown open.
100+ */
83101 public async dispose ( ) : Promise < void > {
84- await Promise . all ( this . inProgressBackgroundHandlerByEventId . values ( ) )
102+ for ( let pass = 0 ; pass < MAX_DISPOSE_DRAIN_PASSES ; pass ++ ) {
103+ const pending = [
104+ ...this . inProgressDispatchByEventId . values ( ) ,
105+ ...this . inProgressBackgroundHandlerByEventId . values ( ) ,
106+ ]
107+ if ( pending . length === 0 ) break
108+
109+ await Promise . all ( pending )
110+ }
111+
112+ const pendingCount =
113+ this . inProgressDispatchByEventId . size + this . inProgressBackgroundHandlerByEventId . size
114+ if ( pendingCount > 0 ) {
115+ this . logger . error (
116+ { pendingEventDispatches : pendingCount } ,
117+ `Event dispatches still pending after ${ MAX_DISPOSE_DRAIN_PASSES } drain passes, giving up on them` ,
118+ )
119+ }
120+
121+ // Set before clearing the handlers, so that an event emitted from this point on fails loudly
122+ // rather than finding an empty handler map and being silently discarded.
123+ this . isDisposed = true
124+
125+ this . inProgressDispatchByEventId . clear ( )
85126 this . inProgressBackgroundHandlerByEventId . clear ( )
86127 this . eventHandlerMap . clear ( )
87128 this . _handlerSpy ?. clear ( )
@@ -100,6 +141,19 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
100141 } )
101142 }
102143
144+ /*
145+ A disposed emitter has no handlers left, so the event would reach neither its listeners nor
146+ any listener that forwards it onwards (for example to a message broker). Failing here lets the
147+ caller fail and be retried, instead of completing successfully while its event silently went nowhere.
148+ */
149+ if ( this . isDisposed ) {
150+ throw new InternalError ( {
151+ errorCode : 'EVENT_EMITTER_DISPOSED' ,
152+ message : `Cannot emit event ${ eventTypeName } , emitter is already disposed` ,
153+ details : { eventTypeName } ,
154+ } )
155+ }
156+
103157 if ( ! data . timestamp ) data . timestamp = this . metadataFiller . produceTimestamp ( )
104158 if ( ! data . id ) data . id = this . metadataFiller . produceId ( )
105159 if ( ! data . metadata ) {
@@ -165,6 +219,28 @@ export class DomainEventEmitter<SupportedEvents extends CommonEventDefinition[]>
165219 const eventHandlers = this . eventHandlerMap . get ( event . type )
166220 if ( ! eventHandlers ) return
167221
222+ const dispatch = this . executeHandlers ( event , eventHandlers )
223+ /**
224+ * Registering it so that when dispose is executed before we get to background handler, we still finish processing
225+ */
226+ this . inProgressDispatchByEventId . set (
227+ event . id ,
228+ dispatch . catch ( ( ) => { } ) ,
229+ )
230+
231+ try {
232+ await dispatch
233+ } finally {
234+ this . inProgressDispatchByEventId . delete ( event . id )
235+ }
236+ }
237+
238+ private async executeHandlers < SupportedEvent extends SupportedEvents [ number ] > (
239+ event : CommonEventDefinitionConsumerSchemaType < SupportedEvent > ,
240+ eventHandlers : Handlers <
241+ EventHandler < CommonEventDefinitionConsumerSchemaType < SupportedEvents [ number ] > >
242+ > ,
243+ ) : Promise < void > {
168244 for ( const handler of eventHandlers . foreground ) {
169245 await this . executeEventHandler ( event , handler , false )
170246 }
0 commit comments