-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Description
Command
serve
Is this a regression?
- Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
When running ng serve with i18n configured, every file save (.html files) triggers a rebuild that creats a new piscina ThreadPool for i18n-inline-worker.js. Old pools and their worker threads are not properly cleaned up, causing unbounded memory growth. Each pool spawns worker threads with their own V8 isolates (~120 MB each). After normal deployment activity (~100 saves), the Node.js process consumes over 6GB of memory.
Disabling i18n configuration in angular.json completely eliminates the leak as no new threads are spawned and memory remains stable across development and html file rebuilds.
In the video attached below, you can see the issue in action, each save of the .html file, creates a new thread and increases the memory used by the ng serve nodejs process
Untitled.mov
Minimal Reproduction
- Create an Angular project with i18n configured, and use the i18n directive or $localize in the template of an HTML file.
- Run
ng serve - Open the component's html file and save it repeatedly
- Monitor memory and thread count by using Activity Monitor or vmmap / footprint
- Observe memory and thread count going up with each save of the html file
Exception or Error
Your Environment
Angular CLI : 21.1.0
Angular : 21.1.0
Node.js : 24.7.0
Package Manager : npm 11.5.1
Operating System : darwin arm64
┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/aria │ 21.1.0 │ ^21.1.0 │
│ @angular/build │ 21.1.0 │ ^21.1.0 │
│ @angular/cdk │ 21.1.0 │ ^21.1.0 │
│ @angular/cli │ 21.1.0 │ ^21.1.0 │
│ @angular/common │ 21.1.0 │ ^21.1.0 │
│ @angular/compiler │ 21.1.0 │ ^21.1.0 │
│ @angular/compiler-cli │ 21.1.0 │ ^21.1.0 │
│ @angular/core │ 21.1.0 │ ^21.1.0 │
│ @angular/forms │ 21.1.0 │ ^21.1.0 │
│ @angular/localize │ 21.1.0 │ ^21.1.0 │
│ @angular/material │ 21.1.0 │ ^21.1.0 │
│ @angular/platform-browser │ 21.1.0 │ ^21.1.0 │
│ @angular/platform-server │ 21.1.0 │ ^21.1.0 │
│ @angular/router │ 21.1.0 │ ^21.1.0 │
│ @angular/ssr │ 21.1.0 │ ^21.1.0 │
│ rxjs │ 7.8.2 │ ~7.8.2 │
│ typescript │ 5.9.3 │ ~5.9.3 │
│ vitest │ 4.0.17 │ ^4.0.17 │
└───────────────────────────┴───────────────────┴───────────────────┘
Anything else relevant?
From what I've gathered, the following function creates a new ThreadPool on every rebuild
| export async function inlineI18n( |
Each call to inlineI18n, which is triggered on every rebuild via
angular-cli/packages/angular/build/src/builders/application/execute-build.ts
Lines 273 to 278 in 70a81fa
| if (i18nOptions.shouldInline) { | |
| const result = await inlineI18n(metafile, options, executionResult, initialFiles); | |
| executionResult.addErrors(result.errors); | |
| executionResult.addWarnings(result.warnings); | |
| executionResult.addPrerenderedRoutes(result.prerenderedRoutes); | |
| } else { |
creates a new i18nInliner, which creates a new WorkerPool, so each pool immediately spawns at least one OS thread with its own V8 isolate
Now I THINK (not entirely sure, but I did a little fix and it fixed the thread spawning problem) the issue is this piece of code:
angular-cli/packages/angular/build/src/builders/application/i18n.ts
Lines 73 to 165 in 70a81fa
| try { | |
| for (const locale of i18nOptions.inlineLocales) { | |
| // A locale specific set of files is returned from the inliner. | |
| const localeInlineResult = await inliner.inlineForLocale( | |
| locale, | |
| i18nOptions.locales[locale].translation, | |
| ); | |
| const localeOutputFiles = localeInlineResult.outputFiles; | |
| inlineResult.errors.push(...localeInlineResult.errors); | |
| inlineResult.warnings.push(...localeInlineResult.warnings); | |
| const { | |
| errors, | |
| warnings, | |
| additionalAssets, | |
| additionalOutputFiles, | |
| prerenderedRoutes: generatedRoutes, | |
| } = await executePostBundleSteps( | |
| metafile, | |
| { | |
| ...options, | |
| baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, | |
| }, | |
| [...unModifiedOutputFiles, ...localeOutputFiles], | |
| executionResult.assetFiles, | |
| initialFiles, | |
| locale, | |
| ); | |
| localeOutputFiles.push(...additionalOutputFiles); | |
| inlineResult.errors.push(...errors); | |
| inlineResult.warnings.push(...warnings); | |
| // Update directory with locale base or subPath | |
| const subPath = i18nOptions.locales[locale].subPath; | |
| if (i18nOptions.flatOutput !== true) { | |
| localeOutputFiles.forEach((file) => { | |
| file.path = join(subPath, file.path); | |
| }); | |
| for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) { | |
| updatedAssetFiles.push({ | |
| source: assetFile.source, | |
| destination: join(subPath, assetFile.destination), | |
| }); | |
| } | |
| } else { | |
| executionResult.assetFiles.push(...additionalAssets); | |
| } | |
| inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes }; | |
| updatedOutputFiles.push(...localeOutputFiles); | |
| } | |
| } finally { | |
| await inliner.close(); | |
| } | |
| // Update the result with all localized files. | |
| executionResult.outputFiles = [ | |
| // Root and SSR entry files are not modified. | |
| ...unModifiedOutputFiles, | |
| // Updated files for each locale. | |
| ...updatedOutputFiles, | |
| ]; | |
| // Assets are only changed if not using the flat output option | |
| if (!i18nOptions.flatOutput) { | |
| executionResult.assetFiles = updatedAssetFiles; | |
| } | |
| // Inline any template updates if present | |
| if (executionResult.templateUpdates?.size) { | |
| // The development server only allows a single locale but issue a warning if used programmatically (experimental) | |
| // with multiple locales and template HMR. | |
| if (i18nOptions.inlineLocales.size > 1) { | |
| inlineResult.warnings.push( | |
| `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, | |
| ); | |
| } | |
| const firstLocale = [...i18nOptions.inlineLocales][0]; | |
| for (const [id, content] of executionResult.templateUpdates) { | |
| const templateUpdateResult = await inliner.inlineTemplateUpdate( | |
| firstLocale, | |
| i18nOptions.locales[firstLocale].translation, | |
| content, | |
| id, | |
| ); | |
| executionResult.templateUpdates.set(id, templateUpdateResult.code); | |
| inlineResult.errors.push(...templateUpdateResult.errors); | |
| inlineResult.warnings.push(...templateUpdateResult.warnings); | |
| } | |
| } |
The if (executionResult.templateUpdates?.size) runs after the finally block which closes the newly created inliner; but inside the if block we use the closed inliner await inliner.inlineForLocale, which then uses the WorkPool instance:
angular-cli/packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Lines 228 to 231 in 70a81fa
| const { output, messages } = await this.#workerPool.run( | |
| { code: templateCode, filename: templateId, locale, translation }, | |
| { name: 'inlineCode' }, | |
| ); |
When doing a test and overriding the method run from the Workpool class, the current worker threads is 0. This causes Piscina to create a new thread (i think, i'm not really sure how Piscina works under the hood), but because this new thread is never called .destroy upon (like we do in the finally block), it becomes an orphan thread, which can't close after the idleTimeout: 4_000, because of the minThreads: 1.
Finally, moving the if block inside the try block (before closing the inliner in the finally block), fixed the issue.
I'm happy to open a PR with the change if it turns out the findings are correct (since I'm not too sure of how Piscina works under the hood fully)