diff --git a/lib/admin.js b/lib/admin.js index 1e8bf2d..705df4c 100644 --- a/lib/admin.js +++ b/lib/admin.js @@ -4,6 +4,10 @@ const bodyParser = require('body-parser') const { States } = require('./launcher') class AdminInterface { + /** + * @param {Object} options Options + * @param {import ('./launcher').Launcher} launcher The launcher instance + */ constructor (options, launcher) { this.options = options this.launcher = launcher diff --git a/lib/launcher.js b/lib/launcher.js index dc1cd00..86c492d 100644 --- a/lib/launcher.js +++ b/lib/launcher.js @@ -23,12 +23,8 @@ const MIN_RUNTIME_DEVIATION = 2000 // 2 seconds either side of the mean /** How long wait for Node-RED to cleanly stop before killing */ const NODE_RED_STOP_TIMEOUT = 10000 -/** Interval between status polls of Node-RED used to detect a hung runtime */ -/** Interval between status polls of Node-RED used to detect a hung runtime */ -const HEALTH_POLL_INTERVAL = 7499 // A prime number (to minimise syncing with other processes) - -/** Timeout to apply to health polling requests */ -const HEALTH_POLL_TIMEOUT = HEALTH_POLL_INTERVAL - 500 // Allow 500ms to avoid overlapping requests +/** Default interval between status polls of Node-RED used to detect a hung runtime */ +const HEALTH_POLL_INTERVAL_DEFAULT = 7499 // A prime number (to minimise syncing with other processes) /** The number of consecutive timeouts during startup phase before considering a NR hang */ const HEALTH_POLL_MAX_STARTUP_ERROR_COUNT = 10 @@ -92,6 +88,13 @@ class Launcher { this.memoryAuditLogged = 0 } + /** @type {Number} */ + get healthCheckInterval () { + const parsed = parseInt(this.settings?.healthCheckInterval) + const value = isNaN(parsed) ? 0 : parsed + return value > 1000 ? value : HEALTH_POLL_INTERVAL_DEFAULT + } + async loadSettings () { this.state = States.LOADING this.logBuffer.add({ level: 'system', msg: 'Loading project settings' }) @@ -416,7 +419,7 @@ class Launcher { pragma: 'no-cache', 'Cache-Control': 'max-age=0, must-revalidate, no-cache' }, - timeout: { request: HEALTH_POLL_TIMEOUT }, + timeout: { request: this.healthCheckInterval - 500 }, retry: { limit: 0 } } // Use a HEAD request to minimise data transfer @@ -488,6 +491,7 @@ class Launcher { clearInterval(this.healthPoll) } let errorCount = 0 + this.logBuffer.add({ level: 'system', msg: `Starting health check monitor (${(this.healthCheckInterval / 1000).toFixed(1)}s)` }) this.healthPoll = setInterval(() => { if (this.state === States.STARTING || this.state === States.RUNNING) { statusPoll().then(() => { @@ -513,7 +517,7 @@ class Launcher { } }) } - }, HEALTH_POLL_INTERVAL) + }, this.healthCheckInterval) if (this.resourcePoll) { clearInterval(this.resourcePoll) diff --git a/package-lock.json b/package-lock.json index f713c28..344f773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "mocha": "^10.2.0", "sass": "1.66.1", "should": "^13.2.3", + "sinon": "^17.0.1", "sqlite3": "^5.1.6", "yaml": "^2.1.3" } @@ -1285,6 +1286,50 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@smithy/abort-controller": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.0.12.tgz", @@ -5128,6 +5173,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", @@ -5199,6 +5250,12 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5774,6 +5831,25 @@ "node": ">= 0.6" } }, + "node_modules/nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "dev": true + }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -7396,6 +7472,33 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -7793,6 +7896,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/package.json b/package.json index fe3aad8..ab73a44 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "mocha": "^10.2.0", "sass": "1.66.1", "should": "^13.2.3", + "sinon" : "^17.0.1", "sqlite3": "^5.1.6", "yaml": "^2.1.3" } diff --git a/test/unit/lib/launcher_spec.js b/test/unit/lib/launcher_spec.js new file mode 100644 index 0000000..daa36bd --- /dev/null +++ b/test/unit/lib/launcher_spec.js @@ -0,0 +1,37 @@ +const should = require('should') // eslint-disable-line +const sinon = require('sinon') +const launcher = require('../../../lib/launcher.js') + +describe.only('Launcher', function () { + it('should create a new launcher', async function () { + const l = new launcher.Launcher({}) + should.exist(l) + }) + describe('health check', function () { + it('has a default value', async function () { + const l = new launcher.Launcher({}) + l.should.have.property('healthCheckInterval', 7499) + }) + it('can be set by user', async function () { + const l = new launcher.Launcher({}) + sinon.stub(l, 'loadSettings').callsFake(() => { + l.settings = { + healthCheckInterval: 1234 + } + }) + await l.loadSettings() + l.should.have.property('healthCheckInterval', 1234) + }) + it('cannot be less than 1 second', async function () { + const l = new launcher.Launcher({}) + sinon.stub(l, 'loadSettings').callsFake(() => { + l.settings = { + healthCheckInterval: 999 + } + }) + await l.loadSettings() + // returns the default value when the user sets the value out of range + l.should.have.property('healthCheckInterval', 7499) + }) + }) +})