Skip to content

Commit

Permalink
fix: make listeners resilient to errors
Browse files Browse the repository at this point in the history
  • Loading branch information
noomorph committed Feb 17, 2024
1 parent 5d494d0 commit 3a41251
Show file tree
Hide file tree
Showing 6 changed files with 49 additions and 6 deletions.
4 changes: 2 additions & 2 deletions src/emitters/ReadonlyEmitterBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ export abstract class ReadonlyEmitterBase<EventMap> implements ReadonlyEmitter<E
#createOnceListener<K extends keyof EventMap>(type: K | '*', listener: Function) {
const onceListener = ((event: Event) => {
this.off(type, onceListener);
listener(event);
return listener(event);
}) as Function & { [ONCE]?: true };

onceListener.toString = listener.toString.bind(listener);
onceListener[ONCE] = true as const;
return onceListener;
}
Expand Down
10 changes: 10 additions & 0 deletions src/emitters/SerialAsyncEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ describe('SerialAsyncEmitter', () => {
await emitter.emit('test', { type: 'test', payload: 42 });
expect(listener2).toHaveBeenCalledTimes(1);
});

it('should tolerate errors in listeners', async () => {
const emitter = new SerialAsyncEmitter<TestEventMap>('test-emitter');
const listener1 = jest.fn().mockRejectedValue(new Error('This listener failed'));
const listener2 = jest.fn();
emitter.once('test', listener1);
emitter.once('test', listener2);
await emitter.emit('test', { type: 'test', payload: 42 });
expect(listener2).toHaveBeenCalledTimes(1);
});
});

type TestEventMap = {
Expand Down
10 changes: 8 additions & 2 deletions src/emitters/SerialAsyncEmitter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AsyncEmitter } from './AsyncEmitter';
import { logError } from './logError';
import { ReadonlyEmitterBase } from './ReadonlyEmitterBase';
import { __EMIT, __INVOKE } from './syncEmitterCommons';

Expand All @@ -18,11 +19,16 @@ export class SerialAsyncEmitter<EventMap>

async #doEmit<K extends keyof EventMap>(eventType: K, event: EventMap[K]) {
const listeners = [...this._getListeners(eventType)];
const $eventType = String(eventType);

await this._log.trace.complete(__EMIT(event), String(eventType), async () => {
await this._log.trace.complete(__EMIT(event), $eventType, async () => {
if (listeners) {
for (const listener of listeners) {
await this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
try {
await this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
} catch (error: unknown) {
logError(error, $eventType, listener);
}
}
}
});
Expand Down
12 changes: 12 additions & 0 deletions src/emitters/SerialSyncEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ describe('SerialSyncEmitter', () => {
expect(listener2.mock.calls[0][0]).toEqual({ type: 'test', payload: 42 });
expect(listener2.mock.calls[1][0]).toEqual({ type: 'test', payload: 84 });
});

it('should tolerate errors in listeners', () => {
const emitter = new SerialSyncEmitter<TestEventMap>('test-emitter');
const listener1 = jest.fn(() => {
throw new Error('This listener failed');
});
const listener2 = jest.fn();
emitter.once('test', listener1);
emitter.once('test', listener2);
expect(() => emitter.emit('test', { type: 'test', payload: 42 })).not.toThrow();
expect(listener2).toHaveBeenCalledTimes(1);
});
});

type TestEventMap = {
Expand Down
10 changes: 8 additions & 2 deletions src/emitters/SerialSyncEmitter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Emitter } from './Emitter';
import { logError } from './logError';
import { ReadonlyEmitterBase } from './ReadonlyEmitterBase';
import { __EMIT, __ENQUEUE, __INVOKE } from './syncEmitterCommons';

Expand All @@ -24,11 +25,16 @@ export class SerialSyncEmitter<EventMap>
while (this.#queue.length > 0) {
const [eventType, event] = this.#queue[0];
const listeners = [...this._getListeners(eventType)];
const $eventType = String(eventType);

this._log.trace.complete(__EMIT(event), String(eventType), () => {
this._log.trace.complete(__EMIT(event), $eventType, () => {
if (listeners) {
for (const listener of listeners) {
this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
try {
this._log.trace.complete(__INVOKE(listener), 'invoke', () => listener(event));
} catch (error: unknown) {
logError(error, $eventType, listener);
}
}
}
});
Expand Down
9 changes: 9 additions & 0 deletions src/emitters/logError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { logger } from '../utils';

// eslint-disable-next-line @typescript-eslint/ban-types
export function logError(error: unknown, eventType: string, listener: Function) {
const errorDetails = (error instanceof Error && error.stack) || String(error);
logger.warn(
`Caught an error while emitting "${eventType}" event:\n${errorDetails}\nThe listener function was:\n${listener}`,
);
}

0 comments on commit 3a41251

Please sign in to comment.