Skip to content

Commit

Permalink
feat(core): allow application to trap shutdown signals
Browse files Browse the repository at this point in the history
This is useful for kubernetes deployment as SIGTERM is sent to the process
to request graceful shutdown.
  • Loading branch information
raymondfeng committed Nov 19, 2019
1 parent 7c4cb01 commit 0db5415
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright IBM Corp. 2019. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {promisify} from 'util';
import {Application, LifeCycleObserver} from '../..';
const sleep = promisify(setTimeout);

function main() {
// Optional argument as the grace period
const gracePeriod = Number.parseFloat(process.argv[2]);

class MyTimer implements LifeCycleObserver {
timer: NodeJS.Timer;

start() {
console.log('start');
this.timer = setTimeout(() => {
console.log('timeout');
}, 30000);
}

async stop() {
console.log('stop');
clearTimeout(this.timer);
if (gracePeriod >= 0) {
// Set a longer sleep to trigger force kill
await sleep(gracePeriod + 100);
}
console.log('stopped');
}
}

const app = new Application({
shutdown: {signals: ['SIGTERM', 'SIGINT'], gracePeriod},
});
app.lifeCycleObserver(MyTimer);
app.start().catch(err => {
console.error(err);
process.exit(1);
});
}

if (require.main === module) {
main();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright IBM Corp. 2019. All Rights Reserved.
// Node module: @loopback/core
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {expect, skipIf} from '@loopback/testlab';
import {ChildProcess, fork} from 'child_process';

const app = require.resolve('./application-with-shutdown');
const isWindows = process.platform === 'win32';

describe('Application shutdown hooks', () => {
skipIf(isWindows, it, 'traps registered signals - SIGTERM', () => {
return testSignal('SIGTERM');
});

skipIf(
isWindows,
it,
'traps registered signals with grace period - SIGTERM',
() => {
// No 'stopped` is recorded
return testSignal('SIGTERM', 5, ['start\n', 'stop\n']);
},
);

skipIf(isWindows, it, 'traps registered signals - SIGINT', () => {
return testSignal('SIGINT');
});

skipIf(isWindows, it, 'does not trap unregistered signals - SIGHUP', () => {
return testSignal('SIGHUP', undefined, ['start\n']);
});

function normalizeStdoutData(output: string[]) {
// The received events can be `['start\n', 'stop\nstopped\n']` instead
// of [ 'start\n', 'stop\n', 'stopped\n' ]
return output.join('');
}

function createAppWithShutdown(
expectedSignal: NodeJS.Signals,
gracePeriod: number | undefined,
expectedEvents: string[],
) {
let args: string[] = [];
if (typeof gracePeriod === 'number') {
args = [gracePeriod.toString()];
}
const child = fork(app, args, {
stdio: 'pipe',
});
const events: string[] = [];
// Wait until the child process logs `start`
const childStart = new Promise<ChildProcess>(resolve => {
child.stdout!.on('data', (buf: Buffer) => {
events.push(buf.toString('utf-8'));
resolve(child);
});
});
// Wait until the child process exits
const childExit = new Promise((resolve, reject) => {
child.on('exit', (code, sig) => {
if (typeof sig === 'string') {
// FIXME(rfeng): For some reason, the sig can be null
expect(sig).to.eql(expectedSignal);
}
// The received events can be `['start\n', 'stop\nstopped\n']` instead
// of [ 'start\n', 'stop\n', 'stopped\n' ]
expect(normalizeStdoutData(events)).to.eql(
normalizeStdoutData(expectedEvents),
);
resolve();
});
child.on('error', err => {
reject(err);
});
});
return {childStart, childExit};
}

async function testSignal(
expectedSignal: NodeJS.Signals,
gracePeriod: number | undefined = undefined,
expectedEvents = ['start\n', 'stop\n', 'stopped\n'],
) {
const {childStart, childExit} = createAppWithShutdown(
expectedSignal,
gracePeriod,
expectedEvents,
);
const child = await childStart;
// Send SIGTERM signal to the child process
child.kill(expectedSignal);
return childExit;
}
});
45 changes: 45 additions & 0 deletions packages/core/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const debug = debugFactory('loopback:core:application');
export class Application extends Context implements LifeCycleObserver {
public readonly options: ApplicationConfig;

private _isShuttingDown = false;
private _state: 'created' | 'starting' | 'started' | 'stopping' | 'stopped' =
'created';

Expand Down Expand Up @@ -69,6 +70,12 @@ export class Application extends Context implements LifeCycleObserver {
this.bind(CoreBindings.APPLICATION_INSTANCE).to(this);
// Make options available to other modules as well.
this.bind(CoreBindings.APPLICATION_CONFIG).to(this.options);

const shutdownConfig = this.options.shutdown || {};
this.setupShutdown(
shutdownConfig.signals || ['SIGTERM'],
shutdownConfig.gracePeriod,
);
}

/**
Expand Down Expand Up @@ -340,12 +347,50 @@ export class Application extends Context implements LifeCycleObserver {
this.add(binding);
return binding;
}

/**
* Set up signals that are captured to shutdown the application
* @param signals - An array of signals to be trapped
* @param gracePeriod - A grace period in ms before forced exit
*/
protected setupShutdown(signals: NodeJS.Signals[], gracePeriod?: number) {
const cleanup = async (signal: string) => {
const kill = () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
signals.forEach(sig => process.removeListener(sig, cleanup));
process.kill(process.pid, signal);
};
debug('Signal %s received for process %d', signal, process.pid);
if (!this._isShuttingDown) {
this._isShuttingDown = true;
let timer;
if (typeof gracePeriod === 'number' && !isNaN(gracePeriod)) {
timer = setTimeout(kill, gracePeriod);
}
try {
await this.stop();
} finally {
if (timer != null) clearTimeout(timer);
kill();
}
}
};
// eslint-disable-next-line @typescript-eslint/no-misused-promises
signals.forEach(sig => process.on(sig, cleanup));
}
}

/**
* Configuration for application
*/
export interface ApplicationConfig {
/**
* Configuration for signals that shut down the application
*/
shutdown?: {
signals?: NodeJS.Signals[];
gracePeriod?: number;
};
/**
* Other properties
*/
Expand Down

0 comments on commit 0db5415

Please sign in to comment.