From 73b5e6b023f1dca843c3fabfc2c602a63c3d015a Mon Sep 17 00:00:00 2001 From: jaknas <36169811+jaknas@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:43:30 +0200 Subject: [PATCH 1/2] fix(css): await sass/less/styl worker disposal on teardown The cssPlugin buildEnd hook and the scss/less/styl processor close methods were synchronous but invoked the asynchronous worker.stop() without awaiting it. server.close() could therefore resolve before the preprocessor workers had actually shut down, leaving their ChildProcess handles alive on the event loop. Make close async end-to-end, dispose the three processors in parallel via Promise.all in createPreprocessorWorkerController, and await the controller from buildEnd. --- .../plugins/cssPreprocessorTeardown.spec.ts | 51 +++++++++++++++++++ packages/vite/src/node/plugins/css.ts | 24 ++++----- 2 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts diff --git a/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts b/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts new file mode 100644 index 00000000000000..768295decd2704 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ChildProcess } from 'node:child_process' +import { describe, expect, test } from 'vitest' +import { createServer } from '../../index' + +const getActiveHandles = (): unknown[] => (process as any)._getActiveHandles() + +const runningSassWorkers = (): ChildProcess[] => + getActiveHandles().filter((h): h is ChildProcess => { + if (!h || (h as object).constructor?.name !== 'ChildProcess') return false + const cp = h as ChildProcess & { spawnfile?: string } + return ( + cp.exitCode == null && + typeof cp.spawnfile === 'string' && + cp.spawnfile.includes('sass') + ) + }) + +describe('css preprocessor worker teardown', () => { + test('awaits sass-embedded worker disposal on server.close()', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-sass-teardown-')) + const scssPath = path.join(root, 'a.scss') + fs.writeFileSync(scssPath, '$c: red;\nbody { color: $c; }\n') + + const server = await createServer({ + root, + logLevel: 'silent', + configFile: false, + server: { port: 0, ws: false }, + }) + await server.listen() + + try { + await server.pluginContainer.transform( + fs.readFileSync(scssPath, 'utf8'), + scssPath, + ) + } catch { + // the optimizer can throw ERR_OUTDATED_OPTIMIZED_DEP post-transform; + // not relevant here — we only need the scss processor to have run. + } + + expect(runningSassWorkers().length).toBeGreaterThan(0) + + await server.close() + + expect(runningSassWorkers().length).toBe(0) + }, 30_000) +}) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index c368a7c73ce3d5..f0d4604504eac4 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -331,8 +331,8 @@ export function cssPlugin(config: ResolvedConfig): Plugin { ) }, - buildEnd() { - preprocessorWorkerController?.close() + async buildEnd() { + await preprocessorWorkerController?.close() }, load: { @@ -2391,7 +2391,7 @@ type StylePreprocessor = { options: Options, resolvers: CSSAtImportResolvers, ) => StylePreprocessorResults | Promise - close: () => void + close: () => Promise } export interface StylePreprocessorResults { @@ -2612,8 +2612,8 @@ const scssProcessor = ( const normalizedErrors = new WeakSet() return { - close() { - worker?.stop() + async close() { + await worker?.stop() }, async process(environment, source, root, options, resolvers) { let sassPackage = loadSassPackage(root, failedSassEmbedded ?? false) @@ -2911,8 +2911,8 @@ const lessProcessor = ( let worker: ReturnType | undefined return { - close() { - worker?.stop() + async close() { + await worker?.stop() }, async process(environment, source, root, options, resolvers) { const lessPath = loadPreprocessorPath(PreprocessLang.less, root) @@ -3034,8 +3034,8 @@ const stylProcessor = ( let worker: ReturnType | undefined return { - close() { - worker?.stop() + async close() { + await worker?.stop() }, async process(_environment, source, root, options, _resolvers) { const stylusPath = loadPreprocessorPath(PreprocessLang.stylus, root) @@ -3150,10 +3150,8 @@ const createPreprocessorWorkerController = (maxWorkers: number | undefined) => { return scss.process(environment, source, root, opts, resolvers) } - const close = () => { - less.close() - scss.close() - styl.close() + const close = async () => { + await Promise.all([less.close(), scss.close(), styl.close()]) } return { From f745d9e8038b17852d44bb3d7551e8f1321fd4a7 Mon Sep 17 00:00:00 2001 From: jaknas <36169811+jaknas@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:05:17 +0200 Subject: [PATCH 2/2] refactor(css): widen StylePreprocessor.close to void | Promise Address review feedback: only the scss processor's close() actually needs to be async (it awaits compiler.dispose()); less and styl use real artichokie WorkerWithFallback whose stop() is sync. - Widen StylePreprocessor.close to () => void | Promise matching the pattern used elsewhere in the repo - Revert lessProcessor.close and stylProcessor.close to sync - scssProcessor.close stays async (load-bearing) - preprocessorWorkerController.close still awaits Promise.all, which transparently handles the mixed void / Promise returns Also drop unnecessary `port: 0` from the regression test. --- .../__tests__/plugins/cssPreprocessorTeardown.spec.ts | 2 +- packages/vite/src/node/plugins/css.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts b/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts index 768295decd2704..e41c31d9f0b9e8 100644 --- a/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/cssPreprocessorTeardown.spec.ts @@ -28,7 +28,7 @@ describe('css preprocessor worker teardown', () => { root, logLevel: 'silent', configFile: false, - server: { port: 0, ws: false }, + server: { ws: false }, }) await server.listen() diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index f0d4604504eac4..1fdcd2e421e932 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -2391,7 +2391,7 @@ type StylePreprocessor = { options: Options, resolvers: CSSAtImportResolvers, ) => StylePreprocessorResults | Promise - close: () => Promise + close: () => void | Promise } export interface StylePreprocessorResults { @@ -2911,8 +2911,8 @@ const lessProcessor = ( let worker: ReturnType | undefined return { - async close() { - await worker?.stop() + close() { + worker?.stop() }, async process(environment, source, root, options, resolvers) { const lessPath = loadPreprocessorPath(PreprocessLang.less, root) @@ -3034,8 +3034,8 @@ const stylProcessor = ( let worker: ReturnType | undefined return { - async close() { - await worker?.stop() + close() { + worker?.stop() }, async process(_environment, source, root, options, _resolvers) { const stylusPath = loadPreprocessorPath(PreprocessLang.stylus, root)