Describe the bug
cssPlugin.buildEnd() and scssProcessor.close() in packages/vite/src/node/plugins/css.ts are declared synchronous but drop an async worker?.stop() promise on the scss path:
The scss worker returned by makeScssWorker is a hand-rolled object typed as a WorkerWithFallback whose stop is async stop() { await (await compilerPromise)?.dispose() }. compiler.dispose() sends the IPC shutdown to the sass-embedded Dart subprocess. Because scssProcessor.close() and cssPlugin.buildEnd are declared () => void, that promise is dropped and server.close() resolves before the ChildProcess has actually exited. The handle stays on the event loop until something else drives the IPC delivery.
In long-lived processes this self-heals. In any setup where Node is expected to exit after server.close() — scripts, programmatic use, Vitest global teardown — the orphaned ChildProcess keeps the process alive.
Different code path from #18224 (addWatchFile re-creating watchers post-close) but produces the same server.close timed out / "something prevents Vite server from exiting" symptom.
Reproduction
Inline script in "Steps to reproduce" — StackBlitz can't run sass-embedded because it ships a native Dart binary that doesn't work in WebContainers.
Steps to reproduce
mkdir repro && cd repro
npm init -y
npm i vite@8.0.9 sass-embedded
repro.mjs:
import { createServer } from 'vite'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-sass-'))
fs.writeFileSync(path.join(root, 'a.scss'), '$c: red;\nbody { color: $c; }\n')
const server = await createServer({
root,
configFile: false,
logLevel: 'warn',
server: { port: 0 },
})
await server.listen()
try {
await server.pluginContainer.transform(
fs.readFileSync(path.join(root, 'a.scss'), 'utf8'),
path.join(root, 'a.scss'),
)
} catch {}
const childProcs = () =>
process._getActiveHandles().filter(h => h?.constructor?.name === 'ChildProcess')
console.log('before:', childProcs().map(h => ({ pid: h.pid, exitCode: h.exitCode })))
await server.close()
console.log('after: ', childProcs().map(h => ({ pid: h.pid, exitCode: h.exitCode })))
process.exit(0)
Expected: after: [{ pid, exitCode: 0 }] — the sass-embedded worker has exited by the time await server.close() resolves.
Actual:
before: [{ pid: 12345, exitCode: null }]
after: [{ pid: 12345, exitCode: null }]
The Dart worker is still running after server.close() settles. Without the process.exit(0) above, node repro.mjs hangs.
System Info
Vite: 8.0.9 (also reproduced on 8.0.8 and main)
sass-embedded: 1.99.0 (latest)
Node: 22.22.1
OS: macOS arm64, Linux x64
Used Package Manager
npm
Validations
Describe the bug
cssPlugin.buildEnd()andscssProcessor.close()inpackages/vite/src/node/plugins/css.tsare declared synchronous but drop an asyncworker?.stop()promise on the scss path:cssPlugin.buildEndscssProcessor.close— the one that actually leakslessProcessor.close— same shape, butstop()is sync (realWorkerWithFallback)stylProcessor.close— same shape, butstop()is sync (realWorkerWithFallback)The scss worker returned by
makeScssWorkeris a hand-rolled object typed as aWorkerWithFallbackwhosestopisasync stop() { await (await compilerPromise)?.dispose() }.compiler.dispose()sends the IPC shutdown to the sass-embedded Dart subprocess. BecausescssProcessor.close()andcssPlugin.buildEndare declared() => void, that promise is dropped andserver.close()resolves before theChildProcesshas actually exited. The handle stays on the event loop until something else drives the IPC delivery.In long-lived processes this self-heals. In any setup where Node is expected to exit after
server.close()— scripts, programmatic use, Vitest global teardown — the orphanedChildProcesskeeps the process alive.Different code path from #18224 (
addWatchFilere-creating watchers post-close) but produces the sameserver.close timed out/ "something prevents Vite server from exiting" symptom.Reproduction
Inline script in "Steps to reproduce" — StackBlitz can't run
sass-embeddedbecause it ships a native Dart binary that doesn't work in WebContainers.Steps to reproduce
repro.mjs:Expected:
after: [{ pid, exitCode: 0 }]— the sass-embedded worker has exited by the timeawait server.close()resolves.Actual:
The Dart worker is still running after
server.close()settles. Without theprocess.exit(0)above,node repro.mjshangs.System Info
Used Package Manager
npm
Validations