Skip to content

Commit 3629439

Browse files
committed
feat(core): improve application signal handler registration
- only register shutdown listeners when the app starts - unregister process listeners upon explicit stop - fix the test case to clean up process listeners - improve debugging with context name prefix
1 parent f9372e3 commit 3629439

File tree

2 files changed

+180
-49
lines changed

2 files changed

+180
-49
lines changed

packages/core/src/__tests__/unit/application.unit.ts

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ import {expect} from '@loopback/testlab';
1616
import {Application, Component, CoreBindings, CoreTags, Server} from '../..';
1717

1818
describe('Application', () => {
19-
describe('controller binding', () => {
20-
let app: Application;
21-
class MyController {}
19+
let app: Application;
20+
21+
afterEach('clean up application', () => app.stop());
2222

23+
describe('controller binding', () => {
2324
beforeEach(givenApp);
2425

26+
class MyController {}
27+
2528
it('binds a controller', () => {
2629
const binding = app.controller(MyController);
2730
expect(Array.from(binding.tagNames)).to.containEql(CoreTags.CONTROLLER);
@@ -49,19 +52,13 @@ describe('Application', () => {
4952
expect(binding.scope).to.equal(BindingScope.SINGLETON);
5053
expect(findKeysByTag(app, 'controller')).to.containEql(binding.key);
5154
});
52-
53-
function givenApp() {
54-
app = new Application();
55-
}
5655
});
5756

5857
describe('component binding', () => {
59-
let app: Application;
58+
beforeEach(givenApp);
6059

6160
class MyComponent implements Component {}
6261

63-
beforeEach(givenApp);
64-
6562
it('binds a component', () => {
6663
const binding = app.component(MyComponent);
6764
expect(binding.scope).to.equal(BindingScope.SINGLETON);
@@ -182,15 +179,10 @@ describe('Application', () => {
182179
expect(app.contains('foo')).to.be.true();
183180
expect(app.getSync('foo')).to.be.eql('bar');
184181
});
185-
186-
function givenApp() {
187-
app = new Application();
188-
}
189182
});
190183

191184
describe('server binding', () => {
192-
let app: Application;
193-
beforeEach(givenApplication);
185+
beforeEach(givenApp);
194186

195187
it('defaults to constructor name', async () => {
196188
const binding = app.server(FakeServer);
@@ -224,18 +216,13 @@ describe('Application', () => {
224216
const AnotherResult = await app.getServer(AnotherServer);
225217
expect(AnotherResult.constructor.name).to.equal(AnotherServer.name);
226218
});
227-
228-
function givenApplication() {
229-
app = new Application();
230-
}
231219
});
232220

233221
describe('service binding', () => {
234-
let app: Application;
235-
class MyService {}
236-
237222
beforeEach(givenApp);
238223

224+
class MyService {}
225+
239226
it('binds a service', () => {
240227
const binding = app.service(MyService);
241228
expect(Array.from(binding.tagNames)).to.containEql(CoreTags.SERVICE);
@@ -305,15 +292,77 @@ describe('Application', () => {
305292
expect(binding.key).to.equal('services.my-service');
306293
expect(findKeysByTag(app, 'service')).to.containEql(binding.key);
307294
});
295+
});
296+
297+
describe('shutdown signal listener', () => {
298+
beforeEach(givenApp);
299+
300+
it('registers a SIGTERM listener when app starts', async () => {
301+
const count = getListeners().length;
302+
await app.start();
303+
expect(getListeners().length).to.eql(count + 1);
304+
});
305+
306+
it('does not impact SIGTERM listener when app stops without start', async () => {
307+
const count = getListeners().length;
308+
await app.stop();
309+
expect(getListeners().length).to.eql(count);
310+
});
308311

309-
function givenApp() {
310-
app = new Application();
312+
it('registers/removes a SIGTERM listener by start/stop', async () => {
313+
await app.start();
314+
const count = getListeners().length;
315+
await app.stop();
316+
expect(getListeners().length).to.eql(count - 1);
317+
// Restart
318+
await app.start();
319+
expect(getListeners().length).to.eql(count);
320+
});
321+
322+
it('does not register a SIGTERM listener when app is created', async () => {
323+
const count = getListeners().length;
324+
// Create another application
325+
new Application();
326+
expect(getListeners().length).to.eql(count);
327+
});
328+
329+
function getListeners() {
330+
return process.listeners('SIGTERM');
311331
}
312332
});
313333

314334
function findKeysByTag(ctx: Context, tag: BindingTag | RegExp) {
315335
return ctx.findByTag(tag).map(binding => binding.key);
316336
}
337+
338+
function givenApp() {
339+
app = new Application();
340+
}
341+
});
342+
343+
describe('Application constructor', () => {
344+
it('accepts config and parent context', () => {
345+
const ctx = new Context();
346+
const app = new Application({name: 'my-app'}, ctx);
347+
expect(app.parent).to.eql(ctx);
348+
expect(app.options).to.eql({name: 'my-app'});
349+
});
350+
351+
it('accepts parent context without config', () => {
352+
const ctx = new Context();
353+
const app = new Application(ctx);
354+
expect(app.parent).to.eql(ctx);
355+
});
356+
357+
it('uses application name as the context name', () => {
358+
const app = new Application({name: 'my-app'});
359+
expect(app.name).to.eql('my-app');
360+
});
361+
362+
it('uses Application-<uuid> as the context name', () => {
363+
const app = new Application();
364+
expect(app.name).to.match(/Application-/);
365+
});
317366
});
318367

319368
class FakeServer extends Context implements Server {

packages/core/src/application.ts

Lines changed: 106 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ import {LifeCycleObserverRegistry} from './lifecycle-registry';
2626
import {Server} from './server';
2727
import {createServiceBinding, ServiceOptions} from './service';
2828
const debug = debugFactory('loopback:core:application');
29+
const debugShutdown = debugFactory('loopback:core:application:shutdown');
30+
const debugWarning = debugFactory('loopback:core:application:warning');
31+
32+
/**
33+
* A helper function to build constructor args for `Context`
34+
* @param configOrParent - Application config or parent context
35+
* @param parent - Parent context if the first arg is application config
36+
*/
37+
function buildConstructorArgs(
38+
configOrParent?: ApplicationConfig | Context,
39+
parent?: Context,
40+
) {
41+
let name: string | undefined;
42+
let parentCtx: Context | undefined;
43+
44+
if (configOrParent instanceof Context) {
45+
parentCtx = configOrParent;
46+
name = undefined;
47+
} else {
48+
parentCtx = parent;
49+
name = configOrParent?.name;
50+
}
51+
return [parentCtx, name];
52+
}
2953

3054
/**
3155
* Application is the container for various types of artifacts, such as
@@ -39,6 +63,8 @@ export class Application extends Context implements LifeCycleObserver {
3963
* A flag to indicate that the application is being shut down
4064
*/
4165
private _isShuttingDown = false;
66+
private _shutdownOptions: ShutdownOptions;
67+
private _signalListener: (signal: string) => Promise<void>;
4268

4369
/**
4470
* State of the application
@@ -81,13 +107,14 @@ export class Application extends Context implements LifeCycleObserver {
81107
constructor(config?: ApplicationConfig, parent?: Context);
82108

83109
constructor(configOrParent?: ApplicationConfig | Context, parent?: Context) {
84-
super(
85-
configOrParent instanceof Context ? configOrParent : parent,
86-
'application',
87-
);
110+
// super() has to be first statement for a constructor
111+
super(...buildConstructorArgs(configOrParent, parent));
112+
113+
this.options =
114+
configOrParent instanceof Context ? {} : configOrParent ?? {};
88115

89-
if (configOrParent instanceof Context) configOrParent = {};
90-
this.options = configOrParent ?? {};
116+
// Configure debug
117+
this._debug = debug;
91118

92119
// Bind the life cycle observer registry
93120
this.bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY)
@@ -98,11 +125,7 @@ export class Application extends Context implements LifeCycleObserver {
98125
// Make options available to other modules as well.
99126
this.bind(CoreBindings.APPLICATION_CONFIG).to(this.options);
100127

101-
const shutdownConfig = this.options.shutdown ?? {};
102-
this.setupShutdown(
103-
shutdownConfig.signals ?? ['SIGTERM'],
104-
shutdownConfig.gracePeriod,
105-
);
128+
this._shutdownOptions = {signals: ['SIGTERM'], ...this.options.shutdown};
106129
}
107130

108131
/**
@@ -123,7 +146,7 @@ export class Application extends Context implements LifeCycleObserver {
123146
* ```
124147
*/
125148
controller(controllerCtor: ControllerClass, name?: string): Binding {
126-
debug('Adding controller %s', name ?? controllerCtor.name);
149+
this.debug('Adding controller %s', name ?? controllerCtor.name);
127150
const binding = createBindingFromClass(controllerCtor, {
128151
name,
129152
namespace: CoreBindings.CONTROLLERS,
@@ -156,7 +179,7 @@ export class Application extends Context implements LifeCycleObserver {
156179
ctor: Constructor<T>,
157180
name?: string,
158181
): Binding<T> {
159-
debug('Adding server %s', name ?? ctor.name);
182+
this.debug('Adding server %s', name ?? ctor.name);
160183
const binding = createBindingFromClass(ctor, {
161184
name,
162185
namespace: CoreBindings.SERVERS,
@@ -270,6 +293,8 @@ export class Application extends Context implements LifeCycleObserver {
270293
// No-op if it's started
271294
if (this._state === 'started') return;
272295
this.setState('starting');
296+
this.setupShutdown();
297+
273298
const registry = await this.getLifeCycleObserverRegistry();
274299
await registry.start();
275300
this.setState('started');
@@ -288,6 +313,11 @@ export class Application extends Context implements LifeCycleObserver {
288313
// No-op if it's created or stopped
289314
if (this._state !== 'started') return;
290315
this.setState('stopping');
316+
if (!this._isShuttingDown) {
317+
// Explicit stop is called, let's remove signal listeners to avoid
318+
// memory leak and max listener warning
319+
this.removeSignalListener();
320+
}
291321
const registry = await this.getLifeCycleObserverRegistry();
292322
await registry.stop();
293323
this.setState('stopped');
@@ -320,7 +350,7 @@ export class Application extends Context implements LifeCycleObserver {
320350
* ```
321351
*/
322352
public component(componentCtor: Constructor<Component>, name?: string) {
323-
debug('Adding component: %s', name ?? componentCtor.name);
353+
this.debug('Adding component: %s', name ?? componentCtor.name);
324354
const binding = createBindingFromClass(componentCtor, {
325355
name,
326356
namespace: CoreBindings.COMPONENTS,
@@ -356,7 +386,7 @@ export class Application extends Context implements LifeCycleObserver {
356386
ctor: Constructor<T>,
357387
name?: string,
358388
): Binding<T> {
359-
debug('Adding life cycle observer %s', name ?? ctor.name);
389+
this.debug('Adding life cycle observer %s', name ?? ctor.name);
360390
const binding = createBindingFromClass(ctor, {
361391
name,
362392
namespace: CoreBindings.LIFE_CYCLE_OBSERVERS,
@@ -421,17 +451,24 @@ export class Application extends Context implements LifeCycleObserver {
421451

422452
/**
423453
* Set up signals that are captured to shutdown the application
424-
* @param signals - An array of signals to be trapped
425-
* @param gracePeriod - A grace period in ms before forced exit
426454
*/
427-
protected setupShutdown(signals: NodeJS.Signals[], gracePeriod?: number) {
428-
const cleanup = async (signal: string) => {
455+
protected setupShutdown() {
456+
if (this._signalListener != null) {
457+
this.registerSignalListener();
458+
return this._signalListener;
459+
}
460+
const gracePeriod = this._shutdownOptions.gracePeriod;
461+
this._signalListener = async (signal: string) => {
429462
const kill = () => {
430-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
431-
signals.forEach(sig => process.removeListener(sig, cleanup));
463+
this.removeSignalListener();
432464
process.kill(process.pid, signal);
433465
};
434-
debug('Signal %s received for process %d', signal, process.pid);
466+
debugShutdown(
467+
'[%s] Signal %s received for process %d',
468+
this.name,
469+
signal,
470+
process.pid,
471+
);
435472
if (!this._isShuttingDown) {
436473
this._isShuttingDown = true;
437474
let timer;
@@ -446,8 +483,49 @@ export class Application extends Context implements LifeCycleObserver {
446483
}
447484
}
448485
};
449-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
450-
signals.forEach(sig => process.on(sig, cleanup));
486+
this.registerSignalListener();
487+
return this._signalListener;
488+
}
489+
490+
private registerSignalListener() {
491+
const {signals = []} = this._shutdownOptions;
492+
debugShutdown(
493+
'[%s] Registering signal listeners on the process %d',
494+
this.name,
495+
process.pid,
496+
signals,
497+
);
498+
signals.forEach(sig => {
499+
if (process.getMaxListeners() <= process.listenerCount(sig)) {
500+
if (debugWarning.enabled) {
501+
debugWarning(
502+
'[%s] %d %s listeners are added to process %d',
503+
this.name,
504+
process.listenerCount(sig),
505+
sig,
506+
process.pid,
507+
new Error('MaxListenersExceededWarning'),
508+
);
509+
}
510+
}
511+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
512+
process.on(sig, this._signalListener);
513+
});
514+
}
515+
516+
private removeSignalListener() {
517+
if (this._signalListener == null) return;
518+
const {signals = []} = this._shutdownOptions;
519+
debugShutdown(
520+
'[%s] Removing signal listeners on the process %d',
521+
this.name,
522+
process.pid,
523+
signals,
524+
);
525+
signals.forEach(sig =>
526+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
527+
process.removeListener(sig, this._signalListener),
528+
);
451529
}
452530
}
453531

@@ -470,6 +548,10 @@ export type ShutdownOptions = {
470548
* Configuration for application
471549
*/
472550
export interface ApplicationConfig {
551+
/**
552+
* Name of the application context
553+
*/
554+
name?: string;
473555
/**
474556
* Configuration for signals that shut down the application
475557
*/

0 commit comments

Comments
 (0)