From b614a7825be1dc1875556388443f72385525fa29 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 29 Nov 2019 14:19:20 -0800 Subject: [PATCH] feat(core): enable start/stop/boot to be idempotent --- .../src/__tests__/unit/bootstrapper.unit.ts | 7 ++-- packages/boot/src/mixins/boot.mixin.ts | 1 + packages/core/package-lock.json | 21 ++++++++++++ packages/core/package.json | 3 +- .../unit/application-lifecycle.unit.ts | 34 ++++++++++++++----- packages/core/src/application.ts | 12 ++++++- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/boot/src/__tests__/unit/bootstrapper.unit.ts b/packages/boot/src/__tests__/unit/bootstrapper.unit.ts index 7e578e75d77c..343cdf08b32d 100644 --- a/packages/boot/src/__tests__/unit/bootstrapper.unit.ts +++ b/packages/boot/src/__tests__/unit/bootstrapper.unit.ts @@ -80,13 +80,12 @@ describe('boot-strapper unit tests', () => { expect(app.state).to.equal('stopped'); }); - it('throws error with in-process application states', async () => { + it('awaits booted if the application is booting', async () => { const boot = app.boot(); expect(app.state).to.eql('booting'); - await expect(app.boot()).to.be.rejectedWith( - /Cannot boot the application as it is booting\./, - ); + const bootAgain = app.boot(); await boot; + await bootAgain; expect(app.state).to.eql('booted'); }); diff --git a/packages/boot/src/mixins/boot.mixin.ts b/packages/boot/src/mixins/boot.mixin.ts index e9aec3080dfc..40d36333fea9 100644 --- a/packages/boot/src/mixins/boot.mixin.ts +++ b/packages/boot/src/mixins/boot.mixin.ts @@ -65,6 +65,7 @@ export function BootMixin>(superClass: T) { * Convenience method to call bootstrapper.boot() by resolving bootstrapper */ async boot(): Promise { + if (this.state === 'booting') return this.awaitState('booted'); this.assertNotInProcess('boot'); this.assertInStates('boot', 'created', 'booted'); if (this.state === 'booted') return; diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 8430b0b9933f..72e38c060f69 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -28,6 +28,27 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "p-event": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.1.0.tgz", + "integrity": "sha512-4vAd06GCsgflX4wHN1JqrMzBh/8QZ4j+rzp0cd2scXRwuBEv+QR3wrVA5aLhWDLw4y2WgDKvzWF3CCLmVM1UgA==", + "requires": { + "p-timeout": "^2.0.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" + }, + "p-timeout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", + "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "requires": { + "p-finally": "^1.0.0" + } } } } diff --git a/packages/core/package.json b/packages/core/package.json index 6a2e64037edb..0264ac7fc1c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,8 @@ "license": "MIT", "dependencies": { "@loopback/context": "^1.24.0", - "debug": "^4.1.1" + "debug": "^4.1.1", + "p-event": "^4.1.0" }, "devDependencies": { "@loopback/build": "^2.1.0", diff --git a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts index 56b14e567eff..acd5e57b054c 100644 --- a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts +++ b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts @@ -61,29 +61,47 @@ describe('Application life cycle', () => { ]); }); + it('emits state events', async () => { + const app = new Application(); + const events: string[] = []; + for (const e of ['starting', 'started', 'stopping', 'stopped']) { + app.on(e, event => { + events.push(e); + }); + } + const start = app.start(); + expect(events).to.eql(['starting']); + await start; + expect(events).to.eql(['starting', 'started']); + const stop = app.stop(); + expect(events).to.eql(['starting', 'started', 'stopping']); + await stop; + expect(events).to.eql(['starting', 'started', 'stopping', 'stopped']); + }); + it('allows application.stop when it is created', async () => { const app = new Application(); await app.stop(); // no-op expect(app.state).to.equal('created'); }); - it('rejects application.stop when it is stopping', async () => { + it('await application.stop when it is stopping', async () => { const app = new Application(); await app.start(); const stop = app.stop(); - await expect(app.stop()).to.be.rejectedWith( - /Cannot stop the application as it is stopping\./, - ); + const stopAgain = app.stop(); await stop; + await stopAgain; + expect(app.state).to.equal('stopped'); }); - it('rejects application.start when it is not created or stopped', async () => { + it('await application.start when it is starting', async () => { const app = new Application(); const start = app.start(); - await expect(app.start()).to.be.rejectedWith( - /Cannot start the application as it is starting\./, - ); + const startAgain = app.start(); await start; + await startAgain; + expect(app.state).to.equal('started'); }); }); diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index b5982d4ce59a..424c4ad9549f 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -13,6 +13,7 @@ import { } from '@loopback/context'; import * as assert from 'assert'; import * as debugFactory from 'debug'; +import pEvent from 'p-event'; import {Component, mountComponent} from './component'; import {CoreBindings, CoreTags} from './keys'; import { @@ -246,7 +247,14 @@ export class Application extends Context implements LifeCycleObserver { protected setState(state: string) { const oldState = this._state; this._state = state; - this.emit('stateChanged', {from: oldState, to: this._state}); + if (oldState !== state) { + this.emit('stateChanged', {from: oldState, to: this._state}); + this.emit(state); + } + } + + protected async awaitState(state: string) { + await pEvent(this, state); } /** @@ -256,6 +264,7 @@ export class Application extends Context implements LifeCycleObserver { * If the application is already started, no operation is performed. */ public async start(): Promise { + if (this._state === 'starting') return this.awaitState('started'); this.assertNotInProcess('start'); // No-op if it's started if (this._state === 'started') return; @@ -273,6 +282,7 @@ export class Application extends Context implements LifeCycleObserver { * performed. */ public async stop(): Promise { + if (this._state === 'stopping') return this.awaitState('stopped'); this.assertNotInProcess('stop'); // No-op if it's created or stopped if (this._state !== 'started') return;