From ed21f8dee0be974ab907d0e30e9a2e17a67352e8 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Sun, 28 Mar 2021 16:40:35 +0200 Subject: [PATCH 01/38] Link to blog post --- README.md | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93f259c..3b4efad 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ assigning random ports to avoid race conditions when there are many services running in parallel on the same machine. As is common with large scale testing. - The superdaemon will create the listen socket and pass it to the managed process as `fd3`, similar to how `systemd` + The superdaemon will create the listen socket and pass it to the managed process as `fd=3`, similar to how `systemd` handles socket activation. This also avoids any race conditions between spawning the managed process and sending the first request, since the listen socket is active the whole time. @@ -42,7 +42,8 @@ const fetch = require('node-fetch'); ``` The managed TCP server does not need to be a Node application. In fact this module was originally developed to test - [Mojolicious](https://mojolicious.org) web applications written in Perl with [Playwright](https://playwright.dev). + [Mojolicious](https://mojolicious.org) web applications written in Perl with [Playwright](https://playwright.dev). For + more details take a look at the [blog post](https://dev.to/kraih/playwright-and-mojolicious-21hn). ```js const t = require('tap'); diff --git a/package.json b/package.json index b7d222b..ca07c7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.12", + "version": "1.0.13", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", From 35cb5c8e865210677d0ab35bcb664d8810318968 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Mon, 29 Mar 2021 00:32:23 +0200 Subject: [PATCH 02/38] Use getters for pid and port --- lib/server-starter.js | 16 ++++++++++++---- package.json | 2 +- test/start.js | 13 +++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index 72c8262..38e4689 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -38,7 +38,7 @@ class Server extends EventEmitter { super(); this._originalPort = port || 0; this._originalAddress = address; - this.port = undefined; + this._port = undefined; this._fd = undefined; this._srv = undefined; this._process = undefined; @@ -102,7 +102,7 @@ class Server extends EventEmitter { const srv = net.createServer(); srv.listen(this._originalPort, this._originalAddress, () => { this._srv = srv; - this.port = srv.address().port; + this._port = srv.address().port; this._fd = srv._handle.fd; resolve(); }); @@ -111,12 +111,20 @@ class Server extends EventEmitter { /** * PID of the launched process - * @returns {number|null} + * @type {number|null} */ - pid () { + get pid () { return this._process ? this._process.pid : null; } + /** + * Port + * @type {number} + */ + get port () { + return this._port; + } + /** * URL of the launched server * @returns {string} diff --git a/package.json b/package.json index ca07c7a..b731f5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.13", + "version": "1.0.14", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", diff --git a/test/start.js b/test/start.js index 372b0c0..c08eff7 100644 --- a/test/start.js +++ b/test/start.js @@ -6,10 +6,11 @@ const starter = require('..'); t.test('Start and stop a server', async t => { const server = await starter.newServer(); - t.equal(server.pid(), null, 'not started'); + t.equal(server.pid, null, 'not started'); await server.launch('node', ['test/support/server.js']); - t.equal(typeof server.pid(), 'number', 'started'); + t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); + t.equal(typeof server.port, 'number', 'port assigned'); const res = await fetch(url); t.equal(res.ok, true, '2xx code'); @@ -18,7 +19,7 @@ t.test('Start and stop a server', async t => { t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); await server.close(); - t.equal(server.pid(), null, 'stopped'); + t.equal(server.pid, null, 'stopped'); let err; try { await fetch(url); } catch (e) { err = e; } @@ -28,9 +29,9 @@ t.test('Start and stop a server', async t => { t.test('Do it again', async t => { const server = await starter.newServer(); - t.equal(server.pid(), null, 'not started'); + t.equal(server.pid, null, 'not started'); await server.launch('node', ['test/support/server.js']); - t.equal(typeof server.pid(), 'number', 'started'); + t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); t.equal(res.ok, true, '2xx code'); @@ -39,5 +40,5 @@ t.test('Do it again', async t => { t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); await server.close(); - t.equal(server.pid(), null, 'stopped'); + t.equal(server.pid, null, 'stopped'); }); From 0d9dd39f60577a6dd354046f603a6d04b656418f Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Mon, 29 Mar 2021 11:27:42 +0200 Subject: [PATCH 03/38] Small documentation improvements --- README.md | 2 +- lib/server-starter.js | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3b4efad..04fd696 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ const server = http.createServer((req, res) => { server.listen({ fd: 3 }); ``` - All the web application has to do is use `fd3` as its listen socket to accept new connections from. + All the web application has to do is use `fd=3` as its listen socket to accept new connections from. ```js const starter = require('@mojolicious/server-starter'); diff --git a/lib/server-starter.js b/lib/server-starter.js index 38e4689..f8fcf30 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -10,7 +10,7 @@ const net = require('net'); const { spawn } = require('child_process'); /** - * Class representing a superdaemon that can manage one or more servers + * Class representing a superdaemon that handles socket activation and can manage one or more servers */ class ServerStarter { /** @@ -118,7 +118,7 @@ class Server extends EventEmitter { } /** - * Port + * Port of activated socket * @type {number} */ get port () { @@ -126,7 +126,7 @@ class Server extends EventEmitter { } /** - * URL of the launched server + * Full URL of the launched server with "http" scheme * @returns {string} */ url () { diff --git a/package.json b/package.json index b731f5d..86a867e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.14", + "version": "1.0.15", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", From c7cf6c79368bbb2df752d342b24b1da3aeaae7aa Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Mon, 29 Mar 2021 21:25:50 +0200 Subject: [PATCH 04/38] Use a little less code --- lib/server-starter.js | 14 ++++++-------- package.json | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index f8fcf30..f23b1f8 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -73,15 +73,13 @@ class Server extends EventEmitter { const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); this._srv.close(); + proc.on('error', e => this.emit('error', e)); - proc.stdout.on('data', data => { - if (stdout) process.stdout.write(data.toString('utf8')); - this.emit('stdout', data); - }); - proc.stderr.on('data', data => { - if (stderr) process.stderr.write(data.toString('utf8')); - this.emit('stderr', data); - }); + if (stdout) proc.stdout.pipe(process.stdout); + if (stderr) proc.stderr.pipe(process.stderr); + proc.stdout.on('data', data => this.emit('stdout', data)); + proc.stderr.on('data', data => this.emit('stderr', data)); + proc.on('exit', (code, signal) => { this._exit = true; this._process = undefined; diff --git a/package.json b/package.json index 86a867e..c2a2615 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.15", + "version": "1.0.16", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", From 34bc5e64462fcd276d3e93ca7d66b44aad273342 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Tue, 30 Mar 2021 00:55:20 +0200 Subject: [PATCH 05/38] The index.js file is not needed --- index.js | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 index.js diff --git a/index.js b/index.js deleted file mode 100644 index dd6a819..0000000 --- a/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * server-starter - * Copyright(c) 2021 Sebastian Riedel - * Artistic-2.0 Licensed - */ -'use strict'; - -module.exports = require('./lib/server-starter.js'); From 5c589fdd223eecfeabf67083def1d13cd09e1dc7 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Tue, 30 Mar 2021 00:55:49 +0200 Subject: [PATCH 06/38] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2a2615..cb877c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.16", + "version": "1.0.17", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", From fbd807655466995ba3683ec58cb6fa19550ba727 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Tue, 30 Mar 2021 02:27:10 +0200 Subject: [PATCH 07/38] newServer returns a Promise --- lib/server-starter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index f23b1f8..2c59a7f 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -17,7 +17,7 @@ class ServerStarter { * Create a server * @param {number} [port=0] - Optional port to listen on * @param {string} [address] - Optional address to listen on - * @returns {Server} + * @returns {Promise} */ newServer (port, address) { const server = new Server(port, address); From 5b75dcd8f115f24e5a80b5f2f37304f0dc67cd08 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Wed, 31 Mar 2021 09:52:36 -0300 Subject: [PATCH 08/38] wait for tcp port available --- lib/server-starter.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index 72c8262..1ba6ced 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -64,12 +64,14 @@ class Server extends EventEmitter { * @param {object} [options] - Optional settings * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR + * @param {number} [options.win32Timeout=30000] - (Windows only) Max time to wait for server ready, in mS * @returns {Promise} */ launch (cmd, args, options = {}) { if (typeof this._process !== 'undefined') throw new Error('Server already launched'); const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; + const win32Timeout = typeof options.win32Timeout !== 'undefined' ? options.win32Timeout : 30000; const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); this._srv.close(); @@ -90,7 +92,23 @@ class Server extends EventEmitter { }); // Should be resolved by the "spawn" event, but that is only supported in Node 15+ - return Promise.resolve; + if (process.platform !== 'win32') return Promise.resolve; + return new Promise((resolve) => { + const retryTime = 60; + const now = new Date(); + const timeToStop = new Date(now.getTime() + win32Timeout); + const port = this.port; + (function loop () { + const connection = net.connect(port, resolve); + connection.on('error', err => { + if (err.code === 'ECONNREFUSED') { + if (new Date() < timeToStop) { + setTimeout(loop, retryTime); + } + } + }); + })(); + }); } /** From d1bf330ad0514e1ac08e2399a64b414974428f38 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Wed, 31 Mar 2021 16:51:58 +0200 Subject: [PATCH 09/38] Enable mergify --- .mergify/config.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .mergify/config.yml diff --git a/.mergify/config.yml b/.mergify/config.yml new file mode 100644 index 0000000..f5d43ab --- /dev/null +++ b/.mergify/config.yml @@ -0,0 +1,20 @@ +pull_request_rules: + - name: automatic merge + conditions: + - "#approved-reviews-by>=2" + - "#changes-requested-reviews-by=0" + - base=master + actions: + merge: + method: merge + - name: remove outdated reviews + conditions: + - base=master + actions: + dismiss_reviews: {} + - name: ask to resolve conflict + conditions: + - conflict + actions: + comment: + message: This pull request is now in conflicts. Could you fix it @{{author}}? 🙏 From 5cf785dffa73114cccf87433fa77062d476accba Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Wed, 31 Mar 2021 19:29:57 +0200 Subject: [PATCH 10/38] Make room for port based tests --- test/{start.js => start_fd.js} | 4 ++-- test/support/{server.js => server_fd.js} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename test/{start.js => start_fd.js} (91%) rename test/support/{server.js => server_fd.js} (100%) diff --git a/test/start.js b/test/start_fd.js similarity index 91% rename from test/start.js rename to test/start_fd.js index c08eff7..7dc01bc 100644 --- a/test/start.js +++ b/test/start_fd.js @@ -7,7 +7,7 @@ const starter = require('..'); t.test('Start and stop a server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js']); + await server.launch('node', ['test/support/server_fd.js']); t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); t.equal(typeof server.port, 'number', 'port assigned'); @@ -30,7 +30,7 @@ t.test('Start and stop a server', async t => { t.test('Do it again', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js']); + await server.launch('node', ['test/support/server_fd.js']); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); diff --git a/test/support/server.js b/test/support/server_fd.js similarity index 100% rename from test/support/server.js rename to test/support/server_fd.js From a58db7399f939480e9d010aa46fc097a55fe20f1 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Wed, 31 Mar 2021 22:59:11 +0200 Subject: [PATCH 11/38] Test port assignment --- test/start_fd.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/start_fd.js b/test/start_fd.js index 7dc01bc..d00c4d1 100644 --- a/test/start_fd.js +++ b/test/start_fd.js @@ -2,6 +2,7 @@ const t = require('tap'); const fetch = require('node-fetch'); +const net = require('net'); const starter = require('..'); t.test('Start and stop a server', async t => { @@ -42,3 +43,32 @@ t.test('Do it again', async t => { await server.close(); t.equal(server.pid, null, 'stopped'); }); + +t.test('Use a specific port', async t => { + const port = await getPort(); + const server = await starter.newServer(port); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server_fd.js']); + t.equal(typeof server.pid, 'number', 'started'); + t.equal(server.port, port, 'right port'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + +function getPort () { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, () => { + resolve(srv.address().port); + srv.close(); + }); + }); +} From 425fcde3f021c411ddf8a7af90b600057a62a680 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Thu, 1 Apr 2021 02:36:45 +0200 Subject: [PATCH 12/38] Give the test a little more time to close the port --- test/start_fd.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/start_fd.js b/test/start_fd.js index d00c4d1..ead29fb 100644 --- a/test/start_fd.js +++ b/test/start_fd.js @@ -67,8 +67,9 @@ function getPort () { const srv = net.createServer(); srv.on('error', reject); srv.listen(0, () => { - resolve(srv.address().port); + const port = srv.address().port; srv.close(); + resolve(port); }); }); } From 7865e33d95deaed7d75c8c0d18f922d6df8a7a87 Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Thu, 1 Apr 2021 11:08:33 +0200 Subject: [PATCH 13/38] Wait for the first listen socket to be closed --- lib/server-starter.js | 4 +--- package.json | 2 +- test/start_fd.js | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index 2c59a7f..dfe5048 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -72,7 +72,6 @@ class Server extends EventEmitter { const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); - this._srv.close(); proc.on('error', e => this.emit('error', e)); if (stdout) proc.stdout.pipe(process.stdout); @@ -87,8 +86,7 @@ class Server extends EventEmitter { this.emit('exit', code, signal); }); - // Should be resolved by the "spawn" event, but that is only supported in Node 15+ - return Promise.resolve; + return new Promise(resolve => this._srv.close(resolve)); } /** diff --git a/package.json b/package.json index cb877c9..9ba21e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.17", + "version": "1.0.18", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", diff --git a/test/start_fd.js b/test/start_fd.js index ead29fb..d00c4d1 100644 --- a/test/start_fd.js +++ b/test/start_fd.js @@ -67,9 +67,8 @@ function getPort () { const srv = net.createServer(); srv.on('error', reject); srv.listen(0, () => { - const port = srv.address().port; + resolve(srv.address().port); srv.close(); - resolve(port); }); }); } From cccc7ed95cc7adc0fa53d714d06acbe8d78866f1 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sat, 3 Apr 2021 12:45:31 -0300 Subject: [PATCH 14/38] add listenAddress attribute --- lib/server-starter.js | 30 ++++++----- test/start.js | 100 +++++++++++++++++++++++++++++++++--- test/support/slow_server.js | 14 +++++ 3 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 test/support/slow_server.js diff --git a/lib/server-starter.js b/lib/server-starter.js index 1ba6ced..d0477f3 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -44,6 +44,7 @@ class Server extends EventEmitter { this._process = undefined; this._exitHandlers = []; this._exit = undefined; + this.listenAddress = undefined; } /** @@ -64,17 +65,22 @@ class Server extends EventEmitter { * @param {object} [options] - Optional settings * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR - * @param {number} [options.win32Timeout=30000] - (Windows only) Max time to wait for server ready, in mS + * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS + * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS + * @param {boolean} [options.fdPassingAllowed=true] - Allow File Descriptor 3 listening (when possible) * @returns {Promise} */ launch (cmd, args, options = {}) { if (typeof this._process !== 'undefined') throw new Error('Server already launched'); const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; - const win32Timeout = typeof options.win32Timeout !== 'undefined' ? options.win32Timeout : 30000; - - const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); - this._srv.close(); + const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; + const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; + const fdPassingAllowed = typeof options.fdPassingAllowed !== 'undefined' ? options.fdPassingAllowed : true; + const useFileDescriptor = process.platform !== 'win32' && fdPassingAllowed; + const spawnOptions = useFileDescriptor ? { stdio: ['pipe', 'pipe', 'pipe', this._fd] } : undefined; + this.listenAddress = useFileDescriptor ? 'http://*?fd=3' : this.url(); + const proc = (this._process = spawn(cmd, args, spawnOptions)); proc.on('error', e => this.emit('error', e)); proc.stdout.on('data', data => { if (stdout) process.stdout.write(data.toString('utf8')); @@ -92,20 +98,16 @@ class Server extends EventEmitter { }); // Should be resolved by the "spawn" event, but that is only supported in Node 15+ - if (process.platform !== 'win32') return Promise.resolve; - return new Promise((resolve) => { - const retryTime = 60; + if (useFileDescriptor) return this._srv.close; + return this._srv.close().then((resolve) => { const now = new Date(); - const timeToStop = new Date(now.getTime() + win32Timeout); + const timeToStop = new Date(now.getTime() + connectTimeout); const port = this.port; (function loop () { const connection = net.connect(port, resolve); connection.on('error', err => { - if (err.code === 'ECONNREFUSED') { - if (new Date() < timeToStop) { - setTimeout(loop, retryTime); - } - } + if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); + else resolve(); // this is intented: don't reject, just stop delaying }); })(); }); diff --git a/test/start.js b/test/start.js index 5d5e1b8..02426bd 100644 --- a/test/start.js +++ b/test/start.js @@ -4,11 +4,11 @@ const t = require('tap'); const fetch = require('node-fetch'); const starter = require('..'); -t.test('Start and stop a server', async t => { +t.test('Start and stop a server, no fd passing allowed', async t => { const server = await starter.newServer(); t.equal(server.pid(), null, 'not started'); - if (process.platform === 'win32') process.env.TEST_SERVER_STARTER_PORT = server.port; - await server.launch('node', ['test/support/server.js']); + process.env.TEST_SERVER_STARTER_PORT = server.port; + await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: false }); t.equal(typeof server.pid(), 'number', 'started'); const url = server.url(); @@ -27,14 +27,15 @@ t.test('Start and stop a server', async t => { t.equal(err.errno, 'ECONNREFUSED', 'right error'); }); -t.test('Do it again', async t => { +t.test('Start and stop a server, using fd passing when available', async t => { const server = await starter.newServer(); t.equal(server.pid(), null, 'not started'); - if (process.platform === 'win32') process.env.TEST_SERVER_STARTER_PORT = server.port; - await server.launch('node', ['test/support/server.js']); + process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; + await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: true }); t.equal(typeof server.pid(), 'number', 'started'); + const url = server.url(); - const res = await fetch(server.url()); + const res = await fetch(url); t.equal(res.ok, true, '2xx code'); t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); const buffer = await res.buffer(); @@ -42,4 +43,89 @@ t.test('Do it again', async t => { await server.close(); t.equal(server.pid(), null, 'stopped'); + + let err; + try { await fetch(url); } catch (e) { err = e; } + t.ok(err, 'request failed'); + t.equal(err.errno, 'ECONNREFUSED', 'right error'); +}); + + + +t.test('Slow server, no fd passing allowed', async t => { + const server = await starter.newServer(); + process.env.TEST_SERVER_STARTER_PORT = server.port; + await server.launch('node', ['test/support/slow_server.js', 1000], { fdPassingAllowed: false }); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code received from slow server'); + + await server.close(); + t.equal(server.pid(), null, 'slow server stopped'); +}); + +t.test('Slow server, using fd passing when available', async t => { + const server = await starter.newServer(); + process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; + await server.launch('node', ['test/support/slow_server.js', 1000], { fdPassingAllowed: false }); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code received from slow server'); + + await server.close(); + t.equal(server.pid(), null, 'slow server stopped'); +}); + +t.test('Unresponsive server (too slow), no fd passing allowed', async t => { + const server = await starter.newServer(); + process.env.TEST_SERVER_STARTER_PORT = server.port; + await server.launch('node', ['test/support/slow_server.js', 1500], + { fdPassingAllowed: false, connectTimeout: 1000 }) + .catch((err) => { + t.equal(err.code, 'ECONNREFUSED', 'right error'); + }); + await server.close(); + t.equal(server.pid(), null, 'slow server stopped'); +}); + +t.test('Unresponsive server (too slow), using fd passing when available', async t => { + const server = await starter.newServer(); + process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; + await server.launch('node', ['test/support/slow_server.js', 1500], + { fdPassingAllowed: false, connectTimeout: 1000 }) + .catch((err) => { + t.equal(err.code, 'ECONNREFUSED', 'right error'); + }); + await server.close(); + t.equal(server.pid(), null, 'slow server stopped'); +}); + +t.test('Fixed port not available / available cases, without allowing fd passing', async t => { + const dummyServer = await starter.newServer(); + const port = dummyServer.port; + t.equal(dummyServer.pid(), null, 'dummy server process not used'); + let server = await starter.newServer(); + process.env.TEST_SERVER_STARTER_PORT = port; // port is the wrong (not available) port + try { + await server.launch('node', ['test/support/server.js'], + { fdPassingAllowed: false, connectTimeout: 100, stderr: false }); + } catch (err) { + t.equal(err.code, 'ECONNREFUSED', 'right error, server cannot listen'); + } + t.equal(server.pid(), null, 'server with port already in use did never start'); + + await dummyServer._srv.close(); + // now port can be used. (OSs avoid reusing recently liberated ports for a long while) + server = await starter.newServer(port); + await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: false }); + t.equal(typeof server.pid(), 'number', 'server with fixed port started'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid(), null, 'server with fixed port stopped'); }); diff --git a/test/support/slow_server.js b/test/support/slow_server.js new file mode 100644 index 0000000..ff91d34 --- /dev/null +++ b/test/support/slow_server.js @@ -0,0 +1,14 @@ +'use strict'; + +const http = require('http'); +const delay = process.argv[2] ? process.argv[2] : 1000; + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World!'); +}); +(() => new Promise(resolve => { + setTimeout( + () => resolve(server.listen(process.env.TEST_SERVER_STARTER_PORT ? process.env.TEST_SERVER_STARTER_PORT : { fd: 3 })), + delay) +}))(); \ No newline at end of file From 9aef07f9fa1d2878367af80d92084bd19957da1b Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 09:37:12 -0300 Subject: [PATCH 15/38] avoid fdpass through env var --- .github/workflows/test.yml | 5 +- .gitignore | 2 +- .mergify/config.yml | 20 ++++++ README.md | 5 +- index.js | 8 --- lib/server-starter.js | 83 ++++++++++++++----------- package.json | 2 +- test/start.js | 121 ++++++++++-------------------------- test/support/server.js | 16 ++++- test/support/slow_server.js | 14 ----- 10 files changed, 123 insertions(+), 153 deletions(-) create mode 100644 .mergify/config.yml delete mode 100644 index.js delete mode 100644 test/support/slow_server.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ab7560..9d74175 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,14 @@ name: test on: [push, pull_request] jobs: test: - name: Node ${{ matrix.node-version }} and ${{ matrix.os }} + name: Node ${{ matrix.node-version }}, ${{ matrix.os }}, and avoid fdpass ${{ matrix.avoid-fdpass }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: node-version: [10.x, 12.x, 14.x, 15.x] os: [ubuntu-latest, macos-latest, windows-latest] + avoid-fdpass: [0,1] steps: - uses: actions/checkout@v1 - name: Use Node ${{ matrix.node-version }} @@ -19,5 +20,7 @@ jobs: run: npm i - name: npm test run: npm test + env: + MOJO_SERVER_STARTER_AVOID_FDPASS: ${{ matrix.avoid-fdpass }} - name: npm run lint run: npm run lint diff --git a/.gitignore b/.gitignore index 099ae14..dbc94a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ node_modules package-lock.json - +.vscode diff --git a/.mergify/config.yml b/.mergify/config.yml new file mode 100644 index 0000000..f5d43ab --- /dev/null +++ b/.mergify/config.yml @@ -0,0 +1,20 @@ +pull_request_rules: + - name: automatic merge + conditions: + - "#approved-reviews-by>=2" + - "#changes-requested-reviews-by=0" + - base=master + actions: + merge: + method: merge + - name: remove outdated reviews + conditions: + - base=master + actions: + dismiss_reviews: {} + - name: ask to resolve conflict + conditions: + - conflict + actions: + comment: + message: This pull request is now in conflicts. Could you fix it @{{author}}? 🙏 diff --git a/README.md b/README.md index f524740..04fd696 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ const server = http.createServer((req, res) => { server.listen({ fd: 3 }); ``` - All the web application has to do is use `fd3` as its listen socket to accept new connections from. + All the web application has to do is use `fd=3` as its listen socket to accept new connections from. ```js const starter = require('@mojolicious/server-starter'); @@ -42,7 +42,8 @@ const fetch = require('node-fetch'); ``` The managed TCP server does not need to be a Node application. In fact this module was originally developed to test - [Mojolicious](https://mojolicious.org) web applications written in Perl with [Playwright](https://playwright.dev). + [Mojolicious](https://mojolicious.org) web applications written in Perl with [Playwright](https://playwright.dev). For + more details take a look at the [blog post](https://dev.to/kraih/playwright-and-mojolicious-21hn). ```js const t = require('tap'); diff --git a/index.js b/index.js deleted file mode 100644 index dd6a819..0000000 --- a/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * server-starter - * Copyright(c) 2021 Sebastian Riedel - * Artistic-2.0 Licensed - */ -'use strict'; - -module.exports = require('./lib/server-starter.js'); diff --git a/lib/server-starter.js b/lib/server-starter.js index d0477f3..b5b9c46 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -10,14 +10,14 @@ const net = require('net'); const { spawn } = require('child_process'); /** - * Class representing a superdaemon that can manage one or more servers + * Class representing a superdaemon that handles socket activation and can manage one or more servers */ class ServerStarter { /** * Create a server * @param {number} [port=0] - Optional port to listen on * @param {string} [address] - Optional address to listen on - * @returns {Server} + * @returns {Promise} */ newServer (port, address) { const server = new Server(port, address); @@ -38,13 +38,13 @@ class Server extends EventEmitter { super(); this._originalPort = port || 0; this._originalAddress = address; - this.port = undefined; + this._port = undefined; this._fd = undefined; this._srv = undefined; this._process = undefined; this._exitHandlers = []; this._exit = undefined; - this.listenAddress = undefined; + this._useFdPass = process.env.MOJO_SERVER_STARTER_AVOID_FDPASS ? false : process.platform !== 'win32' ; } /** @@ -67,7 +67,6 @@ class Server extends EventEmitter { * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS - * @param {boolean} [options.fdPassingAllowed=true] - Allow File Descriptor 3 listening (when possible) * @returns {Promise} */ launch (cmd, args, options = {}) { @@ -76,20 +75,14 @@ class Server extends EventEmitter { const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; - const fdPassingAllowed = typeof options.fdPassingAllowed !== 'undefined' ? options.fdPassingAllowed : true; - const useFileDescriptor = process.platform !== 'win32' && fdPassingAllowed; - const spawnOptions = useFileDescriptor ? { stdio: ['pipe', 'pipe', 'pipe', this._fd] } : undefined; - this.listenAddress = useFileDescriptor ? 'http://*?fd=3' : this.url(); + const spawnOptions = this._useFdPass ? { stdio: ['pipe', 'pipe', 'pipe', this._fd] } : undefined; const proc = (this._process = spawn(cmd, args, spawnOptions)); proc.on('error', e => this.emit('error', e)); - proc.stdout.on('data', data => { - if (stdout) process.stdout.write(data.toString('utf8')); - this.emit('stdout', data); - }); - proc.stderr.on('data', data => { - if (stderr) process.stderr.write(data.toString('utf8')); - this.emit('stderr', data); - }); + if (stdout) proc.stdout.pipe(process.stdout); + if (stderr) proc.stderr.pipe(process.stderr); + proc.stdout.on('data', data => this.emit('stdout', data)); + proc.stderr.on('data', data => this.emit('stderr', data)); + proc.on('exit', (code, signal) => { this._exit = true; this._process = undefined; @@ -97,20 +90,23 @@ class Server extends EventEmitter { this.emit('exit', code, signal); }); - // Should be resolved by the "spawn" event, but that is only supported in Node 15+ - if (useFileDescriptor) return this._srv.close; - return this._srv.close().then((resolve) => { - const now = new Date(); - const timeToStop = new Date(now.getTime() + connectTimeout); - const port = this.port; - (function loop () { - const connection = net.connect(port, resolve); - connection.on('error', err => { - if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); - else resolve(); // this is intented: don't reject, just stop delaying - }); - })(); - }); + return new Promise(resolve => this._srv.close( + () => { + if (this._useFdPass) resolve(); + else { + const now = new Date(); + const timeToStop = new Date(now.getTime() + connectTimeout); + const port = this.port; + (function loop () { + const connection = net.connect(port, resolve); + connection.on('error', err => { + if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); + else resolve(); // this is intented: don't reject, just stop delaying + }); + })(); + } + } + )); } /** @@ -122,7 +118,7 @@ class Server extends EventEmitter { const srv = net.createServer(); srv.listen(this._originalPort, this._originalAddress, () => { this._srv = srv; - this.port = srv.address().port; + this._port = srv.address().port; this._fd = srv._handle.fd; resolve(); }); @@ -131,14 +127,22 @@ class Server extends EventEmitter { /** * PID of the launched process - * @returns {number|null} + * @type {number|null} */ - pid () { + get pid () { return this._process ? this._process.pid : null; } /** - * URL of the launched server + * Port of activated socket + * @type {number} + */ + get port () { + return this._port; + } + + /** + * Full URL of the launched server with "http" scheme * @returns {string} */ url () { @@ -146,6 +150,15 @@ class Server extends EventEmitter { const port = this.port; return `http://${address}:${port}`; } + + /** + * Listen Address to configure service to be launched + * @returns {string} + */ + listenAddress () { + if (this._useFdPass) return 'http://*?fd=3'; + return this.url(); + } } exports = module.exports = new ServerStarter(); diff --git a/package.json b/package.json index e7fe2d7..dd1496d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojolicious/server-starter", - "version": "1.0.12", + "version": "1.0.18", "description": "UNIX superdaemon with support for socket activation", "keywords": [ "superdaemon", diff --git a/test/start.js b/test/start.js index 02426bd..c65d08d 100644 --- a/test/start.js +++ b/test/start.js @@ -2,15 +2,16 @@ const t = require('tap'); const fetch = require('node-fetch'); +const net = require('net'); const starter = require('..'); -t.test('Start and stop a server, no fd passing allowed', async t => { +t.test('Start and stop a server', async t => { const server = await starter.newServer(); - t.equal(server.pid(), null, 'not started'); - process.env.TEST_SERVER_STARTER_PORT = server.port; - await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: false }); - t.equal(typeof server.pid(), 'number', 'started'); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); + t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); + t.equal(typeof server.port, 'number', 'port assigned'); const res = await fetch(url); t.equal(res.ok, true, '2xx code'); @@ -19,7 +20,7 @@ t.test('Start and stop a server, no fd passing allowed', async t => { t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); await server.close(); - t.equal(server.pid(), null, 'stopped'); + t.equal(server.pid, null, 'stopped'); let err; try { await fetch(url); } catch (e) { err = e; } @@ -27,98 +28,29 @@ t.test('Start and stop a server, no fd passing allowed', async t => { t.equal(err.errno, 'ECONNREFUSED', 'right error'); }); -t.test('Start and stop a server, using fd passing when available', async t => { +t.test('Do it again', async t => { const server = await starter.newServer(); - t.equal(server.pid(), null, 'not started'); - process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; - await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: true }); - t.equal(typeof server.pid(), 'number', 'started'); - const url = server.url(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); + t.equal(typeof server.pid, 'number', 'started'); - const res = await fetch(url); + const res = await fetch(server.url()); t.equal(res.ok, true, '2xx code'); t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); const buffer = await res.buffer(); t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); await server.close(); - t.equal(server.pid(), null, 'stopped'); - - let err; - try { await fetch(url); } catch (e) { err = e; } - t.ok(err, 'request failed'); - t.equal(err.errno, 'ECONNREFUSED', 'right error'); + t.equal(server.pid, null, 'stopped'); }); - - -t.test('Slow server, no fd passing allowed', async t => { - const server = await starter.newServer(); - process.env.TEST_SERVER_STARTER_PORT = server.port; - await server.launch('node', ['test/support/slow_server.js', 1000], { fdPassingAllowed: false }); - - const res = await fetch(server.url()); - t.equal(res.ok, true, '2xx code received from slow server'); - - await server.close(); - t.equal(server.pid(), null, 'slow server stopped'); -}); - -t.test('Slow server, using fd passing when available', async t => { - const server = await starter.newServer(); - process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; - await server.launch('node', ['test/support/slow_server.js', 1000], { fdPassingAllowed: false }); - - const res = await fetch(server.url()); - t.equal(res.ok, true, '2xx code received from slow server'); - - await server.close(); - t.equal(server.pid(), null, 'slow server stopped'); -}); - -t.test('Unresponsive server (too slow), no fd passing allowed', async t => { - const server = await starter.newServer(); - process.env.TEST_SERVER_STARTER_PORT = server.port; - await server.launch('node', ['test/support/slow_server.js', 1500], - { fdPassingAllowed: false, connectTimeout: 1000 }) - .catch((err) => { - t.equal(err.code, 'ECONNREFUSED', 'right error'); - }); - await server.close(); - t.equal(server.pid(), null, 'slow server stopped'); -}); - -t.test('Unresponsive server (too slow), using fd passing when available', async t => { - const server = await starter.newServer(); - process.env.TEST_SERVER_STARTER_PORT = process.platform === 'win32' ? server.port : undefined; - await server.launch('node', ['test/support/slow_server.js', 1500], - { fdPassingAllowed: false, connectTimeout: 1000 }) - .catch((err) => { - t.equal(err.code, 'ECONNREFUSED', 'right error'); - }); - await server.close(); - t.equal(server.pid(), null, 'slow server stopped'); -}); - -t.test('Fixed port not available / available cases, without allowing fd passing', async t => { - const dummyServer = await starter.newServer(); - const port = dummyServer.port; - t.equal(dummyServer.pid(), null, 'dummy server process not used'); - let server = await starter.newServer(); - process.env.TEST_SERVER_STARTER_PORT = port; // port is the wrong (not available) port - try { - await server.launch('node', ['test/support/server.js'], - { fdPassingAllowed: false, connectTimeout: 100, stderr: false }); - } catch (err) { - t.equal(err.code, 'ECONNREFUSED', 'right error, server cannot listen'); - } - t.equal(server.pid(), null, 'server with port already in use did never start'); - - await dummyServer._srv.close(); - // now port can be used. (OSs avoid reusing recently liberated ports for a long while) - server = await starter.newServer(port); - await server.launch('node', ['test/support/server.js'], { fdPassingAllowed: false }); - t.equal(typeof server.pid(), 'number', 'server with fixed port started'); +t.test('Use a specific port', async t => { + const port = await getPort(); + const server = await starter.newServer(port); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); + t.equal(typeof server.pid, 'number', 'started'); + t.equal(server.port, port, 'right port'); const res = await fetch(server.url()); t.equal(res.ok, true, '2xx code'); @@ -127,5 +59,16 @@ t.test('Fixed port not available / available cases, without allowing fd passing' t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); await server.close(); - t.equal(server.pid(), null, 'server with fixed port stopped'); + t.equal(server.pid, null, 'stopped'); }); + +function getPort () { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, () => { + resolve(srv.address().port); + srv.close(); + }); + }); +} diff --git a/test/support/server.js b/test/support/server.js index 552ce9a..7a243e2 100644 --- a/test/support/server.js +++ b/test/support/server.js @@ -1,9 +1,21 @@ 'use strict'; -const http = require('http'); +// Usage: node server-starter.js +// is mandatory +const http = require('http'); +const delay = process.argv[3] ? process.argv[3] : 0; +let listen = {fd: 3}; +let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); +if (parts) listen = {port: parts[2], address: parts[1]}; const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World!'); }); -server.listen(process.env.TEST_SERVER_STARTER_PORT ? process.env.TEST_SERVER_STARTER_PORT : { fd: 3 }); + +// delayed start listening +(() => new Promise(resolve => { + setTimeout( + () => resolve(server.listen(listen)), + delay) +}))(); diff --git a/test/support/slow_server.js b/test/support/slow_server.js deleted file mode 100644 index ff91d34..0000000 --- a/test/support/slow_server.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -const http = require('http'); -const delay = process.argv[2] ? process.argv[2] : 1000; - -const server = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Hello World!'); -}); -(() => new Promise(resolve => { - setTimeout( - () => resolve(server.listen(process.env.TEST_SERVER_STARTER_PORT ? process.env.TEST_SERVER_STARTER_PORT : { fd: 3 })), - delay) -}))(); \ No newline at end of file From bc67f2ddcf6a8d0228e34b8a4424999495d029a6 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 09:40:58 -0300 Subject: [PATCH 16/38] fix lint errors --- lib/server-starter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/server-starter.js b/lib/server-starter.js index b5b9c46..c2047ce 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -44,7 +44,7 @@ class Server extends EventEmitter { this._process = undefined; this._exitHandlers = []; this._exit = undefined; - this._useFdPass = process.env.MOJO_SERVER_STARTER_AVOID_FDPASS ? false : process.platform !== 'win32' ; + this._useFdPass = process.env.MOJO_SERVER_STARTER_AVOID_FDPASS ? false : process.platform !== 'win32'; } /** @@ -106,7 +106,7 @@ class Server extends EventEmitter { })(); } } - )); + )); } /** @@ -155,7 +155,7 @@ class Server extends EventEmitter { * Listen Address to configure service to be launched * @returns {string} */ - listenAddress () { + listenAddress () { if (this._useFdPass) return 'http://*?fd=3'; return this.url(); } From 1f0f1afba46ee1f5b2b27ff4550c2dd8fd5732ae Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 10:23:39 -0300 Subject: [PATCH 17/38] add slow server tests --- test/start.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/start.js b/test/start.js index c65d08d..dd327a4 100644 --- a/test/start.js +++ b/test/start.js @@ -44,6 +44,37 @@ t.test('Do it again', async t => { t.equal(server.pid, null, 'stopped'); }); +t.test('Slow server', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress(), 1000]); + t.equal(typeof server.pid, 'number', 'started'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + +t.test('Slow server, with wrong (too small) timeout', {skip: !process.env.MOJO_SERVER_STARTER_AVOID_FDPASS && process.platform !== 'win32'}, async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress(), 1000], {connectTimeout: 500}); + t.equal(typeof server.pid, 'number', 'started'); + + let err; + try { await fetch(server.url()); } catch (e) { err = e; } + t.ok(err, 'request failed'); + t.equal(err.errno, 'ECONNREFUSED', 'right error'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + t.test('Use a specific port', async t => { const port = await getPort(); const server = await starter.newServer(port); From 3ef6fc56a0f268c1e08f690f7dd7f2be1af8860b Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 10:31:11 -0300 Subject: [PATCH 18/38] fix lint again --- test/start.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/start.js b/test/start.js index dd327a4..e67c7c3 100644 --- a/test/start.js +++ b/test/start.js @@ -60,10 +60,10 @@ t.test('Slow server', async t => { t.equal(server.pid, null, 'stopped'); }); -t.test('Slow server, with wrong (too small) timeout', {skip: !process.env.MOJO_SERVER_STARTER_AVOID_FDPASS && process.platform !== 'win32'}, async t => { +t.test('Slow server, with wrong (too small) timeout', { skip: !process.env.MOJO_SERVER_STARTER_AVOID_FDPASS && process.platform !== 'win32' }, async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress(), 1000], {connectTimeout: 500}); + await server.launch('node', ['test/support/server.js', server.listenAddress(), 3000], { connectTimeout: 500 }); t.equal(typeof server.pid, 'number', 'started'); let err; From 3493b4b862757774078cddaf51d6cfcc792ffb3e Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 12:25:07 -0300 Subject: [PATCH 19/38] modify README for non fd passing cases --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 04fd696..eeb7bf6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # server-starter [![](https://github.com/mojolicious/server-starter/workflows/test/badge.svg)](https://github.com/mojolicious/server-starter/actions) - UNIX superdaemon with support for socket activation. + UNIX, MacOS and Windows platforms superdaemon with support for socket activation. ## Description - This module exists to handle socket activation for TCP servers running in separate processes on UNIX. It is capable of + This module exists to handle socket activation for TCP servers running in separate processes on different platforms. It is capable of assigning random ports to avoid race conditions when there are many services running in parallel on the same machine. As is common with large scale testing. - The superdaemon will create the listen socket and pass it to the managed process as `fd=3`, similar to how `systemd` + On UNIX / MacOS platforms the superdaemon will create the listen socket and pass it to the managed process as `fd=3`, similar to how `systemd` handles socket activation. This also avoids any race conditions between spawning the managed process and sending the first request, since the listen socket is active the whole time. + For Windows platforms, read also ```Launching servers / platforms without file descriptor pass support``` below. + ```js const http = require('http'); @@ -77,6 +79,85 @@ t.test('Test the WebSocket chat', async t => { await server.close(); }); ``` +## Launching servers / platforms without file descriptor pass support. + + This module can be used with standard listening address, for platforms not supporting + file description passing (like windows), or servers that can't reuse sockets passed + as file descriptors. + +- Portable listening address + + You can build a portable listening address using the ```listenAddress()``` function on ```server``` object. That function will return an absolute url that you can use to configure your server in a portable way. + + It will either be the string ```http://*?fd=3``` if file description pass is + allowed, or have a format ```http://
:``` that you can use as a listening address or parse it to get the parameters needed by your server (address and port). + + - on your test script: +```js +... + const server = await starter.newServer(); + await server.launch('node', ['your/path/server.js', server.listenAdress]); +... +``` + + - then on your server (```your/path/server.js``` file) you will get the listenAdress as a command parameter: +```js +// called as node server.js + +const http = require('http'); +let listen = {fd: 3}; +let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); +if (parts) listen = {port: parts[2], address: parts[1]}; +// at this point variable listen will either be {fd: 3} or {port: , address:
}, +// dependint on the first command argument (process.argv[2]) + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World!'); +}); +server.listen(listen); +``` +Note that depending on the server application required format, listenAddress() string could be exactly all you need, like in the case of a Mojolicious app, to load it in a portable way you just use the returned string as the '-l' command argument: + +```js +... + const server = await starter.newServer(); + await server.launch('perl', ['your/path/app.pl', '-l', server.listenAdress]); +... +``` + +- Avoid usage of file description passing of listening socket + +You can use the ENV variable ```MOJO_SERVER_STARTER_AVOID_FDPASS```: + +```shell +export MOJO_SERVER_STARTER_AVOID_FDPASS=1 +``` + +Default value is 0, and will use fd passing whenever is possible (i.e. except for windows platforms) + +- Configurable timeout + +When not using fd passing, there is a timeout to wait for the server to start listening. You configure it as option ```connectTimeout```, in mS, when calling the launch() function: + +```js + const server = await starter.newServer(); + await server.launch(, , {connectTimeout: 3000}); +``` + +Default value is 30000 (30 secs). +This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) + +- Configurable retry time + +When not using fd passing, the launch() function will check if the server is listening every mS. You can configure it as an option: + +```js + const server = await starter.newServer(); + await server.launch(, , {retryTime: 250}); +``` +Default value is 60 (60 mS). +This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) ## Install From e99fd6e3fc5065f35763949992d5123c849ff9c6 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 12:49:28 -0300 Subject: [PATCH 20/38] add minimal windows support --- .github/workflows/test.yml | 7 ++- .gitignore | 2 +- README.md | 87 ++++++++++++++++++++++++++++++++-- lib/server-starter.js | 37 +++++++++++++-- package.json | 5 +- test/{start_fd.js => start.js} | 37 +++++++++++++-- test/support/server.js | 21 ++++++++ test/support/server_fd.js | 9 ---- 8 files changed, 179 insertions(+), 26 deletions(-) rename test/{start_fd.js => start.js} (60%) create mode 100644 test/support/server.js delete mode 100644 test/support/server_fd.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0bcea7..9d74175 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,14 @@ name: test on: [push, pull_request] jobs: test: - name: Node ${{ matrix.node-version }} and ${{ matrix.os }} + name: Node ${{ matrix.node-version }}, ${{ matrix.os }}, and avoid fdpass ${{ matrix.avoid-fdpass }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: node-version: [10.x, 12.x, 14.x, 15.x] - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] + avoid-fdpass: [0,1] steps: - uses: actions/checkout@v1 - name: Use Node ${{ matrix.node-version }} @@ -19,5 +20,7 @@ jobs: run: npm i - name: npm test run: npm test + env: + MOJO_SERVER_STARTER_AVOID_FDPASS: ${{ matrix.avoid-fdpass }} - name: npm run lint run: npm run lint diff --git a/.gitignore b/.gitignore index 099ae14..dbc94a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ node_modules package-lock.json - +.vscode diff --git a/README.md b/README.md index 04fd696..eeb7bf6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # server-starter [![](https://github.com/mojolicious/server-starter/workflows/test/badge.svg)](https://github.com/mojolicious/server-starter/actions) - UNIX superdaemon with support for socket activation. + UNIX, MacOS and Windows platforms superdaemon with support for socket activation. ## Description - This module exists to handle socket activation for TCP servers running in separate processes on UNIX. It is capable of + This module exists to handle socket activation for TCP servers running in separate processes on different platforms. It is capable of assigning random ports to avoid race conditions when there are many services running in parallel on the same machine. As is common with large scale testing. - The superdaemon will create the listen socket and pass it to the managed process as `fd=3`, similar to how `systemd` + On UNIX / MacOS platforms the superdaemon will create the listen socket and pass it to the managed process as `fd=3`, similar to how `systemd` handles socket activation. This also avoids any race conditions between spawning the managed process and sending the first request, since the listen socket is active the whole time. + For Windows platforms, read also ```Launching servers / platforms without file descriptor pass support``` below. + ```js const http = require('http'); @@ -77,6 +79,85 @@ t.test('Test the WebSocket chat', async t => { await server.close(); }); ``` +## Launching servers / platforms without file descriptor pass support. + + This module can be used with standard listening address, for platforms not supporting + file description passing (like windows), or servers that can't reuse sockets passed + as file descriptors. + +- Portable listening address + + You can build a portable listening address using the ```listenAddress()``` function on ```server``` object. That function will return an absolute url that you can use to configure your server in a portable way. + + It will either be the string ```http://*?fd=3``` if file description pass is + allowed, or have a format ```http://
:``` that you can use as a listening address or parse it to get the parameters needed by your server (address and port). + + - on your test script: +```js +... + const server = await starter.newServer(); + await server.launch('node', ['your/path/server.js', server.listenAdress]); +... +``` + + - then on your server (```your/path/server.js``` file) you will get the listenAdress as a command parameter: +```js +// called as node server.js + +const http = require('http'); +let listen = {fd: 3}; +let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); +if (parts) listen = {port: parts[2], address: parts[1]}; +// at this point variable listen will either be {fd: 3} or {port: , address:
}, +// dependint on the first command argument (process.argv[2]) + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World!'); +}); +server.listen(listen); +``` +Note that depending on the server application required format, listenAddress() string could be exactly all you need, like in the case of a Mojolicious app, to load it in a portable way you just use the returned string as the '-l' command argument: + +```js +... + const server = await starter.newServer(); + await server.launch('perl', ['your/path/app.pl', '-l', server.listenAdress]); +... +``` + +- Avoid usage of file description passing of listening socket + +You can use the ENV variable ```MOJO_SERVER_STARTER_AVOID_FDPASS```: + +```shell +export MOJO_SERVER_STARTER_AVOID_FDPASS=1 +``` + +Default value is 0, and will use fd passing whenever is possible (i.e. except for windows platforms) + +- Configurable timeout + +When not using fd passing, there is a timeout to wait for the server to start listening. You configure it as option ```connectTimeout```, in mS, when calling the launch() function: + +```js + const server = await starter.newServer(); + await server.launch(, , {connectTimeout: 3000}); +``` + +Default value is 30000 (30 secs). +This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) + +- Configurable retry time + +When not using fd passing, the launch() function will check if the server is listening every mS. You can configure it as an option: + +```js + const server = await starter.newServer(); + await server.launch(, , {retryTime: 250}); +``` +Default value is 60 (60 mS). +This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) ## Install diff --git a/lib/server-starter.js b/lib/server-starter.js index dfe5048..c2047ce 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -44,6 +44,7 @@ class Server extends EventEmitter { this._process = undefined; this._exitHandlers = []; this._exit = undefined; + this._useFdPass = process.env.MOJO_SERVER_STARTER_AVOID_FDPASS ? false : process.platform !== 'win32'; } /** @@ -64,15 +65,18 @@ class Server extends EventEmitter { * @param {object} [options] - Optional settings * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR + * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS + * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS * @returns {Promise} */ launch (cmd, args, options = {}) { if (typeof this._process !== 'undefined') throw new Error('Server already launched'); const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; - - const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); - + const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; + const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; + const spawnOptions = this._useFdPass ? { stdio: ['pipe', 'pipe', 'pipe', this._fd] } : undefined; + const proc = (this._process = spawn(cmd, args, spawnOptions)); proc.on('error', e => this.emit('error', e)); if (stdout) proc.stdout.pipe(process.stdout); if (stderr) proc.stderr.pipe(process.stderr); @@ -86,7 +90,23 @@ class Server extends EventEmitter { this.emit('exit', code, signal); }); - return new Promise(resolve => this._srv.close(resolve)); + return new Promise(resolve => this._srv.close( + () => { + if (this._useFdPass) resolve(); + else { + const now = new Date(); + const timeToStop = new Date(now.getTime() + connectTimeout); + const port = this.port; + (function loop () { + const connection = net.connect(port, resolve); + connection.on('error', err => { + if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); + else resolve(); // this is intented: don't reject, just stop delaying + }); + })(); + } + } + )); } /** @@ -130,6 +150,15 @@ class Server extends EventEmitter { const port = this.port; return `http://${address}:${port}`; } + + /** + * Listen Address to configure service to be launched + * @returns {string} + */ + listenAddress () { + if (this._useFdPass) return 'http://*?fd=3'; + return this.url(); + } } exports = module.exports = new ServerStarter(); diff --git a/package.json b/package.json index 9ba21e3..dd1496d 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,5 @@ }, "engines": { "node": ">= 10" - }, - "os": [ - "!win32" - ] + } } diff --git a/test/start_fd.js b/test/start.js similarity index 60% rename from test/start_fd.js rename to test/start.js index d00c4d1..e67c7c3 100644 --- a/test/start_fd.js +++ b/test/start.js @@ -8,7 +8,7 @@ const starter = require('..'); t.test('Start and stop a server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_fd.js']); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); t.equal(typeof server.port, 'number', 'port assigned'); @@ -31,7 +31,7 @@ t.test('Start and stop a server', async t => { t.test('Do it again', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_fd.js']); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); @@ -44,11 +44,42 @@ t.test('Do it again', async t => { t.equal(server.pid, null, 'stopped'); }); +t.test('Slow server', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress(), 1000]); + t.equal(typeof server.pid, 'number', 'started'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + +t.test('Slow server, with wrong (too small) timeout', { skip: !process.env.MOJO_SERVER_STARTER_AVOID_FDPASS && process.platform !== 'win32' }, async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server.js', server.listenAddress(), 3000], { connectTimeout: 500 }); + t.equal(typeof server.pid, 'number', 'started'); + + let err; + try { await fetch(server.url()); } catch (e) { err = e; } + t.ok(err, 'request failed'); + t.equal(err.errno, 'ECONNREFUSED', 'right error'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + t.test('Use a specific port', async t => { const port = await getPort(); const server = await starter.newServer(port); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_fd.js']); + await server.launch('node', ['test/support/server.js', server.listenAddress()]); t.equal(typeof server.pid, 'number', 'started'); t.equal(server.port, port, 'right port'); diff --git a/test/support/server.js b/test/support/server.js new file mode 100644 index 0000000..7a243e2 --- /dev/null +++ b/test/support/server.js @@ -0,0 +1,21 @@ +'use strict'; + +// Usage: node server-starter.js +// is mandatory + +const http = require('http'); +const delay = process.argv[3] ? process.argv[3] : 0; +let listen = {fd: 3}; +let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); +if (parts) listen = {port: parts[2], address: parts[1]}; +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World!'); +}); + +// delayed start listening +(() => new Promise(resolve => { + setTimeout( + () => resolve(server.listen(listen)), + delay) +}))(); diff --git a/test/support/server_fd.js b/test/support/server_fd.js deleted file mode 100644 index 6222bb0..0000000 --- a/test/support/server_fd.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const http = require('http'); - -const server = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Hello World!'); -}); -server.listen({ fd: 3 }); From 030cdbf8491fa28e22d78a3fb8cb1313391ef15b Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 14:49:16 -0300 Subject: [PATCH 21/38] small fixes --- README.md | 2 +- lib/server-starter.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eeb7bf6..004bf4d 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ This parameter has no effect when socket is passed through file descriptor (in t - Configurable retry time -When not using fd passing, the launch() function will check if the server is listening every mS. You can configure it as an option: +When not using fd passing, the launch() function will check if the server is listening every ```retryTime``` mS. You can configure it as an option: ```js const server = await starter.newServer(); diff --git a/lib/server-starter.js b/lib/server-starter.js index c2047ce..53e3f70 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -94,14 +94,13 @@ class Server extends EventEmitter { () => { if (this._useFdPass) resolve(); else { - const now = new Date(); - const timeToStop = new Date(now.getTime() + connectTimeout); + const timeToStop = new Date(Date.now() + connectTimeout); const port = this.port; (function loop () { const connection = net.connect(port, resolve); connection.on('error', err => { if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); - else resolve(); // this is intented: don't reject, just stop delaying + else resolve(); }); })(); } From 8663e04fdc6dd708c322bffbc97dd133cc58c940 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 16:38:19 -0300 Subject: [PATCH 22/38] add avoidFdPassing option to launch funtion --- .github/workflows/test.yml | 7 +- .gitignore | 1 - README.md | 65 ++++++++----------- lib/server-starter.js | 9 +-- test/start_fd.js | 74 ++++++++++++++++++++++ test/{start.js => start_port.js} | 26 +++++--- test/support/server_fd.js | 9 +++ test/support/{server.js => server_port.js} | 10 ++- 8 files changed, 137 insertions(+), 64 deletions(-) create mode 100644 test/start_fd.js rename test/{start.js => start_port.js} (76%) create mode 100644 test/support/server_fd.js rename test/support/{server.js => server_port.js} (53%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d74175..e0bcea7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,14 +2,13 @@ name: test on: [push, pull_request] jobs: test: - name: Node ${{ matrix.node-version }}, ${{ matrix.os }}, and avoid fdpass ${{ matrix.avoid-fdpass }} + name: Node ${{ matrix.node-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: node-version: [10.x, 12.x, 14.x, 15.x] - os: [ubuntu-latest, macos-latest, windows-latest] - avoid-fdpass: [0,1] + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v1 - name: Use Node ${{ matrix.node-version }} @@ -20,7 +19,5 @@ jobs: run: npm i - name: npm test run: npm test - env: - MOJO_SERVER_STARTER_AVOID_FDPASS: ${{ matrix.avoid-fdpass }} - name: npm run lint run: npm run lint diff --git a/.gitignore b/.gitignore index dbc94a9..d5f19d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules package-lock.json -.vscode diff --git a/README.md b/README.md index 004bf4d..b433d5d 100644 --- a/README.md +++ b/README.md @@ -84,69 +84,59 @@ t.test('Test the WebSocket chat', async t => { This module can be used with standard listening address, for platforms not supporting file description passing (like windows), or servers that can't reuse sockets passed as file descriptors. - -- Portable listening address - - You can build a portable listening address using the ```listenAddress()``` function on ```server``` object. That function will return an absolute url that you can use to configure your server in a portable way. - - It will either be the string ```http://*?fd=3``` if file description pass is - allowed, or have a format ```http://
:``` that you can use as a listening address or parse it to get the parameters needed by your server (address and port). - - on your test script: -```js -... - const server = await starter.newServer(); - await server.launch('node', ['your/path/server.js', server.listenAdress]); -... -``` + Just as an example, suppose you have a simple js server that will listen in a port passed as a parameter: - - then on your server (```your/path/server.js``` file) you will get the listenAdress as a command parameter: ```js -// called as node server.js +// called as node server.js const http = require('http'); -let listen = {fd: 3}; -let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); -if (parts) listen = {port: parts[2], address: parts[1]}; -// at this point variable listen will either be {fd: 3} or {port: , address:
}, -// dependint on the first command argument (process.argv[2]) const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World!'); }); -server.listen(listen); +// take the port value from the first command line argument (process.argv[2]) +server.listen({ port: process.argv[2] }); ``` -Note that depending on the server application required format, listenAddress() string could be exactly all you need, like in the case of a Mojolicious app, to load it in a portable way you just use the returned string as the '-l' command argument: + +To avoid passing the listen socket with a file descriptor, you also have to define option ```avoidFdPassing``` to true: ```js -... - const server = await starter.newServer(); - await server.launch('perl', ['your/path/app.pl', '-l', server.listenAdress]); -... -``` +const starter = require('@mojolicious/server-starter'); +const fetch = require('node-fetch'); -- Avoid usage of file description passing of listening socket +(async () => { + const server = await starter.newServer(); + await server.launch('node', ['server.js', server.port], { avoidFdPassing: true }); + const url = server.url(); -You can use the ENV variable ```MOJO_SERVER_STARTER_AVOID_FDPASS```: + const res = await fetch(url); + const buffer = await res.buffer(); + console.log(buffer.toString('utf8')); -```shell -export MOJO_SERVER_STARTER_AVOID_FDPASS=1 + await server.close(); ``` -Default value is 0, and will use fd passing whenever is possible (i.e. except for windows platforms) +Note that depending on the server application required format, server.url() returned string could be exactly all you need, like in the case of a Mojolicious app, you can just use the returned string as the '-l' command argument: + +```js +... + const server = await starter.newServer(); + await server.launch('perl', ['your/path/app.pl', '-l', server.url()]); +... +``` - Configurable timeout -When not using fd passing, there is a timeout to wait for the server to start listening. You configure it as option ```connectTimeout```, in mS, when calling the launch() function: +When not using fd passing, there is a timeout to wait for the server to start listening. You can configure it with the option ```connectTimeout```, in mS, when calling the launch() function: ```js const server = await starter.newServer(); - await server.launch(, , {connectTimeout: 3000}); + await server.launch(, , { avoidFdPassing: true, connectTimeout: 3000 }); ``` Default value is 30000 (30 secs). -This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) - Configurable retry time @@ -154,10 +144,9 @@ When not using fd passing, the launch() function will check if the server is lis ```js const server = await starter.newServer(); - await server.launch(, , {retryTime: 250}); + await server.launch(, , { avoidFdPassion: true, retryTime: 250 }); ``` Default value is 60 (60 mS). -This parameter has no effect when socket is passed through file descriptor (in that case waiting for the server is not necessary) ## Install diff --git a/lib/server-starter.js b/lib/server-starter.js index 53e3f70..339af97 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -44,7 +44,6 @@ class Server extends EventEmitter { this._process = undefined; this._exitHandlers = []; this._exit = undefined; - this._useFdPass = process.env.MOJO_SERVER_STARTER_AVOID_FDPASS ? false : process.platform !== 'win32'; } /** @@ -65,6 +64,7 @@ class Server extends EventEmitter { * @param {object} [options] - Optional settings * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR + * @param {boolean} [options.avoidFdPassing=false] - Don't allow file description passing * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS * @returns {Promise} @@ -73,9 +73,10 @@ class Server extends EventEmitter { if (typeof this._process !== 'undefined') throw new Error('Server already launched'); const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; + const avoidFdPassing = typeof options.avoidFdPassing !== 'undefined' ? options.avoidFdPassing : false; const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; - const spawnOptions = this._useFdPass ? { stdio: ['pipe', 'pipe', 'pipe', this._fd] } : undefined; + const spawnOptions = avoidFdPassing ? undefined : { stdio: ['pipe', 'pipe', 'pipe', this._fd] }; const proc = (this._process = spawn(cmd, args, spawnOptions)); proc.on('error', e => this.emit('error', e)); if (stdout) proc.stdout.pipe(process.stdout); @@ -90,7 +91,7 @@ class Server extends EventEmitter { this.emit('exit', code, signal); }); - return new Promise(resolve => this._srv.close( + return new Promise((resolve, reject) => this._srv.close( () => { if (this._useFdPass) resolve(); else { @@ -100,7 +101,7 @@ class Server extends EventEmitter { const connection = net.connect(port, resolve); connection.on('error', err => { if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); - else resolve(); + else reject(err); }); })(); } diff --git a/test/start_fd.js b/test/start_fd.js new file mode 100644 index 0000000..d00c4d1 --- /dev/null +++ b/test/start_fd.js @@ -0,0 +1,74 @@ +'use strict'; + +const t = require('tap'); +const fetch = require('node-fetch'); +const net = require('net'); +const starter = require('..'); + +t.test('Start and stop a server', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server_fd.js']); + t.equal(typeof server.pid, 'number', 'started'); + const url = server.url(); + t.equal(typeof server.port, 'number', 'port assigned'); + + const res = await fetch(url); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); + + let err; + try { await fetch(url); } catch (e) { err = e; } + t.ok(err, 'request failed'); + t.equal(err.errno, 'ECONNREFUSED', 'right error'); +}); + +t.test('Do it again', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server_fd.js']); + t.equal(typeof server.pid, 'number', 'started'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + +t.test('Use a specific port', async t => { + const port = await getPort(); + const server = await starter.newServer(port); + t.equal(server.pid, null, 'not started'); + await server.launch('node', ['test/support/server_fd.js']); + t.equal(typeof server.pid, 'number', 'started'); + t.equal(server.port, port, 'right port'); + + const res = await fetch(server.url()); + t.equal(res.ok, true, '2xx code'); + t.equal(res.headers.get('Content-Type'), 'text/plain', 'right "Content-Type" header'); + const buffer = await res.buffer(); + t.equal(buffer.toString('utf8'), 'Hello World!', 'right content'); + + await server.close(); + t.equal(server.pid, null, 'stopped'); +}); + +function getPort () { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, () => { + resolve(srv.address().port); + srv.close(); + }); + }); +} diff --git a/test/start.js b/test/start_port.js similarity index 76% rename from test/start.js rename to test/start_port.js index e67c7c3..e3c5ad9 100644 --- a/test/start.js +++ b/test/start_port.js @@ -8,7 +8,7 @@ const starter = require('..'); t.test('Start and stop a server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress()]); + await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); t.equal(typeof server.port, 'number', 'port assigned'); @@ -31,7 +31,7 @@ t.test('Start and stop a server', async t => { t.test('Do it again', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress()]); + await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); @@ -47,7 +47,7 @@ t.test('Do it again', async t => { t.test('Slow server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress(), 1000]); + await server.launch('node', ['test/support/server_port.js', server.port, 1000], { avoidFdPassing: true }); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); @@ -60,16 +60,22 @@ t.test('Slow server', async t => { t.equal(server.pid, null, 'stopped'); }); -t.test('Slow server, with wrong (too small) timeout', { skip: !process.env.MOJO_SERVER_STARTER_AVOID_FDPASS && process.platform !== 'win32' }, async t => { +t.test('Slow server, with wrong (too small) timeout', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress(), 3000], { connectTimeout: 500 }); + + let launchErr; + try { + await server.launch('node', ['test/support/server_port.js', server.port, 3000], { connectTimeout: 500, avoidFdPassing: true }); + } catch (e) { launchErr = e; } + t.ok(launchErr, 'request failed'); + t.equal(typeof server.pid, 'number', 'started'); - let err; - try { await fetch(server.url()); } catch (e) { err = e; } - t.ok(err, 'request failed'); - t.equal(err.errno, 'ECONNREFUSED', 'right error'); + let fetchErr; + try { await fetch(server.url()); } catch (e) { fetchErr = e; } + t.ok(fetchErr, 'request failed'); + t.equal(fetchErr.errno, 'ECONNREFUSED', 'right error'); await server.close(); t.equal(server.pid, null, 'stopped'); @@ -79,7 +85,7 @@ t.test('Use a specific port', async t => { const port = await getPort(); const server = await starter.newServer(port); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server.js', server.listenAddress()]); + await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); t.equal(typeof server.pid, 'number', 'started'); t.equal(server.port, port, 'right port'); diff --git a/test/support/server_fd.js b/test/support/server_fd.js new file mode 100644 index 0000000..6222bb0 --- /dev/null +++ b/test/support/server_fd.js @@ -0,0 +1,9 @@ +'use strict'; + +const http = require('http'); + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello World!'); +}); +server.listen({ fd: 3 }); diff --git a/test/support/server.js b/test/support/server_port.js similarity index 53% rename from test/support/server.js rename to test/support/server_port.js index 7a243e2..206e1f7 100644 --- a/test/support/server.js +++ b/test/support/server_port.js @@ -1,13 +1,11 @@ 'use strict'; -// Usage: node server-starter.js -// is mandatory +// Usage: node server_port.js +// is mandatory const http = require('http'); const delay = process.argv[3] ? process.argv[3] : 0; -let listen = {fd: 3}; -let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); -if (parts) listen = {port: parts[2], address: parts[1]}; + const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World!'); @@ -16,6 +14,6 @@ const server = http.createServer((req, res) => { // delayed start listening (() => new Promise(resolve => { setTimeout( - () => resolve(server.listen(listen)), + () => resolve(server.listen({ port: process.argv[2] })), delay) }))(); From 08d5c0469489879a89dc07a29370e91e710ac5bc Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 17:39:10 -0300 Subject: [PATCH 23/38] remove listenAddress() function, improve README --- README.md | 28 +++++++--------------------- lib/server-starter.js | 30 +++++++++--------------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b433d5d..e05fa8c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ t.test('Test the WebSocket chat', async t => { Just as an example, suppose you have a simple js server that will listen in a port passed as a parameter: ```js - // called as node server.js const http = require('http'); @@ -118,36 +117,23 @@ const fetch = require('node-fetch'); await server.close(); ``` -Note that depending on the server application required format, server.url() returned string could be exactly all you need, like in the case of a Mojolicious app, you can just use the returned string as the '-l' command argument: - -```js -... - const server = await starter.newServer(); - await server.launch('perl', ['your/path/app.pl', '-l', server.url()]); -... -``` +Note that depending on the acttual command line your server application needs to be started, either ```server.url()``` returned string or ```server.port``` could be exactly all you need to configure as a parameter when calling the ```launch``` function. +## Configurable timers -- Configurable timeout +```launch()``` promise will not resolve until it can be verified that the launched server is actually listening. This behavior is controlled by two timers: -When not using fd passing, there is a timeout to wait for the server to start listening. You can configure it with the option ```connectTimeout```, in mS, when calling the launch() function: +- ```connectTimeout```, in mS, allows to configure maximum time to wait for the launched server to start listening. Default is 30000 (30 secs). ```js const server = await starter.newServer(); - await server.launch(, , { avoidFdPassing: true, connectTimeout: 3000 }); + await server.launch(, , { connectTimeout: 3000 }); ``` - -Default value is 30000 (30 secs). - -- Configurable retry time - -When not using fd passing, the launch() function will check if the server is listening every ```retryTime``` mS. You can configure it as an option: +- ```retryTime```, in mS, allows to configure the time to retry a connection with the launched server. Default is 60 (60 mS). ```js const server = await starter.newServer(); - await server.launch(, , { avoidFdPassion: true, retryTime: 250 }); + await server.launch(, , { retryTime: 250 }); ``` -Default value is 60 (60 mS). - ## Install $ npm i @mojolicious/server-starter diff --git a/lib/server-starter.js b/lib/server-starter.js index 339af97..4cc252c 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -93,18 +93,15 @@ class Server extends EventEmitter { return new Promise((resolve, reject) => this._srv.close( () => { - if (this._useFdPass) resolve(); - else { - const timeToStop = new Date(Date.now() + connectTimeout); - const port = this.port; - (function loop () { - const connection = net.connect(port, resolve); - connection.on('error', err => { - if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); - else reject(err); - }); - })(); - } + const timeToStop = new Date(Date.now() + connectTimeout); + const port = this.port; + (function loop () { + const connection = net.connect(port, resolve); + connection.on('error', err => { + if (err.code === 'ECONNREFUSED' && new Date() < timeToStop) setTimeout(loop, retryTime); + else reject(err); + }); + })(); } )); } @@ -150,15 +147,6 @@ class Server extends EventEmitter { const port = this.port; return `http://${address}:${port}`; } - - /** - * Listen Address to configure service to be launched - * @returns {string} - */ - listenAddress () { - if (this._useFdPass) return 'http://*?fd=3'; - return this.url(); - } } exports = module.exports = new ServerStarter(); From 18c0dd25f9d88c171735098de8bc86a82181f635 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 18:08:19 -0300 Subject: [PATCH 24/38] add race condition caveat --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e05fa8c..972d2b5 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ t.test('Test the WebSocket chat', async t => { ``` ## Launching servers / platforms without file descriptor pass support. - This module can be used with standard listening address, for platforms not supporting + This module can be used with other ways to pass the listening address, for platforms not supporting file description passing (like windows), or servers that can't reuse sockets passed as file descriptors. @@ -98,8 +98,7 @@ const server = http.createServer((req, res) => { // take the port value from the first command line argument (process.argv[2]) server.listen({ port: process.argv[2] }); ``` - -To avoid passing the listen socket with a file descriptor, you also have to define option ```avoidFdPassing``` to true: +To avoid passing the listen socket with a file descriptor, you have to define option ```avoidFdPassing``` to true: ```js const starter = require('@mojolicious/server-starter'); @@ -128,12 +127,16 @@ Note that depending on the acttual command line your server application needs to const server = await starter.newServer(); await server.launch(, , { connectTimeout: 3000 }); ``` + - ```retryTime```, in mS, allows to configure the time to retry a connection with the launched server. Default is 60 (60 mS). ```js const server = await starter.newServer(); await server.launch(, , { retryTime: 250 }); ``` +## Caveats + +- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race conditions exist between the launched server and other potential processes because nothing prevents the operating system to reasign an already closed port to them. ## Install $ npm i @mojolicious/server-starter From 8b1b9f7ea5e58d0cc238acf4ab03009ff150df99 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 18:17:42 -0300 Subject: [PATCH 25/38] fix typos and rephrase race condition caveat --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 972d2b5..6a155c8 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Note that depending on the acttual command line your server application needs to ``` ## Caveats -- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race conditions exist between the launched server and other potential processes because nothing prevents the operating system to reasign an already closed port to them. +- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race condition is generated between the launched server and other potential processes asking for new random ports, because nothing prevents the operating system to reasign an already closed port to them. ## Install $ npm i @mojolicious/server-starter From 293263c011bfe279403c800ddf4e0c07e972ca44 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 18:25:27 -0300 Subject: [PATCH 26/38] fix typos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a155c8..2c291bc 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Note that depending on the acttual command line your server application needs to ``` ## Caveats -- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race condition is generated between the launched server and other potential processes asking for new random ports, because nothing prevents the operating system to reasign an already closed port to them. +- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race condition could be generated between the launched server and other potential processes asking for new random ports, because nothing prevents the operating system to reasign an already closed port to them. ## Install $ npm i @mojolicious/server-starter From 47797fef69562bb3693c8c2146b7503a80b6d8fd Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 18:56:53 -0300 Subject: [PATCH 27/38] add windows workflow, but skiping start_fd.js tests in this case --- .github/workflows/test.yml | 2 +- test/start_fd.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0bcea7..4ab7560 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: node-version: [10.x, 12.x, 14.x, 15.x] - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v1 - name: Use Node ${{ matrix.node-version }} diff --git a/test/start_fd.js b/test/start_fd.js index d00c4d1..734a9ba 100644 --- a/test/start_fd.js +++ b/test/start_fd.js @@ -5,6 +5,8 @@ const fetch = require('node-fetch'); const net = require('net'); const starter = require('..'); +if (process.platform === 'win32') t.grep = [/Not to run on win32/]; // skip tests on win32 + t.test('Start and stop a server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); From c850246d2ad959315a0dbea2ff5aaa583f41f8cc Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 19:46:05 -0300 Subject: [PATCH 28/38] reorganize README --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c291bc..f8ce9cd 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ t.test('Test the WebSocket chat', async t => { file description passing (like windows), or servers that can't reuse sockets passed as file descriptors. + Note that when using this option the module does not avoid the race condition mentioned in the [Description](#description) paragraph. The option exists just because it would allow your colaborators to be able to run your tests in Windows platforms without needing to switch to WSL. + Just as an example, suppose you have a simple js server that will listen in a port passed as a parameter: ```js @@ -134,9 +136,6 @@ Note that depending on the acttual command line your server application needs to const server = await starter.newServer(); await server.launch(, , { retryTime: 250 }); ``` -## Caveats - -- When ```avoidFdPassing``` mode is used with random port assignement (the default when you create your server with the createServer() function), a race condition could be generated between the launched server and other potential processes asking for new random ports, because nothing prevents the operating system to reasign an already closed port to them. ## Install $ npm i @mojolicious/server-starter From 2d204f44d569b787521919827bf5a4aea573a9fb Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 20:09:03 -0300 Subject: [PATCH 29/38] fix workflow --- .github/workflows/test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b85b17..4ab7560 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: test on: [push, pull_request] jobs: test: - name: Node ${{ matrix.node-version }}, ${{ matrix.os }}, and avoid fdpass ${{ matrix.avoid-fdpass }} + name: Node ${{ matrix.node-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -19,7 +19,5 @@ jobs: run: npm i - name: npm test run: npm test - env: - MOJO_SERVER_STARTER_AVOID_FDPASS: ${{ matrix.avoid-fdpass }} - name: npm run lint run: npm run lint From b05e0d82d481c7188bac4f8e568e0eb9e1245c0f Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 20:12:51 -0300 Subject: [PATCH 30/38] leave server_fd.js as in master branch --- test/support/server_fd.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/test/support/server_fd.js b/test/support/server_fd.js index 7a243e2..6222bb0 100644 --- a/test/support/server_fd.js +++ b/test/support/server_fd.js @@ -1,21 +1,9 @@ 'use strict'; -// Usage: node server-starter.js -// is mandatory +const http = require('http'); -const http = require('http'); -const delay = process.argv[3] ? process.argv[3] : 0; -let listen = {fd: 3}; -let parts = process.argv[2].match(/http:\/\/([^\/]+):(\d+)/); -if (parts) listen = {port: parts[2], address: parts[1]}; const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World!'); }); - -// delayed start listening -(() => new Promise(resolve => { - setTimeout( - () => resolve(server.listen(listen)), - delay) -}))(); +server.listen({ fd: 3 }); From 9140764b39f5ab0a3a4246d51e909320eeca0c4d Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Mon, 5 Apr 2021 20:16:34 -0300 Subject: [PATCH 31/38] fix typo --- test/support/server_port.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/server_port.js b/test/support/server_port.js index 206e1f7..06ff107 100644 --- a/test/support/server_port.js +++ b/test/support/server_port.js @@ -3,7 +3,7 @@ // Usage: node server_port.js // is mandatory -const http = require('http'); +const http = require('http'); const delay = process.argv[3] ? process.argv[3] : 0; const server = http.createServer((req, res) => { From 45f29f371d0abc0d99c4e8759da07ee5dfac243d Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 08:23:02 -0300 Subject: [PATCH 32/38] add a launchPortable() function instead of checking avoidFdPassing option, as logic turned out to be very different --- README.md | 12 ++++++------ lib/server-starter.js | 45 +++++++++++++++++++++++++++++++++++-------- test/start_port.js | 10 +++++----- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f8ce9cd..d613d42 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ const server = http.createServer((req, res) => { // take the port value from the first command line argument (process.argv[2]) server.listen({ port: process.argv[2] }); ``` -To avoid passing the listen socket with a file descriptor, you have to define option ```avoidFdPassing``` to true: +To avoid passing the listen socket with a file descriptor, you have to launch your server using the ```launchPortable()``` function: ```js const starter = require('@mojolicious/server-starter'); @@ -108,7 +108,7 @@ const fetch = require('node-fetch'); (async () => { const server = await starter.newServer(); - await server.launch('node', ['server.js', server.port], { avoidFdPassing: true }); + await server.launchPortable('node', ['server.js', server.port]); const url = server.url(); const res = await fetch(url); @@ -118,23 +118,23 @@ const fetch = require('node-fetch'); await server.close(); ``` -Note that depending on the acttual command line your server application needs to be started, either ```server.url()``` returned string or ```server.port``` could be exactly all you need to configure as a parameter when calling the ```launch``` function. +Note that depending on the acttual command line your server application needs to be started, either ```server.url()``` returned string or ```server.port``` could be exactly all you need to configure as a parameter when calling the ```launchPortable()``` function. ## Configurable timers -```launch()``` promise will not resolve until it can be verified that the launched server is actually listening. This behavior is controlled by two timers: +```launchPortable()``` promise will not resolve until it can be verified that the launched server is actually listening. This behavior is controlled by two timers: - ```connectTimeout```, in mS, allows to configure maximum time to wait for the launched server to start listening. Default is 30000 (30 secs). ```js const server = await starter.newServer(); - await server.launch(, , { connectTimeout: 3000 }); + await server.launchPortable(, , { connectTimeout: 3000 }); ``` - ```retryTime```, in mS, allows to configure the time to retry a connection with the launched server. Default is 60 (60 mS). ```js const server = await starter.newServer(); - await server.launch(, , { retryTime: 250 }); + await server.launchPortable(, , { retryTime: 250 }); ``` ## Install diff --git a/lib/server-starter.js b/lib/server-starter.js index 4cc252c..a196917 100644 --- a/lib/server-starter.js +++ b/lib/server-starter.js @@ -64,20 +64,15 @@ class Server extends EventEmitter { * @param {object} [options] - Optional settings * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR - * @param {boolean} [options.avoidFdPassing=false] - Don't allow file description passing - * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS - * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS * @returns {Promise} */ launch (cmd, args, options = {}) { if (typeof this._process !== 'undefined') throw new Error('Server already launched'); const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; - const avoidFdPassing = typeof options.avoidFdPassing !== 'undefined' ? options.avoidFdPassing : false; - const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; - const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; - const spawnOptions = avoidFdPassing ? undefined : { stdio: ['pipe', 'pipe', 'pipe', this._fd] }; - const proc = (this._process = spawn(cmd, args, spawnOptions)); + + const proc = (this._process = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe', this._fd] })); + proc.on('error', e => this.emit('error', e)); if (stdout) proc.stdout.pipe(process.stdout); if (stderr) proc.stderr.pipe(process.stderr); @@ -91,8 +86,42 @@ class Server extends EventEmitter { this.emit('exit', code, signal); }); + return new Promise(resolve => this._srv.close(resolve)); + } + + /** + * Launch server in "portable" mode (i.e., not passing a fd) + * @param {string} cmd - Server command to run + * @param {string[]} args - Arguments to use with server command + * @param {object} [options] - Optional settings + * @param {boolean} [options.stdout=false] - Forward server output from STDOUT to STDOUT + * @param {boolean} [options.stderr=true] - Forward server output from STDERR to STDERR + * @param {number} [options.connectTimeout=30000] - Max time to wait for server ready, in mS + * @param {number} [options.retryTime=60] - Time to retry for server ready, in mS + * @returns {Promise} + */ + launchPortable (cmd, args, options = {}) { + if (typeof this._process !== 'undefined') throw new Error('Server already launched'); + const stdout = typeof options.stdout !== 'undefined' ? options.stdout : false; + const stderr = typeof options.stderr !== 'undefined' ? options.stderr : true; + const connectTimeout = typeof options.connectTimeout !== 'undefined' ? options.connectTimeout : 30000; + const retryTime = typeof options.retryTime !== 'undefined' ? options.retryTime : 60; + return new Promise((resolve, reject) => this._srv.close( () => { + const proc = (this._process = spawn(cmd, args)); + proc.on('error', e => this.emit('error', e)); + if (stdout) proc.stdout.pipe(process.stdout); + if (stderr) proc.stderr.pipe(process.stderr); + proc.stdout.on('data', data => this.emit('stdout', data)); + proc.stderr.on('data', data => this.emit('stderr', data)); + + proc.on('exit', (code, signal) => { + this._exit = true; + this._process = undefined; + this._exitHandlers.forEach(item => item()); + this.emit('exit', code, signal); + }); const timeToStop = new Date(Date.now() + connectTimeout); const port = this.port; (function loop () { diff --git a/test/start_port.js b/test/start_port.js index e3c5ad9..00868d4 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -8,7 +8,7 @@ const starter = require('..'); t.test('Start and stop a server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); + await server.launchPortable('node', ['test/support/server_port.js', server.port]); t.equal(typeof server.pid, 'number', 'started'); const url = server.url(); t.equal(typeof server.port, 'number', 'port assigned'); @@ -31,7 +31,7 @@ t.test('Start and stop a server', async t => { t.test('Do it again', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); + await server.launchPortable('node', ['test/support/server_port.js', server.port]); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); @@ -47,7 +47,7 @@ t.test('Do it again', async t => { t.test('Slow server', async t => { const server = await starter.newServer(); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_port.js', server.port, 1000], { avoidFdPassing: true }); + await server.launchPortable('node', ['test/support/server_port.js', server.port, 1000]); t.equal(typeof server.pid, 'number', 'started'); const res = await fetch(server.url()); @@ -66,7 +66,7 @@ t.test('Slow server, with wrong (too small) timeout', async t => { let launchErr; try { - await server.launch('node', ['test/support/server_port.js', server.port, 3000], { connectTimeout: 500, avoidFdPassing: true }); + await server.launchPortable('node', ['test/support/server_port.js', server.port, 3000], { connectTimeout: 500 }); } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); @@ -85,7 +85,7 @@ t.test('Use a specific port', async t => { const port = await getPort(); const server = await starter.newServer(port); t.equal(server.pid, null, 'not started'); - await server.launch('node', ['test/support/server_port.js', server.port], { avoidFdPassing: true }); + await server.launchPortable('node', ['test/support/server_port.js', server.port]); t.equal(typeof server.pid, 'number', 'started'); t.equal(server.port, port, 'right port'); From e526c69b721f6f1a026ab4c765622b4404f8cc99 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 08:47:14 -0300 Subject: [PATCH 33/38] fix README intralink --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d613d42..b4c336a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ handles socket activation. This also avoids any race conditions between spawning the managed process and sending the first request, since the listen socket is active the whole time. - For Windows platforms, read also ```Launching servers / platforms without file descriptor pass support``` below. + For Windows platforms, read also [Launching servers / platforms without file descriptor pass support](#Launching-servers-without-file-descriptor-pass-support) below. ```js const http = require('http'); @@ -79,7 +79,7 @@ t.test('Test the WebSocket chat', async t => { await server.close(); }); ``` -## Launching servers / platforms without file descriptor pass support. +## Launching servers without file descriptor pass support This module can be used with other ways to pass the listening address, for platforms not supporting file description passing (like windows), or servers that can't reuse sockets passed From 8ab9dd8980ea544561062506a128abc40e43f97c Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 10:59:21 -0300 Subject: [PATCH 34/38] more tests on portable mode --- test/start_port.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/start_port.js b/test/start_port.js index 00868d4..12ad6d0 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -69,6 +69,7 @@ t.test('Slow server, with wrong (too small) timeout', async t => { await server.launchPortable('node', ['test/support/server_port.js', server.port, 3000], { connectTimeout: 500 }); } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); + t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable error'); t.equal(typeof server.pid, 'number', 'started'); @@ -81,6 +82,49 @@ t.test('Slow server, with wrong (too small) timeout', async t => { t.equal(server.pid, null, 'stopped'); }); +t.test('Failed server, (non existent script)', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + + let launchErr; + let emittedErr; + server.on('stderr', (e) => { emittedErr = e; }); + try { + await server.launchPortable('node', ['test/support/server_nonexistent.js'], { connectTimeout: 500, stderr: false }); + } catch (e) { launchErr = e; } + t.ok(launchErr, 'request failed'); + t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); + t.match(emittedErr.toString(), /Error: Cannot find module/, 'right emmited error'); + let fetchErr; + try { await fetch(server.url()); } catch (e) { fetchErr = e; } + t.ok(fetchErr, 'request failed'); + t.equal(fetchErr.errno, 'ECONNREFUSED', 'right error'); + + t.equal(server.pid, null, 'did not start'); +}); + +t.test('Failed server, (connection error on script)', async t => { + const server = await starter.newServer(); + t.equal(server.pid, null, 'not started'); + + let launchErr; + let emittedErr; + server.on('stderr', (e) => { emittedErr = e; }); + try { + await server.launchPortable('node', ['test/support/server_port.js', -1], { connectTimeout: 500, stderr: false }); + } catch (e) { launchErr = e; } + t.ok(launchErr, 'request failed'); + t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); + t.match(emittedErr.toString(), /ERR_SOCKET_BAD_PORT/, 'right emmited error'); + + let fetchErr; + try { await fetch(server.url()); } catch (e) { fetchErr = e; } + t.ok(fetchErr, 'request failed'); + t.equal(fetchErr.errno, 'ECONNREFUSED', 'right error'); + + t.equal(server.pid, null, 'did not start'); +}); + t.test('Use a specific port', async t => { const port = await getPort(); const server = await starter.newServer(port); From cf15c0488bfc5bf81bb5881fc644566da8732db3 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 18:37:06 -0300 Subject: [PATCH 35/38] fix node 10.x windows cannot find module error --- test/start_port.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/start_port.js b/test/start_port.js index 12ad6d0..c4ebe09 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -94,7 +94,7 @@ t.test('Failed server, (non existent script)', async t => { } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); - t.match(emittedErr.toString(), /Error: Cannot find module/, 'right emmited error'); + t.match(emittedErr.toString(), /: Cannot find module/, 'right emmited error'); let fetchErr; try { await fetch(server.url()); } catch (e) { fetchErr = e; } t.ok(fetchErr, 'request failed'); From 575e5d77ba20ebe9ef8e413a24ef81b6454665bf Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 19:03:19 -0300 Subject: [PATCH 36/38] fix bad port test on windows node 10.x --- test/start_port.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/start_port.js b/test/start_port.js index c4ebe09..53ea8b2 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -115,7 +115,12 @@ t.test('Failed server, (connection error on script)', async t => { } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); - t.match(emittedErr.toString(), /ERR_SOCKET_BAD_PORT/, 'right emmited error'); + // unfortunatelly, node 10.x bad port error in windows is very nonspecific + const badPortErrMsg = process.platform === 'win32' && + process.versions.node.split('.')[0] === '10' + ? /^\.processTimers / + : /ERR_SOCKET_BAD_PORT/; + t.match(emittedErr.toString(), new RegExp(badPortErrMsg), 'right emmited error'); let fetchErr; try { await fetch(server.url()); } catch (e) { fetchErr = e; } From 4fbb40b1d67c707a5f659c6f2902246c3f5430ea Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Tue, 6 Apr 2021 19:20:12 -0300 Subject: [PATCH 37/38] fix bad port test, try harder --- test/start_port.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/start_port.js b/test/start_port.js index 53ea8b2..1b8e571 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -115,10 +115,12 @@ t.test('Failed server, (connection error on script)', async t => { } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); - // unfortunatelly, node 10.x bad port error in windows is very nonspecific + // unfortunatelly, node 10.x ERR_SOCKET_BAD_PORT content seems to be broken on windows, + // also been nonspecific, so checks in that particular case we check for any string + // received on stderr const badPortErrMsg = process.platform === 'win32' && process.versions.node.split('.')[0] === '10' - ? /^\.processTimers / + ? /\w/ : /ERR_SOCKET_BAD_PORT/; t.match(emittedErr.toString(), new RegExp(badPortErrMsg), 'right emmited error'); From 054e412f99b576e5f053e3d9a7948732dec2a977 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Wed, 7 Apr 2021 09:04:26 -0300 Subject: [PATCH 38/38] skip some tests on node 10.x (windows), seems to have error msgs broken --- test/start_port.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/start_port.js b/test/start_port.js index 1b8e571..69d77f8 100644 --- a/test/start_port.js +++ b/test/start_port.js @@ -94,7 +94,14 @@ t.test('Failed server, (non existent script)', async t => { } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); - t.match(emittedErr.toString(), /: Cannot find module/, 'right emmited error'); + + // skip windows node 10.x stderr tests, as seem to be broken + t.match( + emittedErr.toString(), + /Error: Cannot find module/, + 'right emmited error', + { skip: process.platform === 'win32' && process.versions.node.split('.')[0] === '10' } + ); let fetchErr; try { await fetch(server.url()); } catch (e) { fetchErr = e; } t.ok(fetchErr, 'request failed'); @@ -115,14 +122,14 @@ t.test('Failed server, (connection error on script)', async t => { } catch (e) { launchErr = e; } t.ok(launchErr, 'request failed'); t.equal(launchErr.code, 'ECONNREFUSED', 'launchPortable timeout error'); - // unfortunatelly, node 10.x ERR_SOCKET_BAD_PORT content seems to be broken on windows, - // also been nonspecific, so checks in that particular case we check for any string - // received on stderr - const badPortErrMsg = process.platform === 'win32' && - process.versions.node.split('.')[0] === '10' - ? /\w/ - : /ERR_SOCKET_BAD_PORT/; - t.match(emittedErr.toString(), new RegExp(badPortErrMsg), 'right emmited error'); + + // skip windows node 10.x stderr tests, as seem to be broken + t.match( + emittedErr.toString(), + /ERR_SOCKET_BAD_PORT/, + 'right emmited error', + { skip: process.platform === 'win32' && process.versions.node.split('.')[0] === '10' } + ); let fetchErr; try { await fetch(server.url()); } catch (e) { fetchErr = e; }