Skip to content

Commit

Permalink
fix #3558: put the stop() api call back
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Dec 22, 2023
1 parent 2aa166b commit 8e1cb75
Show file tree
Hide file tree
Showing 6 changed files with 81 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
}
```

* Provide the `stop()` API in node to exit esbuild's child process ([#3558](https://github.com/evanw/esbuild/issues/3558))

You can now call `stop()` in esbuild's node API to exit esbuild's child process to reclaim the resources used. It only makes sense to do this for a long-lived node process when you know you will no longer be making any more esbuild API calls. It is not necessary to call this to allow node to exit, and it's advantageous to not call this in between calls to esbuild's API as sharing a single long-lived esbuild child process is more efficient than re-creating a new esbuild child process for every API call.

## 0.19.10

* Fix glob imports in TypeScript files ([#3319](https://github.com/evanw/esbuild/issues/3319))
Expand Down
12 changes: 12 additions & 0 deletions lib/npm/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
throw new Error(`The "analyzeMetafileSync" API only works in node`)
}

export const stop = () => {
if (stopService) stopService()
}

interface Service {
build: typeof types.build
context: typeof types.context
Expand All @@ -52,6 +56,7 @@ interface Service {
}

let initializePromise: Promise<void> | undefined
let stopService: (() => void) | undefined
let longLivedService: Service | undefined

let ensureServiceIsRunning = (): Service => {
Expand Down Expand Up @@ -129,6 +134,13 @@ const startRunningService = async (wasmURL: string | URL, wasmModule: WebAssembl
// This will throw if WebAssembly module instantiation fails
await firstMessagePromise

stopService = () => {
worker.terminate()
initializePromise = undefined
stopService = undefined
longLivedService = undefined
}

longLivedService = {
build: (options: types.BuildOptions) =>
new Promise<types.BuildResult>((resolve, reject) =>
Expand Down
23 changes: 22 additions & 1 deletion lib/npm/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ export let analyzeMetafileSync: typeof types.analyzeMetafileSync = (metafile, op
return result!
}

export const stop = () => {
if (stopService) stopService()
if (workerThreadService) workerThreadService.stop()
}

let initializeWasCalled = false

export let initialize: typeof types.initialize = options => {
Expand All @@ -243,6 +248,7 @@ interface Service {

let defaultWD = process.cwd()
let longLivedService: Service | undefined
let stopService: (() => void) | undefined

let ensureServiceIsRunning = (): Service => {
if (longLivedService) return longLivedService
Expand Down Expand Up @@ -278,6 +284,16 @@ let ensureServiceIsRunning = (): Service => {
stdout.on('data', readFromStdout)
stdout.on('end', afterClose)

stopService = () => {
// Close all resources related to the subprocess.
stdin.destroy()
stdout.destroy()
child.kill()
initializeWasCalled = false
longLivedService = undefined
stopService = undefined
}

let refCount = 0
child.unref()
if (stdin.unref) {
Expand Down Expand Up @@ -395,6 +411,7 @@ interface WorkerThreadService {
transformSync(input: string | Uint8Array, options?: types.TransformOptions): types.TransformResult
formatMessagesSync: typeof types.formatMessagesSync
analyzeMetafileSync: typeof types.analyzeMetafileSync
stop(): void
}

let workerThreadService: WorkerThreadService | null = null
Expand Down Expand Up @@ -475,7 +492,7 @@ let startWorkerThreadService = (worker_threads: typeof import('worker_threads'))
// Calling unref() on a worker will allow the thread to exit if it's the last
// only active handle in the event system. This means node will still exit
// when there are no more event handlers from the main thread. So there's no
// need to have a "stop()" function.
// need to call the "stop()" function.
worker.unref()

return {
Expand All @@ -492,6 +509,10 @@ let startWorkerThreadService = (worker_threads: typeof import('worker_threads'))
analyzeMetafileSync(metafile, options) {
return runCallSync('analyzeMetafile', [metafile, options])
},
stop() {
workerThreadService = null
worker.terminate()
},
}
}

Expand Down
13 changes: 13 additions & 0 deletions lib/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,3 +661,16 @@ export interface InitializeOptions {
}

export let version: string

// Call this function to terminate esbuild's child process. The child process
// is not terminated and re-created for each API call because it's more
// efficient to keep it around when there are multiple API calls.
//
// In node this happens automatically before the parent node process exits. So
// you only need to call this if you know you will not make any more esbuild
// API calls and you want to clean up resources.
//
// Unlike node, Deno lacks the necessary APIs to clean up child processes
// automatically. You must manually call stop() in Deno when you're done
// using esbuild or Deno will continue running forever.
export declare function stop(): void;
7 changes: 1 addition & 6 deletions scripts/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,7 @@ const buildDenoLib = async (esbuildPath) => {
fs.writeFileSync(path.join(denoDir, 'wasm.js'), modWASM)

// Generate "deno/mod.d.ts"
const types_ts = fs.readFileSync(path.join(repoDir, 'lib', 'shared', 'types.ts'), 'utf8') +
`\n// Unlike node, Deno lacks the necessary APIs to clean up child processes` +
`\n// automatically. You must manually call stop() in Deno when you're done` +
`\n// using esbuild or Deno will continue running forever.` +
`\nexport function stop(): void;` +
`\n`
const types_ts = fs.readFileSync(path.join(repoDir, 'lib', 'shared', 'types.ts'), 'utf8')
fs.writeFileSync(path.join(denoDir, 'mod.d.ts'), types_ts)
fs.writeFileSync(path.join(denoDir, 'wasm.d.ts'), types_ts)

Expand Down
32 changes: 29 additions & 3 deletions scripts/js-api-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -7038,7 +7038,7 @@ let functionScopeCases = [
}
}

let syncTests = {
let apiSyncTests = {
async defaultExport({ esbuild }) {
assert.strictEqual(typeof esbuild.version, 'string')
assert.strictEqual(esbuild.version, esbuild.default.version)
Expand Down Expand Up @@ -7356,6 +7356,26 @@ let childProcessTests = {
},
}

let syncTests = {
async startStop({ esbuild }) {
for (let i = 0; i < 3; i++) {
let result1 = await esbuild.transform('1+2')
assert.strictEqual(result1.code, '1 + 2;\n')

let result2 = esbuild.transformSync('2+3')
assert.strictEqual(result2.code, '2 + 3;\n')

let result3 = await esbuild.build({ stdin: { contents: '1+2' }, write: false })
assert.strictEqual(result3.outputFiles[0].text, '1 + 2;\n')

let result4 = esbuild.buildSync({ stdin: { contents: '2+3' }, write: false })
assert.strictEqual(result4.outputFiles[0].text, '2 + 3;\n')

esbuild.stop()
}
},
}

async function assertSourceMap(jsSourceMap, source) {
jsSourceMap = JSON.parse(jsSourceMap)
assert.deepStrictEqual(jsSourceMap.version, 3)
Expand Down Expand Up @@ -7399,11 +7419,11 @@ async function main() {
...Object.entries(transformTests),
...Object.entries(formatTests),
...Object.entries(analyzeTests),
...Object.entries(syncTests),
...Object.entries(apiSyncTests),
...Object.entries(childProcessTests),
]

const allTestsPassed = (await Promise.all(tests.map(([name, fn]) => {
let allTestsPassed = (await Promise.all(tests.map(([name, fn]) => {
const promise = runTest(name, fn)

// Time out each individual test after 3 minutes. This exists to help debug test hangs in CI.
Expand All @@ -7415,6 +7435,12 @@ async function main() {
return promise.finally(() => clearTimeout(timeout))
}))).every(success => success)

for (let [name, fn] of Object.entries(syncTests)) {
if (!await runTest(name, fn)) {
allTestsPassed = false
}
}

if (!allTestsPassed) {
console.error(`❌ js api tests failed`)
process.exit(1)
Expand Down

0 comments on commit 8e1cb75

Please sign in to comment.