Skip to content

[Bug]: sass-embedded ChildProcess outlives server.close() - cssPlugin teardown drops worker.stop() promise #22274

Description

@jaknas

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 [email protected] 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)
node repro.mjs

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions