From e99fd6e3fc5065f35763949992d5123c849ff9c6 Mon Sep 17 00:00:00 2001 From: Daniel Mantovani Date: Sun, 4 Apr 2021 12:49:28 -0300 Subject: [PATCH] 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 });