diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..9bcdb46 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] +} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 2850efc..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,74 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "master" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "master" ] - schedule: - - cron: '44 21 * * 5' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index aebe8bb..63e9df1 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -3,16 +3,15 @@ name: CI on: push: branches: [ master ] - pull_request: branches: [ master ] - workflow_dispatch: {} - jobs: Job: name: Node.js - uses: artusjs/github-actions/.github/workflows/node-test.yml@v1 + uses: node-modules/github-actions/.github/workflows/node-test.yml@master with: os: 'ubuntu-latest, macos-latest' - version: '14, 16, 18' + version: '18.19.0, 18, 20, 22' + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml new file mode 100644 index 0000000..bac3fac --- /dev/null +++ b/.github/workflows/pkg.pr.new.yml @@ -0,0 +1,23 @@ +name: Publish Any Commit +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run prepublishOnly --if-present + + - run: npx pkg-pr-new publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1612587..1c6cbb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,14 +4,10 @@ on: push: branches: [ master ] - workflow_dispatch: {} - jobs: release: name: Node.js - uses: artusjs/github-actions/.github/workflows/node-release.yml@v1 + uses: node-modules/github-actions/.github/workflows/node-release.yml@master secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GIT_TOKEN: ${{ secrets.GIT_TOKEN }} - with: - checkTest: false diff --git a/.gitignore b/.gitignore index a84002d..50717b4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ results node_modules npm-debug.log +.tshy* +.eslintcache +dist diff --git a/History.md b/CHANGELOG.md similarity index 100% rename from History.md rename to CHANGELOG.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a137bcd --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2013 - 2014 fengmk2 +Copyright (c) 2015 - present node-modules and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 3ec5257..b8bf7aa 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ [![NPM version][npm-image]][npm-url] [![Test coverage][cov-image]][cov-url] [![npm download][download-image]][download-url] +[![Node.js Version](https://img.shields.io/node/v/graceful.svg?style=flat)](https://nodejs.org/en/download/) [npm-image]: https://img.shields.io/npm/v/graceful.svg?style=flat-square [npm-url]: https://npmjs.org/package/graceful -[cov-image]: https://codecov.io/github/node-modules/cfork/coverage.svg?branch=master -[cov-url]: https://codecov.io/github/node-modules/cfork?branch=master +[cov-image]: https://codecov.io/github/node-modules/graceful/coverage.svg?branch=master +[cov-url]: https://codecov.io/github/node-modules/graceful?branch=master [download-image]: https://img.shields.io/npm/dm/graceful.svg?style=flat-square [download-url]: https://npmjs.org/package/graceful @@ -17,26 +18,25 @@ Graceful exit when `uncaughtException` emit, base on `process.on('uncaughtExcept It's the best way to handle `uncaughtException` on current situations. -* [domain failure](https://github.com/fengmk2/domain-middleware/blob/master/example/failure.js). * [Node.js 异步异常的处理与domain模块解析](http://deadhorse.me/nodejs/2013/04/13/exception_and_domain.html) ## Install ```bash -$ npm install graceful +npm install graceful ``` ## Usage -Please see [connect_with_cluster](https://github.com/fengmk2/graceful/tree/master/example/connect_with_cluster) example. +Please see [express_with_cluster](https://github.com/node-modules/graceful/tree/master/example/express_with_cluster) example. This below code just for dev demo, don't use it on production env: ```js -var express = require('express'); -var graceful = require('graceful'); +const express = require('express'); +const { graceful } = require('graceful'); -var app = express() +const app = express() .use() .use(function(req, res){ if (Math.random() > 0.5) { @@ -59,7 +59,7 @@ var app = express() res.end(err.message); }); -var server = app.listen(1984); +const server = app.listen(1984); graceful({ servers: [server], @@ -76,43 +76,18 @@ graceful({ }); ``` -If you are using [pm](https://github.com/aleafs/pm), -you can follow the [graceful_exit with pm demo](https://github.com/aleafs/pm/tree/master/demo/graceful_exit). +### ESM and TypeScript - +```ts +import { graceful } from 'graceful'; +``` ## Contributors -|[
fengmk2](https://github.com/fengmk2)
|[
dead-horse](https://github.com/dead-horse)
|[
hyj1991](https://github.com/hyj1991)
|[
imyelo](https://github.com/imyelo)
| -| :---: | :---: | :---: | :---: | - - -This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Thu Sep 22 2022 19:41:57 GMT+0800`. +[![Contributors](https://contrib.rocks/image?repo=node-modules/graceful)](https://github.com/node-modules/graceful/graphs/contributors) - +Made with [contributors-img](https://contrib.rocks). ## License -(The MIT License) - -Copyright (c) 2013 - 2014 fengmk2 <fengmk2@gmail.com> -Copyright (c) 2015 - 2016 node-modules and other contributors - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +[MIT](LICENSE) diff --git a/example/express_with_cluster/README.md b/example/express_with_cluster/README.md index 112ce45..d399e7c 100644 --- a/example/express_with_cluster/README.md +++ b/example/express_with_cluster/README.md @@ -1,13 +1,13 @@ # express with cluster example -* Master: dispatch.js -* Worker: worker.js -* Your application logic: app.js +* Master: dispatch.cjs +* Worker: worker.cjs +* Your application logic: app.cjs ## Run ```bash -$ node example/express_with_cluster/dispatch.js +$ node example/express_with_cluster/dispatch.cjs ``` ## Test @@ -21,10 +21,10 @@ $ curl localhost:1337/asyncerror ``` -[dispatch.js](https://github.com/node-modules/graceful/blob/master/example/express_with_cluster/dispatch.js) stdout: +[dispatch.cjs](https://github.com/node-modules/graceful/blob/master/example/express_with_cluster/dispatch.cjs) stdout: ```bash -$ node example/express_with_cluster/dispatch.js +$ node example/express_with_cluster/dispatch.cjs [Thu Apr 11 2013 18:45:36 GMT+0800 (CST)] [worker:21711] start listen on 1337 [Thu Apr 11 2013 18:45:36 GMT+0800 (CST)] [worker:21712] start listen on 1337 [uncaughtException] throw error 1 times diff --git a/example/express_with_cluster/app.js b/example/express_with_cluster/app.cjs similarity index 100% rename from example/express_with_cluster/app.js rename to example/express_with_cluster/app.cjs diff --git a/example/express_with_cluster/dispatch.js b/example/express_with_cluster/dispatch.cjs similarity index 96% rename from example/express_with_cluster/dispatch.js rename to example/express_with_cluster/dispatch.cjs index 37b70a0..9824bb3 100644 --- a/example/express_with_cluster/dispatch.js +++ b/example/express_with_cluster/dispatch.cjs @@ -1,11 +1,9 @@ -"use strict"; - // http://nodejs.org/docs/latest/api/domain.html#domain_warning_don_t_ignore_errors var cluster = require('cluster'); var path = require('path'); cluster.setupMaster({ - exec: path.join(__dirname, 'worker.js') + exec: path.join(__dirname, 'worker.cjs') }); // In real life, you'd probably use more than just 2 workers, diff --git a/example/express_with_cluster/worker.js b/example/express_with_cluster/worker.cjs similarity index 94% rename from example/express_with_cluster/worker.js rename to example/express_with_cluster/worker.cjs index 2a51478..64ada2d 100644 --- a/example/express_with_cluster/worker.js +++ b/example/express_with_cluster/worker.cjs @@ -1,7 +1,7 @@ "use strict"; var PORT = +process.env.PORT || 1337; -var graceful = require('../../'); +var { graceful } = require('../../'); var server = require('./app'); server.listen(PORT); console.log('[%s] [worker:%s] web server start listen on %s', new Date(), process.pid, PORT); diff --git a/example/failure.js b/example/failure.cjs similarity index 94% rename from example/failure.js rename to example/failure.cjs index 5fa4196..4ece362 100644 --- a/example/failure.js +++ b/example/failure.cjs @@ -2,7 +2,7 @@ var http = require('http'); var express = require('express'); -var graceful = require('../'); +var { graceful } = require('../'); var keepAliveClient = http.request({ host: 'www.google.com', diff --git a/index.js b/index.js deleted file mode 100644 index e9208ea..0000000 --- a/index.js +++ /dev/null @@ -1,142 +0,0 @@ -'use strict'; - -var http = require('http'); -var cluster = require('cluster'); -var ms = require('humanize-ms'); -var pstree = require('ps-tree'); - -/** - * graceful, please use with `cluster` in production env. - * - * @param {Object} options - * - {Array} servers, we need to close it and stop taking new requests. - * - {Function(err, throwErrorCount)} [error], when uncaughtException emit, error(err, count). - * You can log error here. - * - {Number} [killTimeout], worker suicide timeout, default is 30 seconds. - * - {Object} [worker], worker contains `disconnect()`. - */ -module.exports = function graceful(options) { - options = options || {}; - var killTimeout = ms(options.killTimeout || '30s'); - var onError = options.error || function () {}; - var servers = options.servers || options.server || []; - var ignoreCode = options.ignoreCode || []; - if (!Array.isArray(servers)) { - servers = [servers]; - } - if (servers.length === 0) { - throw new TypeError('options.servers required!'); - } - - var throwErrorCount = 0; - process.on('uncaughtException', function (err) { - throwErrorCount += 1; - onError(err, throwErrorCount); - console.error('[%s] [graceful:worker:%s:uncaughtException] throw error %d times', - Date(), process.pid, throwErrorCount); - console.error(err); - console.error(err.stack); - - if(ignoreCode.includes(err.code)) { - console.error('Error code: %s matches ignore list: %s, don\'t exit.', err.code, '[ ' + ignoreCode.join(', ') + ' ]'); - return; - } - - if (throwErrorCount > 1) { - return; - } - - servers.forEach(function (server) { - if (server instanceof http.Server) { - server.on('request', function (req, res) { - // Let http server set `Connection: close` header, and close the current request socket. - req.shouldKeepAlive = false; - res.shouldKeepAlive = false; - if (!res._header) { - res.setHeader('Connection', 'close'); - } - }); - } - }); - - // make sure we close down within `killTimeout` seconds - var killtimer = setTimeout(function () { - console.error('[%s] [graceful:worker:%s] kill timeout, exit now.', Date(), process.pid); - if (process.env.NODE_ENV !== 'test') { - // kill children by SIGKILL before exit - killChildren(function() { - process.exit(1); - }); - } - }, killTimeout); - console.error('[%s] [graceful:worker:%s] will exit after %dms', Date(), process.pid, killTimeout); - - // But don't keep the process open just for that! - // If there is no more io waitting, just let process exit normally. - if (typeof killtimer.unref === 'function') { - // only worked on node 0.10+ - killtimer.unref(); - } - - var worker = options.worker || cluster.worker; - - // cluster mode - if (worker) { - try { - // stop taking new requests. - // because server could already closed, need try catch the error: `Error: Not running` - for (var i = 0; i < servers.length; i++) { - var server = servers[i]; - server.close(); - console.error('[%s] [graceful:worker:%s] close server#%s, _connections: %s', - Date(), process.pid, i, server._connections); - } - console.error('[%s] [graceful:worker:%s] close %d servers!', - Date(), process.pid, servers.length); - } catch (er1) { - // Usually, this error throw cause by the active connections after the first domain error, - // oh well, not much we can do at this point. - console.error('[%s] [graceful:worker:%s] Error on server close!\n%s', - Date(), process.pid, er1.stack); - } - - try { - // Let the master know we're dead. This will trigger a - // 'disconnect' in the cluster master, and then it will fork - // a new worker. - worker.send('graceful:disconnect'); - worker.disconnect(); - console.error('[%s] [graceful:worker:%s] worker disconnect!', - Date(), process.pid); - } catch (er2) { - // Usually, this error throw cause by the active connections after the first domain error, - // oh well, not much we can do at this point. - console.error('[%s] [graceful:worker:%s] Error on worker disconnect!\n%s', - Date(), process.pid, er2.stack); - } - } - }); -}; - -function killChildren(callback) { - pstree(process.pid, function(err, children) { - if (err) { - // if get children error, just ignore it - console.error('[%s] [graceful:worker:%s] pstree find children error: %s', Date(), process.pid, err); - callback(); - return; - } - children.forEach(function(child) { - kill(parseInt(child.PID)); - }); - callback(); - }); -} - -function kill(pid) { - try { - process.kill(pid, 'SIGKILL'); - } catch (_) { - // ignore - } -} diff --git a/package.json b/package.json index 3c78687..23ba23f 100644 --- a/package.json +++ b/package.json @@ -2,24 +2,6 @@ "name": "graceful", "version": "1.1.0", "description": "Graceful exit when `uncaughtException` emit, base on `process.on('uncaughtException')`.", - "main": "index.js", - "files": [ - "index.js" - ], - "scripts": { - "test": "egg-bin test", - "ci": "egg-bin cov", - "lint": "echo 'ignore'" - }, - "dependencies": { - "humanize-ms": "^1.2.1", - "ps-tree": "^1.1.0" - }, - "devDependencies": { - "egg-bin": "^5.6.1", - "express": "^4.16.4", - "supertest": "^1.2.0" - }, "homepage": "https://github.com/node-modules/graceful", "repository": { "type": "git", @@ -34,9 +16,65 @@ "cluster", "graceful exit" ], + "author": "fengmk2 ", + "license": "MIT", "engines": { - "node": ">= 0.10.0" + "node": ">= 18.19.0" }, - "author": "fengmk2 ", - "license": "MIT" + "dependencies": { + "@fengmk2/ps-tree": "^2.0.2", + "humanize-ms": "^2.0.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/tsconfig": "1", + "@types/express": "^5.0.0", + "@types/mocha": "10", + "@types/node": "22", + "@types/supertest": "^6.0.2", + "egg-bin": "6", + "eslint": "8", + "eslint-config-egg": "14", + "express": "^4.21.2", + "mm": "^3.4.0", + "supertest": "^7.0.0", + "tshy": "3", + "tshy-after": "1", + "typescript": "5" + }, + "scripts": { + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run lint -- --fix && npm run prepublishOnly", + "test": "egg-bin test", + "preci": "npm run lint && npm run prepublishOnly && attw --pack", + "ci": "egg-bin cov", + "prepublishOnly": "tshy && tshy-after" + }, + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..75d90f1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,162 @@ +import { Server } from 'node:http'; +import cluster, { type Worker } from 'node:cluster'; +import { debuglog } from 'node:util'; +import { ms } from 'humanize-ms'; +import { pstree } from '@fengmk2/ps-tree'; + +const debug = debuglog('graceful'); + +export interface GracefulOptions { + /** + * servers, we need to close it and stop taking new requests. + */ + servers?: Server[] | Server; + /** + * @deprecated please use `servers` instead + */ + server?: Server[] | Server; + /** + * worker suicide timeout, default is 30 seconds + */ + killTimeout?: number | string; + /** + * when uncaughtException emit, error(err, count). + * You can log error here. + */ + error?: (err: Error, throwErrorCount: number) => void; + /** + * worker contains `disconnect()` + */ + worker?: Worker; + /** + * ignore error code + */ + ignoreCode?: number[]; +} + +/** + * graceful, please use with `cluster` in production env. + */ +export function graceful(options: GracefulOptions) { + const killTimeout = ms(options.killTimeout ?? '30s'); + const onError = options.error || function() {}; + let servers = options.servers ?? options.server ?? []; + const ignoreCode = options.ignoreCode ?? []; + if (!Array.isArray(servers)) { + servers = [ servers ]; + } + if (servers.length === 0) { + throw new TypeError('options.servers required!'); + } + + let throwErrorCount = 0; + process.on('uncaughtException', err => { + throwErrorCount += 1; + onError(err, throwErrorCount); + console.error('[%s] [graceful:worker:%s:uncaughtException] throw error %d times', + Date(), process.pid, throwErrorCount); + console.error(err); + console.error(err.stack); + const errorCode = Reflect.get(err, 'code'); + if (ignoreCode.includes(errorCode)) { + console.error('Error code: %s matches ignore list: %j, don\'t exit.', errorCode, ignoreCode); + return; + } + + if (throwErrorCount > 1) { + return; + } + + servers.forEach(server => { + if (server instanceof Server) { + server.on('request', (req, res) => { + // Let http server set `Connection: close` header, and close the current request socket. + // req.shouldKeepAlive = false; + Reflect.set(req, 'shouldKeepAlive', false); + res.shouldKeepAlive = false; + if (!res.headersSent) { + res.setHeader('Connection', 'close'); + } + }); + } + }); + + // make sure we close down within `killTimeout` seconds + const killTimer = setTimeout(async () => { + console.error('[%s] [graceful:worker:%s] kill timeout, exit now. NODE_ENV: %s', + Date(), process.pid, process.env.NODE_ENV); + if (process.env.NODE_ENV !== 'test') { + // kill children by SIGKILL before exit + await killChildren(); + process.exit(1); + } + }, killTimeout); + console.error('[%s] [graceful:worker:%s] will exit after %dms', + Date(), process.pid, killTimeout); + + // But don't keep the process open just for that! + // If there is no more io waiting, just let process exit normally. + killTimer.unref(); + + const worker = options.worker || cluster.worker; + + // cluster mode + if (worker) { + try { + // stop taking new requests. + // because server could already closed, need try catch the error: `Error: Not running` + for (const [ i, server ] of servers.entries()) { + server.close(); + console.error('[%s] [graceful:worker:%s] close server#%s, connections: %s', + Date(), process.pid, i, server.connections); + } + console.error('[%s] [graceful:worker:%s] close %d servers!', + Date(), process.pid, servers.length); + } catch (err: any) { + // Usually, this error throw cause by the active connections after the first domain error, + // oh well, not much we can do at this point. + console.error('[%s] [graceful:worker:%s] Error on server close!\n%s', + Date(), process.pid, err.stack); + } + + try { + // Let the master know we're dead. This will trigger a + // 'disconnect' in the cluster master, and then it will fork + // a new worker. + worker.send('graceful:disconnect'); + worker.disconnect(); + console.error('[%s] [graceful:worker:%s] worker disconnect!', + Date(), process.pid); + } catch (err: any) { + // Usually, this error throw cause by the active connections after the first domain error, + // oh well, not much we can do at this point. + console.error('[%s] [graceful:worker:%s] Error on worker disconnect!\n%s', + Date(), process.pid, err.stack); + } + } + }); +} + +async function killChildren() { + try { + const children = await pstree(process.pid); + for (const child of children) { + killProcess(parseInt(child.PID)); + } + console.error('[%s] [graceful:worker:%s] pstree find %d children and killed', + Date(), process.pid, children.length); + } catch (err) { + // if get children error, just ignore it + console.error('[%s] [graceful:worker:%s] pstree find children error: %s', + Date(), process.pid, err); + } +} + +function killProcess(pid: number) { + try { + process.kill(pid, 'SIGKILL'); + } catch (err) { + // ignore + debug('kill %s error: %s', pid, err); + } +} diff --git a/test/fixtures/app.cjs b/test/fixtures/app.cjs new file mode 100644 index 0000000..4610995 --- /dev/null +++ b/test/fixtures/app.cjs @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { fork } = require('node:child_process'); +const path = require('node:path'); +const { createServer } = require('node:http'); +const { graceful } = require('../../'); + +fork(path.join(__dirname, 'worker.cjs')); + +const server = createServer(); +server.listen(); +graceful({ + server, + killTimeout: 2000, +}); + +setTimeout(function() { + throw new Error('wow'); +}, 100); diff --git a/test/fixtures/app.js b/test/fixtures/app.js deleted file mode 100644 index ee27f42..0000000 --- a/test/fixtures/app.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -var graceful = require('../../'); -var fork = require('child_process').fork; -var path = require('path'); - -fork(path.join(__dirname, 'worker.js')); - - -var server = require('http').createServer(); -server.listen(); -graceful({ - server: server, - killTimeout: 2000, -}); - -setTimeout(function() { - throw new Error('wow'); -}, 100); diff --git a/test/fixtures/foo.js b/test/fixtures/foo.js index 65a8b38..8cc7aa3 100644 --- a/test/fixtures/foo.js +++ b/test/fixtures/foo.js @@ -1 +1 @@ -console.log('bar'); \ No newline at end of file +console.log('bar'); diff --git a/test/fixtures/ignore.cjs b/test/fixtures/ignore.cjs new file mode 100644 index 0000000..6decbad --- /dev/null +++ b/test/fixtures/ignore.cjs @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { fork } = require('node:child_process'); +const path = require('node:path'); +const { createServer } = require('node:http'); +const { graceful } = require('../../'); + +fork(path.join(__dirname, 'worker.cjs')); + +const server = createServer(); +server.listen(); +graceful({ + server, + killTimeout: 2000, + ignoreCode: [ 'EMOCKERROR' ], +}); + +setTimeout(function() { + const error = new Error('mock'); + error.code = 'EMOCKERROR'; + throw error; +}, 1000); diff --git a/test/fixtures/ignore.js b/test/fixtures/ignore.js deleted file mode 100644 index 0810adf..0000000 --- a/test/fixtures/ignore.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -var graceful = require('../../'); -var fork = require('child_process').fork; -var path = require('path'); - -fork(path.join(__dirname, 'worker.js')); - - -var server = require('http').createServer(); -server.listen(); -graceful({ - server: server, - killTimeout: 2000, - ignoreCode: ['EMOCKERROR'] -}); - -setTimeout(function () { - const error = new Error('mock'); - error.code = 'EMOCKERROR'; - throw error; -}, 1000); diff --git a/test/fixtures/worker.js b/test/fixtures/worker.cjs similarity index 94% rename from test/fixtures/worker.js rename to test/fixtures/worker.cjs index d5fef0f..1593a02 100644 --- a/test/fixtures/worker.js +++ b/test/fixtures/worker.cjs @@ -1,5 +1,3 @@ -'use strict'; - console.log('worker1 [%s] started', process.pid); setTimeout(function() { diff --git a/test/graceful.test.js b/test/graceful.test.js deleted file mode 100644 index dc10702..0000000 --- a/test/graceful.test.js +++ /dev/null @@ -1,126 +0,0 @@ -'use strict'; - -var cluster = require('cluster'); -var http = require('http'); -var request = require('supertest'); -var express = require('express'); -var graceful = require('../'); - -describe('graceful.test.js', function () { - function normalHandler(req, res, next) { - if (req.url === '/sync_error') { - throw new Error('sync_error'); - } - if (req.url === '/async_error') { - process.nextTick(function () { - ff.foo(); - }); - return; - } - if (req.url === '/async_error_twice') { - setTimeout(function () { - ff.foo(); - }, 100); - setTimeout(function () { - bar.bar(); - }, 200); - return; - } - if (req.url === '/async_error_triple') { - setTimeout(function () { - ff.foo(); - }, 100); - setTimeout(function () { - bar.bar(); - }, 200); - setTimeout(function () { - hehe.bar(); - }, 200); - return; - } - res.end(req.url); - } - - function errorHandler(err, req, res, next) { - res.statusCode = 500; - res.end(err.message); - } - - var server = http.createServer(); - graceful({ server: server, killTimeout: '1s' }); - - var app = express() - .use('/public', express.static(__dirname + '/fixtures')) - .use(normalHandler) - .use(errorHandler); - - server.on('request', app); - - it('should GET / status 200', function (done) { - request(server) - .get('/') - .expect(200, done); - }); - - it('should GET /public/foo.js status 200', function (done) { - request(server) - .get('/public/foo.js') - .expect('console.log(\'bar\');') - .expect(200, done); - }); - - it('should GET /sync_error status 500', function (done) { - request(server) - .get('/sync_error') - .expect('sync_error') - .expect(500, done); - }); - - describe.skip('hack for async error', function () { - // Because `domain` will still throw `uncaughtException`, we need to hack for `mocha` test. - // https://github.com/joyent/node/issues/4375 - // https://gist.github.com/4179636 - var mochaHandler; - before(function () { - mochaHandler = process.listeners('uncaughtException').pop(); - }); - after(function (done) { - setTimeout(function () { - // ...but be sure to re-enable mocha's error handler - process.on('uncaughtException', mochaHandler); - done(); - }, 2000); - }); - - beforeEach(function () { - cluster.worker = { - disconnect: function () {} - }; - }); - afterEach(function () { - delete cluster.worker; - }); - - it('should GET /async_error status 500', function (done) { - delete cluster.worker.disconnect; - request(server) - .get('/async_error') - .expect('ff is not defined') - .expect(500, done); - }); - - it('should GET /async_error_twice status 500', function (done) { - request(server) - .get('/async_error_twice') - .expect('ff is not defined') - .expect(500, done); - }); - - it('should GET /async_error_triple status 500', function (done) { - request(server) - .get('/async_error_triple') - .expect('ff is not defined') - .expect(500, done); - }); - }); -}); diff --git a/test/graceful.test.ts b/test/graceful.test.ts new file mode 100644 index 0000000..c69c5dd --- /dev/null +++ b/test/graceful.test.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import request from 'supertest'; +import express from 'express'; +import { graceful } from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/graceful.test.ts', () => { + function normalHandler(req: any, res: any) { + if (req.url === '/sync_error') { + throw new Error('sync_error'); + } + if (req.url === '/async_error') { + process.nextTick(function() { + // @ts-ignore + ff.foo(); + }); + return; + } + if (req.url === '/async_error_twice') { + setTimeout(function() { + // @ts-ignore + ff.foo(); + }, 100); + setTimeout(function() { + // @ts-ignore + bar.bar(); + }, 200); + return; + } + if (req.url === '/async_error_triple') { + setTimeout(function() { + // @ts-ignore + ff.foo(); + }, 100); + setTimeout(function() { + // @ts-ignore + bar.bar(); + }, 200); + setTimeout(function() { + // @ts-ignore + hehe.bar(); + }, 200); + return; + } + res.end(req.url); + } + + function errorHandler(err: any, _req: any, res: any) { + res.statusCode = 500; + res.end(err.message); + } + + const server = http.createServer(); + graceful({ server, killTimeout: '1s' }); + + const app = express() + .use('/public', express.static(__dirname + '/fixtures')) + .use(normalHandler) + .use(errorHandler); + + server.on('request', app); + + it('should GET / status 200', function(done) { + request(server) + .get('/') + .expect(200, done); + }); + + it('should GET /public/foo.js status 200', function(done) { + request(server) + .get('/public/foo.js') + .expect('console.log(\'bar\');\n') + .expect(200, done); + }); + + it('should GET /sync_error status 500', function(done) { + request(server) + .get('/sync_error') + .expect(/sync_error/) + .expect(500, done); + }); +}); diff --git a/test/ignore.test.js b/test/ignore.test.js deleted file mode 100644 index 5293f5c..0000000 --- a/test/ignore.test.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -var assert = require('assert'); -var path = require('path'); -var fork = require('child_process').fork; - - -describe('test/worker.test.js', () => { - it('should kill all children', function (done) { - var app = fork(path.join(__dirname, 'fixtures/ignore.js')); - setTimeout(function() { - assert(alive(app.pid)); - }, 1000); - - setTimeout(function () { - assert(alive(app.pid)); - done(); - }, 4000); - }); -}); - -function alive(pid) { - try { - process.kill(pid, 0); - return true; - } catch (err) { - return false; - } -} diff --git a/test/ignore.test.ts b/test/ignore.test.ts new file mode 100644 index 0000000..6cdd91d --- /dev/null +++ b/test/ignore.test.ts @@ -0,0 +1,30 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { fork } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/ignore.test.ts', () => { + it('should kill all children', function(done) { + const app = fork(path.join(__dirname, 'fixtures/ignore.cjs')); + setTimeout(function() { + assert(alive(app.pid!)); + }, 1000); + + setTimeout(function() { + assert(alive(app.pid!)); + done(); + }, 4000); + }); +}); + +function alive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return false; + } +} diff --git a/test/worker.test.js b/test/worker.test.js deleted file mode 100644 index 2c6b590..0000000 --- a/test/worker.test.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -var assert = require('assert'); -var path = require('path'); -var fork = require('child_process').fork; -var pstree = require('ps-tree'); - - -describe('test/worker.test.js', () => { - it('should kill all children', function (done) { - var app = fork(path.join(__dirname, 'fixtures/app.js')); - var workerPid; - setTimeout(function() { - assert(alive(app.pid)); - pstree(app.pid, function (_, children) { - assert(children.length === 1); - workerPid = children[0].PID; - }); - }, 1000); - - setTimeout(function () { - assert(!alive(app.pid)); - assert(!alive(workerPid)); - done(); - }, 4000); - }); -}); - -function alive(pid) { - try { - process.kill(pid, 0); - return true; - } catch (err) { - return false; - } -} diff --git a/test/worker.test.ts b/test/worker.test.ts new file mode 100644 index 0000000..1c6a509 --- /dev/null +++ b/test/worker.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { fork } from 'node:child_process'; +import { pstree } from '@fengmk2/ps-tree'; +import mm from 'mm'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/worker.test.ts', () => { + afterEach(mm.restore); + + it('should kill all children', done => { + mm(process.env, 'NODE_ENV', 'prod'); + const app = fork(path.join(__dirname, 'fixtures/app.cjs')); + let workerPid: string; + setTimeout(() => { + assert(alive(app.pid!), 'app.pid should alive'); + pstree(app.pid!, (err, children) => { + if (err) { + return done(err); + } + assert(children); + assert.equal(children.length, 1); + workerPid = children[0].PID; + }); + }, 1000); + + setTimeout(() => { + assert(!alive(app.pid!), 'app.pid should not alive'); + assert(!alive(Number(workerPid)), 'workerPid should not alive'); + done(); + }, 4000); + }); +}); + +function alive(pid: number) { + try { + process.kill(pid, 0); + console.warn('%s alive', pid); + return true; + } catch (err) { + console.error('kill %s error: %s', pid, err); + return false; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +}