Skip to content

Commit 7040f36

Browse files
committed
fix(@angular/build): aggregate parallel worker performance timings on the main thread
Rather than having the parallel worker thread print its cumulative durations separately to the console (which causes console spam and disjointed/incomplete final logs), we serialize and return the worker's durations to the main thread upon completing the diagnostics task. The main thread then merges them into the global cumulative durations map, producing a single, complete, and perfectly aggregated performance report at the end of the build.
1 parent b44828c commit 7040f36

4 files changed

Lines changed: 141 additions & 3 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type * as ng from '@angular/compiler-cli';
10+
import type { PartialMessage } from 'esbuild';
11+
import type ts from 'typescript';
12+
import * as diagnosticsModule from '../../esbuild/angular/diagnostics';
13+
import * as profilingModule from '../../esbuild/profiling';
14+
import type { AngularHostOptions } from '../angular-host';
15+
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
16+
17+
/**
18+
* Minimal stub for the TypeScript module: only DiagnosticCategory is accessed in diagnoseFiles.
19+
*/
20+
const MOCK_TYPESCRIPT = {
21+
DiagnosticCategory: { Error: 1, Warning: 0, Message: 2, Suggestion: 3 },
22+
} as unknown as typeof ts;
23+
24+
/** Concrete subclass used to control collectDiagnostics behaviour in tests. */
25+
class ConcreteCompilation extends AngularCompilation {
26+
private diagnostics_: ts.Diagnostic[] = [];
27+
private throwError_: Error | undefined;
28+
29+
setDiagnostics(diagnostics: ts.Diagnostic[]): void {
30+
this.diagnostics_ = diagnostics;
31+
}
32+
33+
setThrowError(error: Error): void {
34+
this.throwError_ = error;
35+
}
36+
37+
override async initialize(
38+
_tsconfig: string,
39+
_hostOptions: AngularHostOptions,
40+
_compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
41+
): Promise<{
42+
affectedFiles: ReadonlySet<ts.SourceFile>;
43+
compilerOptions: ng.CompilerOptions;
44+
referencedFiles: readonly string[];
45+
}> {
46+
return {
47+
affectedFiles: new Set(),
48+
compilerOptions: {} as ng.CompilerOptions,
49+
referencedFiles: [],
50+
};
51+
}
52+
53+
override emitAffectedFiles(): Iterable<EmitFileResult> {
54+
return [];
55+
}
56+
57+
protected override *collectDiagnostics(_modes: DiagnosticModes): Iterable<ts.Diagnostic> {
58+
if (this.throwError_) {
59+
throw this.throwError_;
60+
}
61+
yield* this.diagnostics_;
62+
}
63+
}
64+
65+
describe('AngularCompilation.diagnoseFiles', () => {
66+
let compilation: ConcreteCompilation;
67+
let profileAsyncSpy: jasmine.Spy;
68+
69+
beforeEach(() => {
70+
compilation = new ConcreteCompilation();
71+
// Default: transparent passthrough so the real loop still runs.
72+
profileAsyncSpy = spyOn(profilingModule, 'profileAsync').and.callFake(
73+
<T>(_name: string, action: () => Promise<T>): Promise<T> => action(),
74+
);
75+
spyOn(AngularCompilation, 'loadTypescript').and.resolveTo(MOCK_TYPESCRIPT);
76+
});
77+
78+
it('runs diagnostic collection inside a profileAsync block', async () => {
79+
await compilation.diagnoseFiles();
80+
81+
expect(profileAsyncSpy).toHaveBeenCalledWith('NG_DIAGNOSTICS_TOTAL', jasmine.any(Function));
82+
});
83+
84+
it('returns correct errors and warnings', async () => {
85+
const errorDiagnostic = { category: 1 /* Error */ } as ts.Diagnostic;
86+
const warningDiagnostic = { category: 0 /* Warning */ } as ts.Diagnostic;
87+
compilation.setDiagnostics([errorDiagnostic, warningDiagnostic]);
88+
89+
spyOn(diagnosticsModule, 'convertTypeScriptDiagnostic').and.callFake(
90+
(_ts: typeof ts, diagnostic: ts.Diagnostic): PartialMessage => ({
91+
text: diagnostic.category === 1 ? 'error message' : 'warning message',
92+
}),
93+
);
94+
95+
const result = await compilation.diagnoseFiles();
96+
97+
expect(result.errors).toEqual([{ text: 'error message' }]);
98+
expect(result.warnings).toEqual([{ text: 'warning message' }]);
99+
});
100+
});

packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createRequire } from 'node:module';
1212
import { MessageChannel } from 'node:worker_threads';
1313
import type { SourceFile } from 'typescript';
1414
import { WorkerPool } from '../../../utils/worker-pool';
15+
import { mergeCumulativeDurations } from '../../esbuild/profiling';
1516
import type { AngularHostOptions } from '../angular-host';
1617
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
1718

@@ -124,10 +125,15 @@ export class ParallelCompilation extends AngularCompilation {
124125
throw new Error('Not implemented in ParallelCompilation.');
125126
}
126127

127-
override diagnoseFiles(
128+
override async diagnoseFiles(
128129
modes = DiagnosticModes.All,
129130
): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> {
130-
return this.#worker.run(modes, { name: 'diagnose' });
131+
const { timings, ...result } = await this.#worker.run(modes, { name: 'diagnose' });
132+
if (timings) {
133+
mergeCumulativeDurations(timings);
134+
}
135+
136+
return result;
131137
}
132138

133139
override emitAffectedFiles(): Promise<Iterable<EmitFileResult>> {

packages/angular/build/src/tools/angular/compilation/parallel-worker.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import assert from 'node:assert';
1111
import { randomUUID } from 'node:crypto';
1212
import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads';
1313
import { SourceFileCache } from '../../esbuild/angular/source-file-cache';
14+
import { getAndClearCumulativeDurations } from '../../esbuild/profiling';
1415
import type { AngularCompilation, DiagnosticModes } from './angular-compilation';
1516
import { AotCompilation } from './aot-compilation';
1617
import { JitCompilation } from './jit-compilation';
@@ -121,12 +122,17 @@ export async function initialize(request: InitRequest) {
121122
export async function diagnose(modes: DiagnosticModes): Promise<{
122123
errors?: PartialMessage[];
123124
warnings?: PartialMessage[];
125+
timings?: Record<string, number[]>;
124126
}> {
125127
assert(compilation);
126128

127129
const diagnostics = await compilation.diagnoseFiles(modes);
130+
const timings = getAndClearCumulativeDurations();
128131

129-
return diagnostics;
132+
return {
133+
...diagnostics,
134+
timings,
135+
};
130136
}
131137

132138
export async function emit() {

packages/angular/build/src/tools/esbuild/profiling.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ export function resetCumulativeDurations(): void {
1414
cumulativeDurations?.clear();
1515
}
1616

17+
export function getAndClearCumulativeDurations(): Record<string, number[]> | undefined {
18+
if (!cumulativeDurations || cumulativeDurations.size === 0) {
19+
return undefined;
20+
}
21+
22+
const data = Object.fromEntries(cumulativeDurations);
23+
24+
cumulativeDurations.clear();
25+
26+
return data;
27+
}
28+
29+
export function mergeCumulativeDurations(data: Record<string, number[]>): void {
30+
cumulativeDurations ??= new Map();
31+
32+
for (const [name, durations] of Object.entries(data)) {
33+
let existing = cumulativeDurations.get(name);
34+
if (!existing) {
35+
existing = [];
36+
cumulativeDurations.set(name, existing);
37+
}
38+
39+
existing.push(...durations);
40+
}
41+
}
42+
1743
export function logCumulativeDurations(): void {
1844
if (!debugPerformance || !cumulativeDurations) {
1945
return;

0 commit comments

Comments
 (0)