From 2d9b1663cba9255675567c44109b12bfcb548ccf Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 28 May 2026 11:30:44 -0400 Subject: [PATCH] fix(@angular/build): prevent esbuild service child process leakage Use scoped esbuild.context() for single builds to guarantee accurate tracking and clean disposal of all internal esbuild service child processes. Additionally, wrap runEsBuildBuildAction in a try/finally block leveraging a state tracking flag to deterministically trigger context disposal for all single builds and watch initialization failure paths. This leakage primarily impacts programmatic usage and custom Architect decorators, where the hosting Node.js process remains alive after build completion. Fixes #33201 --- .../src/builders/application/build-action.ts | 105 ++++++++++-------- .../src/tools/esbuild/bundler-context.ts | 7 +- 2 files changed, 59 insertions(+), 53 deletions(-) diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index d483d32909c0..77d1f16cbd2a 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -95,62 +95,71 @@ export async function* runEsBuildBuildAction( } } - // Setup watcher if watch mode enabled let watcher: import('../../tools/esbuild/watcher').BuildWatcher | undefined; - if (watch) { - if (progress) { - logger.info('Watch mode enabled. Watching for file changes...'); - } + let watchLoopStarted = false; + try { + // Setup watcher if watch mode enabled + if (watch) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + const ignored: string[] = [ + // Ignore the output and cache paths to avoid infinite rebuild cycles + outputOptions.base, + cacheOptions.basePath, + `${toPosixPath(workspaceRoot)}/**/.*/**`, + ]; + + // Setup a watcher + const { createWatcher } = await import('../../tools/esbuild/watcher'); + watcher = createWatcher({ + polling: typeof poll === 'number', + interval: poll, + followSymlinks: preserveSymlinks, + ignored, + }); + + // Setup abort support + options.signal?.addEventListener('abort', () => void watcher?.close()); + + // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set + if (shouldWatchRoot) { + if (!preserveSymlinks) { + // Ignore all node modules directories to avoid excessive file watchers. + // Package changes are handled below by watching manifest and lock files. + // NOTE: this is not enable when preserveSymlinks is true as this would break `npm link` usages. + ignored.push('**/node_modules/**'); + + watcher.add( + packageWatchFiles + .map((file) => path.join(workspaceRoot, file)) + .filter((file) => existsSync(file)), + ); + } - const ignored: string[] = [ - // Ignore the output and cache paths to avoid infinite rebuild cycles - outputOptions.base, - cacheOptions.basePath, - `${toPosixPath(workspaceRoot)}/**/.*/**`, - ]; - - // Setup a watcher - const { createWatcher } = await import('../../tools/esbuild/watcher'); - watcher = createWatcher({ - polling: typeof poll === 'number', - interval: poll, - followSymlinks: preserveSymlinks, - ignored, - }); - - // Setup abort support - options.signal?.addEventListener('abort', () => void watcher?.close()); - - // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set - if (shouldWatchRoot) { - if (!preserveSymlinks) { - // Ignore all node modules directories to avoid excessive file watchers. - // Package changes are handled below by watching manifest and lock files. - // NOTE: this is not enable when preserveSymlinks is true as this would break `npm link` usages. - ignored.push('**/node_modules/**'); - - watcher.add( - packageWatchFiles - .map((file) => path.join(workspaceRoot, file)) - .filter((file) => existsSync(file)), - ); + watcher.add(projectRoot); } - watcher.add(projectRoot); + // Watch locations provided by the initial build result + watcher.add(result.watchFiles); } - // Watch locations provided by the initial build result - watcher.add(result.watchFiles); - } + // Output the first build results after setting up the watcher to ensure that any code executed + // higher in the iterator call stack will trigger the watcher. This is particularly relevant for + // unit tests which execute the builder and modify the file system programmatically. + yield* emitOutputResults(result, outputOptions); - // Output the first build results after setting up the watcher to ensure that any code executed - // higher in the iterator call stack will trigger the watcher. This is particularly relevant for - // unit tests which execute the builder and modify the file system programmatically. - yield* emitOutputResults(result, outputOptions); + // Finish if watch mode is not enabled + if (!watcher) { + return; + } - // Finish if watch mode is not enabled - if (!watcher) { - return; + watchLoopStarted = true; + } finally { + if (!watchLoopStarted && result) { + await result.dispose(); + } } // Used to force a full result on next rebuild if there were initial errors. diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 968815a52fd5..dd2764c8c608 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -204,14 +204,11 @@ export class BundlerContext { if (this.#esbuildContext) { // Rebuild using the existing incremental build context result = await this.#esbuildContext.rebuild(); - } else if (this.incremental) { - // Create an incremental build context and perform the first build. + } else { + // Create a build context and perform the build. // Context creation does not perform a build. this.#esbuildContext = await context(this.#esbuildOptions); result = await this.#esbuildContext.rebuild(); - } else { - // For non-incremental builds, perform a single build - result = await build(this.#esbuildOptions); } } catch (failure) { // Build failures will throw an exception which contains errors/warnings