├── .editorconfig ├── test ├── server.js ├── performance.js ├── fixture.cert ├── fixture.key └── stoppable.test.js ├── .travis.yml ├── LICENSE ├── .gitignore ├── package.json ├── lib └── stoppable.js └── readme.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const stoppable = require('..') 3 | 4 | const grace = Number(process.argv[2] || Infinity) 5 | const server = http.createServer((req, res) => { 6 | const delay = parseInt(req.url.slice(1), 10) 7 | res.writeHead(200) 8 | res.write('hello') 9 | setTimeout(() => res.end('world'), delay) 10 | server.stop() 11 | }) 12 | stoppable(server, grace) 13 | server.listen(8000, () => console.log('listening')) 14 | -------------------------------------------------------------------------------- /test/performance.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const stoppable = require('..') 3 | 4 | setTimeout(() => { 5 | server.close(() => { 6 | console.log('\nstopped performance testing server') 7 | process.exit() 8 | }) 9 | }, 30000) 10 | 11 | const server = http.createServer((req, res) => { 12 | res.end('hello world') 13 | }) 14 | 15 | if (process.argv[2] === '1') stoppable(server, 5000) 16 | 17 | server.listen(8000, () => { 18 | console.log('\nstarted performance testing server') 19 | }) 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '10' 5 | - '9' 6 | - '8' 7 | before_install: | 8 | [[ ! -x ~/npm/node_modules/.bin/npm ]] && { 9 | # caching feature creates `~/npm` for us 10 | cd ~/npm && npm install npm@^6 11 | cd - 12 | } || true 13 | # avoids bugs around https://github.com/travis-ci/travis-ci/issues/5092 14 | export PATH=~/npm/node_modules/.bin:$PATH 15 | # this avoids compilation in most cases (where we don't need it) 16 | install: npm ci 17 | cache: 18 | directories: 19 | - ~/.npm # cache npm's cache 20 | - ~/npm # cache latest npm 21 | notifications: 22 | email: false 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Hunter Loftis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixture.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDBzCCAe+gAwIBAgIJALpsaWTYcddKMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV 3 | BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNzA1MTkxOTAwMzZaFw0yNzA1MTcxOTAw 4 | MzZaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB 5 | BQADggEPADCCAQoCggEBAOix4xb3jnIdWwqfT+vtoXadLuQJE21HN19Y2falkJuB 6 | k/ME0TKMeMjmQQZCO7G0K+5fMnQsEtQjcjY3XUXViliGfkAQplUD3Z9/xwsTSGN2 7 | ahBXE2W/GV+xJ6uX9KbJx2pMOXCuux6cKylYhmr8cTs2f9E6QpPji4LqtHv/9cAE 8 | QKRmv2rSAP1Q+1Ne2WYNbgHBuI35vuQsvZTN5QsozsferP9Qqtx8kpnBaLTgFZYD 9 | ZaEreYwFFYAQNfq2jOGEAAxStiXUpn3rT9T8KeOvLfWOifqYzDOTzL0t2py9bnvl 10 | x2fl8aJHc3NiU+4qlq3DuDEitiUoOkirGhFL7JFH4K0CAwEAAaNQME4wHQYDVR0O 11 | BBYEFAI/PRTwA3VKpSQAwXg2JDmDGVXxMB8GA1UdIwQYMBaAFAI/PRTwA3VKpSQA 12 | wXg2JDmDGVXxMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAHfYOl+n 13 | qB0Oyq/2jXE33fR5+QsaDrciXH0BWCjIVM8ADLPjYPAD3StTIceg3wfCw2n9xuku 14 | pObukGbApSqvGBSAIsvbbNAwcFp31DCqGQdfVMJMG1TCp3J7y0WslYMqU7DM1jYt 15 | ybqF9ICuGdPZ0KVsv0/ZmbSs98/fSyjUYoHfD+GngVrguuU/v0XUi4hjVqFyMZQZ 16 | AxGNq4QIlKxdo55L45vCMzGiajT7BE0EnChvFpOGXF5/pk072RESI7uxJBiAssWP 17 | uCk0xHxLtacOQK3seFFw0d7t3769gVDNi732eTMhoFQj+loSgmnRwDKL7QPhZ8tj 18 | pRRUGV4sPR+ucpo= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stoppable", 3 | "version": "1.1.0", 4 | "engines": { 5 | "node": ">=4", 6 | "npm": ">=6" 7 | }, 8 | "keywords": [ 9 | "server", 10 | "net", 11 | "connect", 12 | "socket", 13 | "connection", 14 | "stop", 15 | "close", 16 | "disconnect", 17 | "disconnection", 18 | "http", 19 | "https", 20 | "tcp" 21 | ], 22 | "files": [ 23 | "lib" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/hunterloftis/stoppable.git" 28 | }, 29 | "homepage": "https://github.com/hunterloftis/stoppable", 30 | "scripts": { 31 | "lint": "standard --fix \"lib/**/*.js\" \"test/**/*.js\"", 32 | "spec": "nyc --check-coverage mocha --bail \"test/*.test.js\"", 33 | "test": "npm run lint && npm audit && npm run spec", 34 | "coverage": "nyc mocha --bail \"test/*.test.js\"", 35 | "perf:baseline": "node test/performance.js & sleep 2 && artillery quick -d 10 -r 1000 -o /dev/null -k http://localhost:8000", 36 | "perf:stoppable": "node test/performance.js 1 & sleep 2 && artillery quick -d 10 -r 1000 -o /dev/null -k http://localhost:8000" 37 | }, 38 | "main": "lib/stoppable.js", 39 | "license": "MIT", 40 | "devDependencies": { 41 | "artillery": "^1.6.0-29", 42 | "awaiting": "^3.0.0", 43 | "chai": "^4.1.2", 44 | "mocha": "^5.0.5", 45 | "nyc": "^14.1.1", 46 | "requisition": "^1.7.0", 47 | "standard": "^11.0.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/fixture.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA6LHjFveOch1bCp9P6+2hdp0u5AkTbUc3X1jZ9qWQm4GT8wTR 3 | Mox4yOZBBkI7sbQr7l8ydCwS1CNyNjddRdWKWIZ+QBCmVQPdn3/HCxNIY3ZqEFcT 4 | Zb8ZX7Enq5f0psnHakw5cK67HpwrKViGavxxOzZ/0TpCk+OLguq0e//1wARApGa/ 5 | atIA/VD7U17ZZg1uAcG4jfm+5Cy9lM3lCyjOx96s/1Cq3HySmcFotOAVlgNloSt5 6 | jAUVgBA1+raM4YQADFK2JdSmfetP1Pwp468t9Y6J+pjMM5PMvS3anL1ue+XHZ+Xx 7 | okdzc2JT7iqWrcO4MSK2JSg6SKsaEUvskUfgrQIDAQABAoIBAE8UJRipCL+/OjFh 8 | 8sc6+qRUxpq4euGoUikVCP3JRluSrbTo7i8/jcy4c2CtIZxCnqtjrsHMOJnfcfD6 9 | 37fb2ig7jKw4/E3oAmkyA3LAGtmyZFkpPm5Vg0oB6nlmKr6D1EFLpjmlJ/I/IGvs 10 | qcGyCMkWvFlec0HPEpprKOr7EYkvQscy99ny7JswG1P9PELwo7tIeb0BioPYnFmF 11 | I0BPgI1lxDHKQTUPAao9rStiHsuPMCkw51qUgp/Z814ld4KDXCaWFQPy5riHDykH 12 | wm9n2hkM6pq4d6eHuMVj7CuBdp141k2BAnZdysMHpE9y1315+didoEcox8+zOLeO 13 | OC4XZAECgYEA/U98ld2YnVcSL9/Pr17SVG/q3iZaUueUkf+CEdj7LpvStedpky5W 14 | dOM7ad0kBcPqIafgn/O3teYjVl8FM0oOtOheMHHMkYxbXuECA5hkk7emu3oIJcAO 15 | +9Pb/uGdufWmAVyRueRam4tubiLxv46xeGUmscCnwG78bj+rq74ATjcCgYEA6ypd 16 | qt/b43y4SHY4LDuuJk5jfC5MNXztIi3sOtuGoJNUlzC/hI/NNhEDhP3Pzo9c/i0k 17 | aCzyjhRyiaFK2SHQ5SQdCFi44PM+MptwFjY1KPGv20m5omfBgJOoF+Ad13qrUQF/ 18 | b7/C5j3PZkOZfwaYO+erLeaayWKRJi2AEoXb9jsCgYEAnxAHuo/A4qQnXnqbDpNr 19 | Xew9Pqw0sbSLvbYFNjHbYKQmh2U+DVbeoV2DFHHxydEBN4sUaTyAUq+l5vmZ6WAK 20 | phz38FG1VHwfcA+41QsftQZwo274qMPWZNnfXkjMY1ZWnKpFM8aqAtxmRrCYv2Ha 21 | HTDfQGUqsZK/3ncK1LhltrcCgYBiJKk4wfpL42YpX6Ur2LBibj6Yud22SO/SXuYC 22 | 3lE+PJ6GBqM3GKilEs6sNxz98Nj3fzF9hJyp7SCsDbNmEPXUW5D+RcDKqNlhV3uc 23 | 2XywHMWuuAMQI0sfdQAnDrKFlj1fLkfYBGi7nDotTLMHz2HDRnkrS913hHpdO4oC 24 | sPjOtwKBgQDDiG7Vagk4SgPXt7zE0aSiFIIjJLpM28mES+k6zOtKAyOcTMHcDrI7 25 | YmSN1kq3w2g7RS5eMUpszqbUGoR6VDAjbgGAakDOno/uZWfEMjiQiKvRDSY1nmlc 26 | xSKubMZDf/OKUYTGasL1rqJJN7mxW2irptygc26NxMeAWZfgkmiPLg== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /lib/stoppable.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const https = require('https') 4 | 5 | module.exports = (server, grace) => { 6 | grace = typeof grace === 'undefined' ? Infinity : grace 7 | const reqsPerSocket = new Map() 8 | let stopped = false 9 | let gracefully = true 10 | 11 | if (server instanceof https.Server) { 12 | server.on('secureConnection', onConnection) 13 | } else { 14 | server.on('connection', onConnection) 15 | } 16 | 17 | server.on('request', onRequest) 18 | server.stop = stop 19 | server._pendingSockets = reqsPerSocket 20 | return server 21 | 22 | function onConnection (socket) { 23 | reqsPerSocket.set(socket, 0) 24 | socket.once('close', () => reqsPerSocket.delete(socket)) 25 | } 26 | 27 | function onRequest (req, res) { 28 | reqsPerSocket.set(req.socket, reqsPerSocket.get(req.socket) + 1) 29 | res.once('finish', () => { 30 | const pending = reqsPerSocket.get(req.socket) - 1 31 | reqsPerSocket.set(req.socket, pending) 32 | if (stopped && pending === 0) { 33 | req.socket.end() 34 | } 35 | }) 36 | } 37 | 38 | function stop (callback) { 39 | // allow request handlers to update state before we act on that state 40 | setImmediate(() => { 41 | stopped = true 42 | if (grace < Infinity) { 43 | setTimeout(destroyAll, grace).unref() 44 | } 45 | server.close(e => { 46 | if (callback) { 47 | callback(e, gracefully) 48 | } 49 | }) 50 | reqsPerSocket.forEach(endIfIdle) 51 | }) 52 | } 53 | 54 | function endIfIdle (requests, socket) { 55 | if (requests === 0) socket.end() 56 | } 57 | 58 | function destroyAll () { 59 | gracefully = false 60 | reqsPerSocket.forEach((reqs, socket) => socket.end()) 61 | setImmediate(() => { 62 | reqsPerSocket.forEach((reqs, socket) => socket.destroy()) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Stoppable 2 | 3 | [![Build Status](https://travis-ci.org/hunterloftis/stoppable.svg?branch=master)](https://travis-ci.org/hunterloftis/stoppable) 4 | 5 | > Node's `server.close()` the way you probably [expected it to work by default](https://github.com/nodejs/node/issues/2642). 6 | 7 | ## Summary 8 | 9 | ```js 10 | const server = stoppable(http.createServer(handler)) 11 | server.stop() 12 | ``` 13 | 14 | Stoppable stops accepting new connections and closes existing, idle connections (including keep-alives) 15 | without killing requests that are in-flight. 16 | 17 | ## Requirements 18 | 19 | - Node.js v6+ 20 | 21 | Node.js v4.x is *unofficially* supported. 22 | 23 | ## Installation 24 | 25 | ```bash 26 | yarn add stoppable 27 | ``` 28 | 29 | (or use npm) 30 | 31 | ## Usage 32 | 33 | **constructor** 34 | 35 | ```js 36 | stoppable(server, grace) 37 | ``` 38 | 39 | Decorates the server instance with a `stop` method. 40 | Returns the server instance, so can be chained, or can be run as a standalone statement. 41 | 42 | - server: Any HTTP or HTTPS Server instance 43 | - grace: Milliseconds to wait before force-closing connections 44 | 45 | `grace` defaults to Infinity (don't force-close). 46 | If you want to immediately kill all sockets you can use a grace of 0. 47 | 48 | **stop()** 49 | 50 | ```js 51 | server.stop(callback) 52 | ``` 53 | 54 | Closes the server. 55 | 56 | - callback: passed along to the existing `server.close` function to auto-register a 'close' event. 57 | The first agrument is an error, and the second argument is a boolean that indicates whether it stopped gracefully. 58 | 59 | ## Design decisions 60 | 61 | - Monkey patching generally sucks, but in this case it's the nicest API. Let's call it "decorating." 62 | - `grace` could be specified on `stop`, but it's better to match the existing `server.close` API. 63 | - Clients should be handled respectfully, so we aren't just destroying sockets, we're sending `FIN` packets first. 64 | - Any solution to this problem requires bookkeeping on every connection and request/response. 65 | We're doing a minimum of work on these "hot" code paths and delaying as much as possible to the actual `stop` method. 66 | 67 | ## Performance 68 | 69 | There's no way to provide this functionality without bookkeeping on connection, disconnection, request, and response. 70 | However, Stoppable strives to do minimal work in hot code paths and to use optimal data structures. 71 | 72 | I'd be interested to see real-world performance benchmarks; 73 | the simple loopback artillery benchmark included in the lib shows very little overhead from using a stoppable server: 74 | 75 | ### Without Stoppable 76 | 77 | ```plain 78 | Scenarios launched: 10000 79 | Scenarios completed: 10000 80 | Requests completed: 10000 81 | RPS sent: 939.85 82 | Request latency: 83 | min: 0.5 84 | max: 51.3 85 | median: 2.1 86 | p95: 3.7 87 | p99: 15.3 88 | Scenario duration: 89 | min: 1 90 | max: 60.7 91 | median: 3.6 92 | p95: 7.6 93 | p99: 19 94 | Scenario counts: 95 | 0: 10000 (100%) 96 | Codes: 97 | 200: 10000 98 | ``` 99 | 100 | ### With Stoppable 101 | 102 | ```plain 103 | Scenarios launched: 10000 104 | Scenarios completed: 10000 105 | Requests completed: 10000 106 | RPS sent: 940.73 107 | Request latency: 108 | min: 0.5 109 | max: 43.4 110 | median: 2.1 111 | p95: 3.8 112 | p99: 15.5 113 | Scenario duration: 114 | min: 1.1 115 | max: 57 116 | median: 3.7 117 | p95: 8 118 | p99: 19.4 119 | Scenario counts: 120 | 0: 10000 (100%) 121 | Codes: 122 | 200: 10000 123 | ``` 124 | 125 | ## License 126 | 127 | MIT -------------------------------------------------------------------------------- /test/stoppable.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const http = require('http') 4 | const https = require('https') 5 | const a = require('awaiting') 6 | const request = require('requisition') 7 | const assert = require('chai').assert 8 | const fs = require('fs') 9 | const stoppable = require('..') 10 | const child = require('child_process') 11 | const path = require('path') 12 | 13 | const PORT = 8000 14 | 15 | const schemes = { 16 | http: { 17 | agent: (opts = {}) => new http.Agent(opts), 18 | server: handler => http.createServer(handler || ((req, res) => res.end('hello'))) 19 | }, 20 | https: { 21 | agent: (opts = {}) => https.Agent(Object.assign({rejectUnauthorized: false}, opts)), 22 | server: handler => https.createServer({ 23 | key: fs.readFileSync('test/fixture.key'), 24 | cert: fs.readFileSync('test/fixture.cert') 25 | }, handler || ((req, res) => res.end('hello'))) 26 | } 27 | } 28 | 29 | Object.keys(schemes).forEach(schemeName => { 30 | const scheme = schemes[schemeName] 31 | 32 | describe(`${schemeName}.Server`, function () { 33 | describe('.close()', () => { 34 | let server 35 | 36 | beforeEach(function () { 37 | server = scheme.server() 38 | }) 39 | 40 | describe('without keep-alive connections', () => { 41 | let closed = 0 42 | it('stops accepting new connections', async () => { 43 | server.on('close', () => closed++) 44 | server.listen(PORT) 45 | await a.event(server, 'listening') 46 | const res1 = 47 | await request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent()) 48 | const text1 = await res1.text() 49 | assert.equal(text1, 'hello') 50 | server.close() 51 | const err = await a.failure( 52 | request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent())) 53 | assert.match(err.message, /ECONNREFUSED/) 54 | }) 55 | 56 | it('closes', () => { 57 | assert.equal(closed, 1) 58 | }) 59 | }) 60 | 61 | describe('with keep-alive connections', () => { 62 | let closed = 0 63 | 64 | it('stops accepting new connections', async () => { 65 | server.on('close', () => closed++) 66 | server.listen(PORT) 67 | await a.event(server, 'listening') 68 | const res1 = await request(`${schemeName}://localhost:${PORT}`) 69 | .agent(scheme.agent({keepAlive: true})) 70 | const text1 = await res1.text() 71 | assert.equal(text1, 'hello') 72 | server.close() 73 | const err = 74 | await a.failure(request(`${schemeName}://localhost:${PORT}`) 75 | .agent(scheme.agent({keepAlive: true}))) 76 | assert.match(err.message, /ECONNREFUSED/) 77 | }) 78 | 79 | it("doesn't close", () => { 80 | assert.equal(closed, 0) 81 | }) 82 | }) 83 | }) 84 | 85 | describe('.stop()', function () { 86 | describe('without keep-alive connections', function () { 87 | let closed = 0 88 | let gracefully = false 89 | let server 90 | 91 | beforeEach(function () { 92 | server = stoppable(scheme.server()) 93 | }) 94 | 95 | it('stops accepting new connections', async () => { 96 | server.on('close', () => closed++) 97 | server.listen(PORT) 98 | await a.event(server, 'listening') 99 | const res1 = 100 | await request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent()) 101 | const text1 = await res1.text() 102 | assert.equal(text1, 'hello') 103 | server.stop((e, g) => { 104 | gracefully = g 105 | }) 106 | const err = await a.failure( 107 | request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent())) 108 | assert.match(err.message, /ECONNREFUSED/) 109 | }) 110 | 111 | it('closes', () => { 112 | assert.equal(closed, 1) 113 | }) 114 | 115 | it('gracefully', () => { 116 | assert.isOk(gracefully) 117 | }) 118 | }) 119 | 120 | describe('with keep-alive connections', () => { 121 | let closed = 0 122 | let gracefully = false 123 | let server 124 | 125 | beforeEach(function () { 126 | server = stoppable(scheme.server()) 127 | }) 128 | 129 | it('stops accepting new connections', async () => { 130 | server.on('close', () => closed++) 131 | server.listen(PORT) 132 | await a.event(server, 'listening') 133 | const res1 = await request(`${schemeName}://localhost:${PORT}`) 134 | .agent(scheme.agent({keepAlive: true})) 135 | const text1 = await res1.text() 136 | assert.equal(text1, 'hello') 137 | server.stop((e, g) => { 138 | gracefully = g 139 | }) 140 | const err = await a.failure(request(`${schemeName}://localhost:${PORT}`) 141 | .agent(scheme.agent({ keepAlive: true }))) 142 | assert.match(err.message, /ECONNREFUSED/) 143 | }) 144 | 145 | it('closes', () => { assert.equal(closed, 1) }) 146 | 147 | it('gracefully', () => { 148 | assert.isOk(gracefully) 149 | }) 150 | 151 | it('empties all sockets once closed', 152 | () => { assert.equal(server._pendingSockets.size, 0) }) 153 | 154 | it('registers the "close" callback', (done) => { 155 | server.listen(PORT) 156 | server.stop(done) 157 | }) 158 | }) 159 | }) 160 | 161 | describe('with a 0.5s grace period', () => { 162 | let gracefully = true 163 | let server 164 | 165 | beforeEach(function () { 166 | server = stoppable(scheme.server((req, res) => { 167 | res.writeHead(200) 168 | res.write('hi') 169 | }), 500) 170 | }) 171 | 172 | it('kills connections after 0.5s', async () => { 173 | server.listen(PORT) 174 | await a.event(server, 'listening') 175 | await Promise.all([ 176 | request(`${schemeName}://localhost:${PORT}`) 177 | .agent(scheme.agent({keepAlive: true})), 178 | request(`${schemeName}://localhost:${PORT}`) 179 | .agent(scheme.agent({keepAlive: true})) 180 | ]) 181 | const start = Date.now() 182 | server.stop((e, g) => { 183 | gracefully = g 184 | }) 185 | await a.event(server, 'close') 186 | assert.closeTo(Date.now() - start, 500, 50) 187 | }) 188 | 189 | it('gracefully', () => { 190 | assert.isNotOk(gracefully) 191 | }) 192 | 193 | it('empties all sockets', () => { 194 | assert.equal(server._pendingSockets.size, 0) 195 | }) 196 | }) 197 | 198 | describe('with requests in-flight', () => { 199 | let server 200 | let gracefully = false 201 | 202 | beforeEach(function () { 203 | server = stoppable(scheme.server((req, res) => { 204 | const delay = parseInt(req.url.slice(1), 10) 205 | res.writeHead(200) 206 | res.write('hello') 207 | setTimeout(() => res.end('world'), delay) 208 | })) 209 | }) 210 | 211 | it('closes their sockets once they finish', async () => { 212 | server.listen(PORT) 213 | await a.event(server, 'listening') 214 | const start = Date.now() 215 | const res = await Promise.all([ 216 | request(`${schemeName}://localhost:${PORT}/250`) 217 | .agent(scheme.agent({keepAlive: true})), 218 | request(`${schemeName}://localhost:${PORT}/500`) 219 | .agent(scheme.agent({keepAlive: true})) 220 | ]) 221 | server.stop((e, g) => { 222 | gracefully = g 223 | }) 224 | const bodies = await Promise.all(res.map(r => r.text())) 225 | await a.event(server, 'close') 226 | assert.equal(bodies[0], 'helloworld') 227 | assert.closeTo(Date.now() - start, 500, 100) 228 | }) 229 | it('gracefully', () => { 230 | assert.isOk(gracefully) 231 | }) 232 | 233 | describe('with in-flights finishing before grace period ends', function () { 234 | if (schemeName !== 'http') { 235 | return 236 | } 237 | 238 | it('exits immediately', async () => { 239 | const file = path.join(__dirname, 'server.js') 240 | const server = child.spawn('node', [file, '500']) 241 | await a.event(server.stdout, 'data') 242 | const start = Date.now() 243 | const res = await request(`${schemeName}://localhost:${PORT}/250`) 244 | .agent(scheme.agent({keepAlive: true})) 245 | const body = await res.text() 246 | assert.equal(body, 'helloworld') 247 | assert.closeTo(Date.now() - start, 250, 100) 248 | }) 249 | }) 250 | }) 251 | }) 252 | }) 253 | --------------------------------------------------------------------------------