Skip to content

Commit 88f73eb

Browse files
committed
refactor(@angular/build): use piscina-based worker pool for Sass rendering
The multi-threaded Sass rendering system now uses a thread pool back by the `piscina` package. This package is already used in multiple locations throughout the build system. This replaces the custom worker implementation and reduces the direct code complexity as well as providing improved handling of worker turndown and cleanup.
1 parent 46e5993 commit 88f73eb

File tree

2 files changed

+111
-181
lines changed

2 files changed

+111
-181
lines changed

packages/angular/build/src/tools/sass/sass-service.ts

Lines changed: 89 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,32 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { join } from 'node:path';
9+
import assert from 'node:assert';
1010
import { fileURLToPath, pathToFileURL } from 'node:url';
11-
import { MessageChannel, Worker } from 'node:worker_threads';
12-
import {
11+
import { MessageChannel } from 'node:worker_threads';
12+
import { Piscina } from 'piscina';
13+
import type {
1314
CanonicalizeContext,
1415
CompileResult,
1516
Deprecation,
1617
Exception,
1718
FileImporter,
1819
Importer,
19-
Logger,
2020
NodePackageImporter,
2121
SourceSpan,
2222
StringOptions,
2323
} from 'sass';
2424
import { maxWorkers } from '../../utils/environment-options';
2525

26+
// Polyfill Symbol.dispose if not present
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
(Symbol as any).dispose ??= Symbol('Symbol Dispose');
29+
2630
/**
2731
* The maximum number of Workers that will be created to execute render requests.
2832
*/
2933
const MAX_RENDER_WORKERS = maxWorkers;
3034

31-
/**
32-
* The callback type for the `dart-sass` asynchronous render function.
33-
*/
34-
type RenderCallback = (error?: Exception, result?: CompileResult) => void;
35-
36-
/**
37-
* An object containing the contextual information for a specific render request.
38-
*/
39-
interface RenderRequest {
40-
id: number;
41-
workerIndex: number;
42-
callback: RenderCallback;
43-
logger?: Logger;
44-
importers?: Importers[];
45-
}
46-
4735
/**
4836
* All available importer types.
4937
*/
@@ -83,7 +71,6 @@ export type SerializableWarningMessage = (
8371
* A response from the Sass render Worker containing the result of the operation.
8472
*/
8573
interface RenderResponseMessage {
86-
id: number;
8774
error?: Exception;
8875
result?: Omit<CompileResult, 'loadedUrls'> & { loadedUrls: string[] };
8976
warnings?: SerializableWarningMessage[];
@@ -96,14 +83,25 @@ interface RenderResponseMessage {
9683
* the worker which can be up to two times faster than the asynchronous variant.
9784
*/
9885
export class SassWorkerImplementation {
99-
private readonly workers: Worker[] = [];
100-
private readonly availableWorkers: number[] = [];
101-
private readonly requests = new Map<number, RenderRequest>();
102-
private readonly workerPath = join(__dirname, './worker.js');
103-
private idCounter = 1;
104-
private nextWorkerIndex = 0;
86+
#workerPool: Piscina | undefined;
87+
88+
constructor(
89+
private readonly rebase = false,
90+
readonly maxThreads = MAX_RENDER_WORKERS,
91+
) {}
92+
93+
#ensureWorkerPool(): Piscina {
94+
this.#workerPool ??= new Piscina({
95+
filename: require.resolve('./worker'),
96+
minThreads: 1,
97+
maxThreads: this.maxThreads,
98+
// Shutdown idle threads after 1 second of inactivity
99+
idleTimeout: 1000,
100+
recordTiming: false,
101+
});
105102

106-
constructor(private rebase = false) {}
103+
return this.#workerPool;
104+
}
107105

108106
/**
109107
* Provides information about the Sass implementation.
@@ -126,136 +124,94 @@ export class SassWorkerImplementation {
126124
* @param source The contents to compile.
127125
* @param options The `dart-sass` options to use when rendering the stylesheet.
128126
*/
129-
compileStringAsync(source: string, options: StringOptions<'async'>): Promise<CompileResult> {
127+
async compileStringAsync(
128+
source: string,
129+
options: StringOptions<'async'>,
130+
): Promise<CompileResult> {
130131
// The `functions`, `logger` and `importer` options are JavaScript functions that cannot be transferred.
131132
// If any additional function options are added in the future, they must be excluded as well.
132133
const { functions, importers, url, logger, ...serializableOptions } = options;
133134

134-
// The CLI's configuration does not use or expose the ability to defined custom Sass functions
135+
// The CLI's configuration does not use or expose the ability to define custom Sass functions
135136
if (functions && Object.keys(functions).length > 0) {
136137
throw new Error('Sass custom functions are not supported.');
137138
}
138139

139-
return new Promise<CompileResult>((resolve, reject) => {
140-
let workerIndex = this.availableWorkers.pop();
141-
if (workerIndex === undefined) {
142-
if (this.workers.length < MAX_RENDER_WORKERS) {
143-
workerIndex = this.workers.length;
144-
this.workers.push(this.createWorker());
145-
} else {
146-
workerIndex = this.nextWorkerIndex++;
147-
if (this.nextWorkerIndex >= this.workers.length) {
148-
this.nextWorkerIndex = 0;
149-
}
150-
}
151-
}
152-
153-
const callback: RenderCallback = (error, result) => {
154-
if (error) {
155-
const url = error.span?.url as string | undefined;
156-
if (url) {
157-
error.span.url = pathToFileURL(url);
158-
}
159-
160-
reject(error);
161-
162-
return;
163-
}
164-
165-
if (!result) {
166-
reject(new Error('No result.'));
140+
using importerChannel = importers?.length ? this.#createImporterChannel(importers) : undefined;
167141

168-
return;
169-
}
170-
171-
resolve(result);
172-
};
173-
174-
const request = this.createRequest(workerIndex, callback, logger, importers);
175-
this.requests.set(request.id, request);
176-
177-
this.workers[workerIndex].postMessage({
178-
id: request.id,
142+
const response = (await this.#ensureWorkerPool().run(
143+
{
179144
source,
180-
hasImporter: !!importers?.length,
145+
importerChannel,
181146
hasLogger: !!logger,
182147
rebase: this.rebase,
183148
options: {
184149
...serializableOptions,
185150
// URL is not serializable so to convert to string here and back to URL in the worker.
186151
url: url ? fileURLToPath(url) : undefined,
187152
},
188-
});
189-
});
153+
},
154+
{
155+
transferList: importerChannel ? [importerChannel.port] : undefined,
156+
},
157+
)) as RenderResponseMessage;
158+
159+
const { result, error, warnings } = response;
160+
161+
if (warnings && logger?.warn) {
162+
for (const { message, span, ...options } of warnings) {
163+
logger.warn(message, {
164+
...options,
165+
span: span && {
166+
...span,
167+
url: span.url ? pathToFileURL(span.url) : undefined,
168+
},
169+
});
170+
}
171+
}
172+
173+
if (error) {
174+
// Convert stringified url value required for cloning back to a URL object
175+
const url = error.span?.url as string | undefined;
176+
if (url) {
177+
error.span.url = pathToFileURL(url);
178+
}
179+
180+
throw error;
181+
}
182+
183+
assert(result, 'Sass render worker should always return a result or an error');
184+
185+
return {
186+
...result,
187+
// URL is not serializable so in the worker we convert to string and here back to URL.
188+
loadedUrls: result.loadedUrls.map((p) => pathToFileURL(p)),
189+
};
190190
}
191191

192192
/**
193193
* Shutdown the Sass render worker.
194194
* Executing this method will stop any pending render requests.
195+
* @returns A void promise that resolves when closing is complete.
195196
*/
196-
close(): void {
197-
for (const worker of this.workers) {
197+
async close(): Promise<void> {
198+
if (this.#workerPool) {
198199
try {
199-
void worker.terminate();
200-
} catch {}
200+
await this.#workerPool.destroy();
201+
} finally {
202+
this.#workerPool = undefined;
203+
}
201204
}
202-
this.requests.clear();
203205
}
204206

205-
private createWorker(): Worker {
207+
#createImporterChannel(importers: Iterable<Importers>) {
206208
const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel();
207209
const importerSignal = new Int32Array(new SharedArrayBuffer(4));
208210

209-
const worker = new Worker(this.workerPath, {
210-
workerData: { workerImporterPort, importerSignal },
211-
transferList: [workerImporterPort],
212-
});
213-
214-
worker.on('message', (response: RenderResponseMessage) => {
215-
const request = this.requests.get(response.id);
216-
if (!request) {
217-
return;
218-
}
219-
220-
this.requests.delete(response.id);
221-
this.availableWorkers.push(request.workerIndex);
222-
223-
if (response.warnings && request.logger?.warn) {
224-
for (const { message, span, ...options } of response.warnings) {
225-
request.logger.warn(message, {
226-
...options,
227-
span: span && {
228-
...span,
229-
url: span.url ? pathToFileURL(span.url) : undefined,
230-
},
231-
});
232-
}
233-
}
234-
235-
if (response.result) {
236-
request.callback(undefined, {
237-
...response.result,
238-
// URL is not serializable so in the worker we convert to string and here back to URL.
239-
loadedUrls: response.result.loadedUrls.map((p) => pathToFileURL(p)),
240-
});
241-
} else {
242-
request.callback(response.error);
243-
}
244-
});
245-
246211
mainImporterPort.on(
247212
'message',
248-
({ id, url, options }: { id: number; url: string; options: CanonicalizeContext }) => {
249-
const request = this.requests.get(id);
250-
if (!request?.importers) {
251-
mainImporterPort.postMessage(null);
252-
Atomics.store(importerSignal, 0, 1);
253-
Atomics.notify(importerSignal, 0);
254-
255-
return;
256-
}
257-
258-
this.processImporters(request.importers, url, {
213+
({ url, options }: { url: string; options: CanonicalizeContext }) => {
214+
this.processImporters(importers, url, {
259215
...options,
260216
// URL is not serializable so in the worker we convert to string and here back to URL.
261217
containingUrl: options.containingUrl
@@ -277,7 +233,13 @@ export class SassWorkerImplementation {
277233

278234
mainImporterPort.unref();
279235

280-
return worker;
236+
return {
237+
port: workerImporterPort,
238+
signal: importerSignal,
239+
[Symbol.dispose]() {
240+
mainImporterPort.close();
241+
},
242+
};
281243
}
282244

283245
private async processImporters(
@@ -301,21 +263,6 @@ export class SassWorkerImplementation {
301263
return null;
302264
}
303265

304-
private createRequest(
305-
workerIndex: number,
306-
callback: RenderCallback,
307-
logger: Logger | undefined,
308-
importers: Importers[] | undefined,
309-
): RenderRequest {
310-
return {
311-
id: this.idCounter++,
312-
workerIndex,
313-
callback,
314-
logger,
315-
importers,
316-
};
317-
}
318-
319266
private isFileImporter(value: Importers): value is FileImporter {
320267
return 'findFileUrl' in value;
321268
}

0 commit comments

Comments
 (0)