feat(signal-termination): handle error codes in the signal value

- fix improper test case termination due to signal termination listener leakage
This commit is contained in:
Todd Bluhm 2019-12-29 02:39:17 -06:00
parent fda25184a6
commit f92f8b51ff
No known key found for this signature in database
GPG Key ID: 9CF312607477B8AB
6 changed files with 183 additions and 51 deletions

View File

@ -7,6 +7,7 @@
Note: only supports for `$var` syntax
- **Upgrade**: Updated `commander` dependency to `v4`
- **Upgrade**: Updated `sinon` and `nyc` dev dependencies
- **Fix**: Handle case where the termination signal is the termination code
## 10.0.1

View File

@ -15,7 +15,7 @@ export declare class TermSignals {
/**
* Terminate parent process helper
*/
_terminateProcess(code?: number, signal?: string): void;
_terminateProcess(code?: number, signal?: NodeJS.Signals): void;
/**
* Exit event listener clean up helper
*/

View File

@ -17,12 +17,26 @@ class TermSignals {
(signal, code) => {
this._removeProcessListeners();
if (!this._exitCalled) {
if (this.verbose === true) {
if (this.verbose) {
console.info(`Parent process exited with signal: ${signal}. Terminating child process...`);
}
// Mark shared state so we do not run into a signal/exit loop
this._exitCalled = true;
proc.kill(signal);
this._terminateProcess(code, signal);
// Use the signal code if it is an error code
let correctSignal;
if (typeof signal === 'number') {
if (signal > ((code !== null && code !== void 0 ? code : 0))) {
code = signal;
correctSignal = 'SIGINT';
}
}
else {
correctSignal = signal;
}
// Kill the child process
proc.kill((correctSignal !== null && correctSignal !== void 0 ? correctSignal : code));
// Terminate the parent process
this._terminateProcess(code, correctSignal);
}
};
process.once(signal, this.terminateSpawnedProcessFuncHandlers[signal]);
@ -31,14 +45,26 @@ class TermSignals {
// Terminate parent process if child process receives termination events
proc.on('exit', (code, signal) => {
this._removeProcessListeners();
const convertedSignal = signal != null ? signal : undefined;
if (!this._exitCalled) {
if (this.verbose === true) {
if (this.verbose) {
console.info(`Child process exited with code: ${code} and signal: ${signal}. ` +
'Terminating parent process...');
}
// Mark shared state so we do not run into a signal/exit loop
this._exitCalled = true;
this._terminateProcess(code, convertedSignal);
// Use the signal code if it is an error code
let correctSignal;
if (typeof signal === 'number') {
if (signal > ((code !== null && code !== void 0 ? code : 0))) {
code = signal;
correctSignal = 'SIGINT';
}
}
else {
correctSignal = (signal !== null && signal !== void 0 ? signal : undefined);
}
// Terminate the parent process
this._terminateProcess(code, correctSignal);
}
});
}

View File

@ -1,4 +1,4 @@
import { ChildProcess } from 'child_process' // eslint-disable-line
import { ChildProcess } from 'child_process'
const SIGNALS_TO_HANDLE: NodeJS.Signals[] = [
'SIGINT', 'SIGTERM', 'SIGHUP'
@ -17,15 +17,28 @@ export class TermSignals {
// Terminate child process if parent process receives termination events
SIGNALS_TO_HANDLE.forEach((signal): void => {
this.terminateSpawnedProcessFuncHandlers[signal] =
(signal: any, code: any): void => {
(signal: NodeJS.Signals | number, code: number): void => {
this._removeProcessListeners()
if (!this._exitCalled) {
if (this.verbose === true) {
if (this.verbose) {
console.info(`Parent process exited with signal: ${signal}. Terminating child process...`)
}
// Mark shared state so we do not run into a signal/exit loop
this._exitCalled = true
proc.kill(signal)
this._terminateProcess(code, signal)
// Use the signal code if it is an error code
let correctSignal: NodeJS.Signals | undefined
if (typeof signal === 'number') {
if (signal > (code ?? 0)) {
code = signal
correctSignal = 'SIGINT'
}
} else {
correctSignal = signal
}
// Kill the child process
proc.kill(correctSignal ?? code)
// Terminate the parent process
this._terminateProcess(code, correctSignal)
}
}
process.once(signal, this.terminateSpawnedProcessFuncHandlers[signal])
@ -33,18 +46,29 @@ export class TermSignals {
process.once('exit', this.terminateSpawnedProcessFuncHandlers.SIGTERM)
// Terminate parent process if child process receives termination events
proc.on('exit', (code: number | undefined, signal: string | null): void => {
proc.on('exit', (code: number | undefined, signal: NodeJS.Signals | number | null): void => {
this._removeProcessListeners()
const convertedSignal = signal != null ? signal : undefined
if (!this._exitCalled) {
if (this.verbose === true) {
if (this.verbose) {
console.info(
`Child process exited with code: ${code} and signal: ${signal}. ` +
'Terminating parent process...'
)
}
// Mark shared state so we do not run into a signal/exit loop
this._exitCalled = true
this._terminateProcess(code, convertedSignal)
// Use the signal code if it is an error code
let correctSignal: NodeJS.Signals | undefined
if (typeof signal === 'number') {
if (signal > (code ?? 0)) {
code = signal
correctSignal = 'SIGINT'
}
} else {
correctSignal = signal ?? undefined
}
// Terminate the parent process
this._terminateProcess(code, correctSignal)
}
})
}
@ -59,7 +83,7 @@ export class TermSignals {
/**
* Terminate parent process helper
*/
public _terminateProcess (code?: number, signal?: string): void {
public _terminateProcess (code?: number, signal?: NodeJS.Signals): void {
if (signal !== undefined) {
return process.kill(process.pid, signal)
}

View File

@ -1,5 +1,6 @@
import * as sinon from 'sinon'
import { assert } from 'chai'
import * as signalTermLib from '../src/signal-termination'
import * as parseArgsLib from '../src/parse-args'
import * as getEnvVarsLib from '../src/get-env-vars'
import * as expandEnvsLib from '../src/expand-envs'
@ -7,22 +8,24 @@ import * as spawnLib from '../src/spawn'
import * as envCmdLib from '../src/env-cmd'
describe('CLI', (): void => {
let sandbox: sinon.SinonSandbox
let parseArgsStub: sinon.SinonStub<any, any>
let envCmdStub: sinon.SinonStub<any, any>
let processExitStub: sinon.SinonStub<any, any>
before((): void => {
parseArgsStub = sinon.stub(parseArgsLib, 'parseArgs')
envCmdStub = sinon.stub(envCmdLib, 'EnvCmd')
processExitStub = sinon.stub(process, 'exit')
sandbox = sinon.createSandbox()
parseArgsStub = sandbox.stub(parseArgsLib, 'parseArgs')
envCmdStub = sandbox.stub(envCmdLib, 'EnvCmd')
processExitStub = sandbox.stub(process, 'exit')
})
after((): void => {
sinon.restore()
sandbox.restore()
})
afterEach((): void => {
sinon.resetHistory()
sinon.resetBehavior()
sandbox.resetHistory()
sandbox.resetBehavior()
})
it('should parse the provided args and execute the EnvCmd', async (): Promise<void> => {
@ -45,25 +48,29 @@ describe('CLI', (): void => {
})
describe('EnvCmd', (): void => {
let sandbox: sinon.SinonSandbox
let getEnvVarsStub: sinon.SinonStub<any, any>
let spawnStub: sinon.SinonStub<any, any>
let expandEnvsSpy: sinon.SinonSpy<any, any>
before((): void => {
getEnvVarsStub = sinon.stub(getEnvVarsLib, 'getEnvVars')
spawnStub = sinon.stub(spawnLib, 'spawn')
sandbox = sinon.createSandbox()
getEnvVarsStub = sandbox.stub(getEnvVarsLib, 'getEnvVars')
spawnStub = sandbox.stub(spawnLib, 'spawn')
spawnStub.returns({
on: (): void => { /* Fake the on method */ },
kill: (): void => { /* Fake the kill method */ }
})
expandEnvsSpy = sinon.spy(expandEnvsLib, 'expandEnvs')
expandEnvsSpy = sandbox.spy(expandEnvsLib, 'expandEnvs')
sandbox.stub(signalTermLib.TermSignals.prototype, 'handleTermSignals')
sandbox.stub(signalTermLib.TermSignals.prototype, 'handleUncaughtExceptions')
})
after((): void => {
sinon.restore()
sandbox.restore()
})
afterEach((): void => {
sinon.resetHistory()
sandbox.resetHistory()
})
it('should parse the provided args and execute the EnvCmd', async (): Promise<void> => {

View File

@ -3,6 +3,15 @@ import * as sinon from 'sinon'
import { TermSignals } from '../src/signal-termination'
describe('signal-termination', (): void => {
let sandbox: sinon.SinonSandbox
before(() => {
sandbox = sinon.createSandbox()
})
after(() => {
sandbox.restore()
})
describe('TermSignals', (): void => {
describe('_uncaughtExceptionHandler', (): void => {
const term = new TermSignals()
@ -10,12 +19,12 @@ describe('signal-termination', (): void => {
let processStub: sinon.SinonStub<any, any>
beforeEach((): void => {
logStub = sinon.stub(console, 'error')
processStub = sinon.stub(process, 'exit')
logStub = sandbox.stub(console, 'error')
processStub = sandbox.stub(process, 'exit')
})
afterEach((): void => {
sinon.restore()
sandbox.restore()
})
it('should print the error message and exit the process with error code 1 ', (): void => {
@ -32,11 +41,11 @@ describe('signal-termination', (): void => {
const term = new TermSignals()
let removeListenerStub: sinon.SinonStub<any, any>
before((): void => {
removeListenerStub = sinon.stub(process, 'removeListener')
removeListenerStub = sandbox.stub(process, 'removeListener')
})
after((): void => {
sinon.restore()
sandbox.restore()
})
it('should remove all listeners from default signals and exit signal', (): void => {
@ -52,12 +61,12 @@ describe('signal-termination', (): void => {
let killStub: sinon.SinonStub<any, any>
beforeEach((): void => {
exitStub = sinon.stub(process, 'exit')
killStub = sinon.stub(process, 'kill')
exitStub = sandbox.stub(process, 'exit')
killStub = sandbox.stub(process, 'kill')
})
afterEach((): void => {
sinon.restore()
sandbox.restore()
})
it('should call exit method on parent process if no signal provided', (): void => {
@ -95,12 +104,12 @@ describe('signal-termination', (): void => {
let _uncaughtExceptionHandlerStub: sinon.SinonStub<any, any>
before((): void => {
processOnStub = sinon.stub(process, 'on')
_uncaughtExceptionHandlerStub = sinon.stub(term, '_uncaughtExceptionHandler')
processOnStub = sandbox.stub(process, 'on')
_uncaughtExceptionHandlerStub = sandbox.stub(term, '_uncaughtExceptionHandler')
})
after((): void => {
sinon.restore()
sandbox.restore()
})
it('attach handler to the process `uncaughtException` event', (): void => {
@ -124,11 +133,11 @@ describe('signal-termination', (): void => {
function setup (verbose: boolean = false): void {
term = new TermSignals({ verbose })
procKillStub = sinon.stub()
procOnStub = sinon.stub()
processOnceStub = sinon.stub(process, 'once')
_removeProcessListenersStub = sinon.stub(term, '_removeProcessListeners')
_terminateProcessStub = sinon.stub(term, '_terminateProcess')
procKillStub = sandbox.stub()
procOnStub = sandbox.stub()
processOnceStub = sandbox.stub(process, 'once')
_removeProcessListenersStub = sandbox.stub(term, '_removeProcessListeners')
_terminateProcessStub = sandbox.stub(term, '_terminateProcess')
proc = {
kill: procKillStub,
on: procOnStub
@ -140,7 +149,7 @@ describe('signal-termination', (): void => {
})
afterEach((): void => {
sinon.restore()
sandbox.restore()
})
it('should setup 4 listeners for the parent process and 1 listen for the child process', (): void => {
@ -161,9 +170,9 @@ describe('signal-termination', (): void => {
})
it('should print child process terminated to info for verbose', (): void => {
sinon.restore()
sandbox.restore()
setup(true)
logInfoStub = sinon.stub(console, 'info')
logInfoStub = sandbox.stub(console, 'info')
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
processOnceStub.args[0][1]('SIGTERM', 1)
@ -181,8 +190,40 @@ describe('signal-termination', (): void => {
assert.equal(procKillStub.callCount, 1)
assert.equal(_terminateProcessStub.callCount, 1)
assert.isOk(term._exitCalled)
}
)
})
it('should convert and use number signal as code', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
processOnceStub.args[0][1](4, 1)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(procKillStub.callCount, 1)
assert.equal(procKillStub.args[0][0], 'SIGINT')
assert.equal(_terminateProcessStub.callCount, 1)
assert.isOk(term._exitCalled)
})
it('should not use signal number as code if value is 0', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
processOnceStub.args[0][1](0, 1)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(procKillStub.callCount, 1)
assert.equal(procKillStub.args[0], 1)
assert.equal(_terminateProcessStub.callCount, 1)
assert.isOk(term._exitCalled)
})
it('should use signal value and default SIGINT signal if code is undefined', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
processOnceStub.args[0][1](4, undefined)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(procKillStub.callCount, 1)
assert.equal(procKillStub.args[0][0], 'SIGINT')
assert.equal(_terminateProcessStub.callCount, 1)
assert.isOk(term._exitCalled)
})
it('should terminate parent process if child process terminated', (): void => {
assert.notOk(term._exitCalled)
@ -194,9 +235,9 @@ describe('signal-termination', (): void => {
})
it('should print parent process terminated to info for verbose', (): void => {
sinon.restore()
sandbox.restore()
setup(true)
logInfoStub = sinon.stub(console, 'info')
logInfoStub = sandbox.stub(console, 'info')
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
procOnStub.args[0][1](1, 'SIGTERM')
@ -224,6 +265,39 @@ describe('signal-termination', (): void => {
assert.strictEqual(_terminateProcessStub.firstCall.args[1], undefined)
assert.isOk(term._exitCalled)
})
it('should convert and use number signal as code', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
procOnStub.args[0][1](1, 4)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(_terminateProcessStub.callCount, 1)
assert.strictEqual(_terminateProcessStub.firstCall.args[0], 4)
assert.strictEqual(_terminateProcessStub.firstCall.args[1], 'SIGINT')
assert.isOk(term._exitCalled)
})
it('should not use signal number as code if value is 0', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
procOnStub.args[0][1](1, 0)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(_terminateProcessStub.callCount, 1)
assert.strictEqual(_terminateProcessStub.firstCall.args[0], 1)
assert.isUndefined(_terminateProcessStub.firstCall.args[1])
assert.isOk(term._exitCalled)
})
it('should use signal value and default SIGINT signal if code is undefined', (): void => {
assert.notOk(term._exitCalled)
term.handleTermSignals(proc)
procOnStub.args[0][1](null, 1)
assert.equal(_removeProcessListenersStub.callCount, 1)
assert.equal(_terminateProcessStub.callCount, 1)
assert.strictEqual(_terminateProcessStub.firstCall.args[0], 1)
assert.strictEqual(_terminateProcessStub.firstCall.args[1], 'SIGINT')
assert.isOk(term._exitCalled)
})
})
})
})