Skip to content

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

@jaknas

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 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)
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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions