diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18f44467931..548a3e4d556 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: JS API Tests run: node scripts/js-api-tests.js + - name: NodeJS Unref Tests + run: node scripts/node-unref-tests.js + - name: Plugin Tests run: node scripts/plugin-tests.js diff --git a/lib/node.ts b/lib/node.ts index 1ba21ce3a10..89701e76a52 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -151,6 +151,7 @@ export let startService: typeof types.startService = common.referenceCountedServ windowsHide: true, stdio: ['pipe', 'pipe', 'inherit'], }); + let { readFromStdout, afterClose, service } = common.createChannel({ writeToStdin(bytes) { child.stdin.write(bytes); @@ -159,25 +160,59 @@ export let startService: typeof types.startService = common.referenceCountedServ isSync: false, isBrowser: false, }); - child.stdout.on('data', readFromStdout); - child.stdout.on('end', afterClose); + + const stdin: typeof child.stdin & { unref?(): void } = child.stdin; + const stdout: typeof child.stdout & { unref?(): void } = child.stdout; + + stdout.on('data', readFromStdout); + stdout.on('end', afterClose); + + let refCount = 0; + child.unref(); + if (stdin.unref) { + stdin.unref(); + } + if (stdout.unref) { + stdout.unref(); + } + + const unref = () => { + if (--refCount === 0) { + child.unref(); + } + }; + + const refPromise = (promise: Promise): Promise => { + if (++refCount === 1) { + child.ref(); + } + promise.then(unref, unref); + return promise; + } // Create an asynchronous Promise-based API return Promise.resolve({ build: (options: types.BuildOptions): Promise => - new Promise((resolve, reject) => + refPromise(new Promise((resolve, reject) => service.buildOrServe('build', null, options, isTTY(), (err, res) => - err ? reject(err) : resolve(res as types.BuildResult))), + err ? reject(err) : resolve(res as types.BuildResult)))), serve: (serveOptions, buildOptions) => { if (serveOptions === null || typeof serveOptions !== 'object') throw new Error('The first argument must be an object') - return new Promise((resolve, reject) => - service.buildOrServe('serve', serveOptions, buildOptions, isTTY(), (err, res) => - err ? reject(err) : resolve(res as types.ServeResult))) + return refPromise(new Promise((resolve, reject) => + service.buildOrServe('serve', serveOptions, buildOptions, isTTY(), (err, res) => { + if (err) { + reject(err); + } else { + refPromise((res as types.ServeResult).wait) + resolve(res as types.ServeResult); + } + }) + )) }, transform: (input, options) => { input += ''; - return new Promise((resolve, reject) => + return refPromise(new Promise((resolve, reject) => service.transform('transform', input, options || {}, isTTY(), { readFile(tempFile, callback) { try { @@ -201,7 +236,7 @@ export let startService: typeof types.startService = common.referenceCountedServ callback(null); } }, - }, (err, res) => err ? reject(err) : resolve(res!))); + }, (err, res) => err ? reject(err) : resolve(res!)))); }, stop() { child.kill(); }, }); diff --git a/scripts/node-unref-tests.js b/scripts/node-unref-tests.js new file mode 100644 index 00000000000..c68e2503c90 --- /dev/null +++ b/scripts/node-unref-tests.js @@ -0,0 +1,76 @@ +// This test verifies that: +// - a running service will not prevent NodeJS to exit if there is no compilation in progress. +// - the NodeJS process will continue running if there is a serve() active or a transform or build in progress. + +const assert = require('assert') +const { fork } = require('child_process'); + +// The tests to run in the child process +async function tests() { + const esbuild = require('./esbuild').installForTests() + + async function testTransform() { + const t1 = await esbuild.transform(`1+2`) + const t2 = await esbuild.transform(`1+3`) + assert.strictEqual(t1.code, `1 + 2;\n`) + assert.strictEqual(t2.code, `1 + 3;\n`) + } + + async function testServe() { + const server = await esbuild.serve({}, {}) + assert.strictEqual(server.host, '127.0.0.1') + assert.strictEqual(typeof server.port, 'number') + server.stop() + await server.wait + } + + const service = await esbuild.startService(); + try { + await testTransform() + await testServe() + } catch (error) { + service.stop(); + throw error; + } +} + +// Called when this is the hild process to run the tests. +function runTests() { + process.exitCode = 1; + tests().then(() => { + process.exitCode = 0; + }, (error) => { + console.error('❌', error) + }); +} + +// A child process need to be started to verify that a running service is not hanging node. +function startChildProcess() { + const child = fork(__filename, ['__forked__'], { stdio: 'inherit', env: process.env }); + + const timeout = setTimeout(()=> { + console.error('❌ node unref test timeout - child_process.unref() broken?') + process.exit(1); + }, 6000); + + child.on('error', (error) => { + console.error('❌', error); + process.exit(1); + }) + + child.on('exit', (code) => { + clearTimeout(timeout); + if (code) { + console.error('❌ node unref tests failed') + process.exit(1); + } else { + console.log(`✅ node unref tests passed`) + } + }) +} + +if (process.argv[2] === '__forked__') { + runTests(); +} else { + startChildProcess(); +} \ No newline at end of file