Skip to content

feat: use signal-exit to handle shutdown - #23159

Draft
jamesopstad wants to merge 5 commits into
vitejs:mainfrom
jamesopstad:feat/signal-exit-shutdown
Draft

feat: use signal-exit to handle shutdown#23159
jamesopstad wants to merge 5 commits into
vitejs:mainfrom
jamesopstad:feat/signal-exit-shutdown

Conversation

@jamesopstad

Copy link
Copy Markdown
Contributor

Summary

Replaces Vite's ad-hoc signal handling (process.once('SIGTERM')) with signal-exit (https://github.com/tapjs/signal-exit) for shutdown of the dev and preview servers. signal-exit covers the full set of exit signals (SIGINT/Ctrl+C, SIGTERM, etc.) and intercepted process.exit() calls through a single, well-tested code path, and coordinates across separately-bundled copies (e.g. Vite's and Rolldown's) via a shared globalThis registry so callbacks fire exactly once. The existing stdin-end handling is retained (see below), since signal-exit does not cover it.

Stacked on #23110.

Notes

stdin end

Keeps the process.stdin.on('end') listener (with the existing CI !== 'true' guard) that signal-exit does not cover. This handles the case where a parent process shuts Vite down by closing the pipe rather than sending a signal — common when Vite runs as a child of another dev tool (e.g. Phoenix/Elixir watchers), and the source of zombie-process reports (#5743, #19091).

Added fallback for WebContainers

signal-exit works by patching process.reallyExit, which is a no-op in WebContainers (e.g. StackBlitz). Since Vite currently uses plain process.on('SIGTERM') / process.stdin.on('end') listeners (which do fire there), migrating to signal-exit would silently drop exit cleanup in WebContainers. This adds a fallback that registers a plain process.on('exit') listener when process.versions.webcontainer is set, matching Rolldown's handling (https://github.com/rolldown/rolldown/blob/main/packages/rolldown/src/utils/signal-exit.ts).

Note that exit listeners run synchronously and the process terminates as soon as they return, so async cleanup (await server.close()) may not finish on this path. This is inherent to process.on('exit') and is the same trade-off Rolldown makes. It is still strictly better than the alternative, where signal-exit is a plain no-op in WebContainers and no cleanup runs at all.

Comment on lines +1968 to +1974
// Gracefully shut down when stdin ends (e.g. a parent process closed the pipe).
// `signal-exit` does not cover this. This was originally borrowed from Rollup,
// which has since dropped it; Vite keeps it to avoid leaving zombie processes.
// See https://github.com/vitejs/vite/pull/1857.
const onStdinEnd = () => {
runExitCallbacks(null, 0)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking rollup/rollup#3493 which is linked in that PR, it seems the code was reverted in rollup/rollup#5803 so probably we should do that later.

})
})

describe('exit listener', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better to use an e2e test instead. Either by adding a new playground similarly to playground/cli or spawning node here.

@sapphi-red

Copy link
Copy Markdown
Member

Have you tested the repro in #15418?

@sapphi-red

Copy link
Copy Markdown
Member

/ecosystem-ci run

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@vitejs/plugin-legacy

pnpm add https://pkg.pr.new/@vitejs/plugin-legacy@23159 -D
npm i https://pkg.pr.new/@vitejs/plugin-legacy@23159 -D
yarn add https://pkg.pr.new/@vitejs/plugin-legacy@23159.tgz -D

vite

pnpm add https://pkg.pr.new/vite@23159 -D
npm i https://pkg.pr.new/vite@23159 -D
yarn add https://pkg.pr.new/vite@23159.tgz -D

commit: f557329

@vite-ecosystem-ci

Copy link
Copy Markdown

📝 Ran ecosystem CI on 94cf41f: Open

suite result latest scheduled
astro failure failure
qwik ⏹️ cancelled ⏹️ cancelled
sveltekit failure failure
tanstack-start success ⏹️ cancelled
react-router failure failure
vitest failure failure

nuxt, module-federation, quasar, marko, laravel, vike, storybook, analogjs, vite-plugin-react, unocss, waku, vite-plugin-vue, vuepress, vite-plugin-cloudflare, vite-setup-catalogue, vite-plugin-pwa, vite-plugin-rsc, vite-plugin-svelte, vitepress, vite-environment-examples

@jamesopstad

Copy link
Copy Markdown
Contributor Author

I've tried the prerelease and unfortunately it doesn't work correctly so I'll convert this to draft.

Here's the AI analysis:

  1. Symptom — closeServer hook doesn't run on Ctrl+C. Reproduces with plain vite dev and zero plugins.
  2. Root cause — Two separately-bundled copies of signal-exit@4.1.0 each install a process.on('SIGINT') listener:
  • Rolldown, at import time (rolldown/dist/index.mjs:9-14, setup.ts main-thread onExit() closing its trace subscriber).
  • Vite, when the dev server starts (setupExitListener → onExit(parentExitCallback), node.js:3337).
    They share the global emitter (Symbol.for("signal-exit emitter")) so callbacks are deduped, but each SignalExit keeps its own #loaded/#sigListeners and unload() only removes its own listener.
  1. Failure sequence on SIGINT (signal-exit handler, node.js:1554-1568):
  2. Rolldown's handler runs first: listeners.length(2) === count(2) → unload()s itself (count→1), emit("exit") runs Vite's parentExitCallback, which returns true and starts the async runExitCallbacks → server.close() → closeServer hook (deferred to a microtask). emit returns true, Rolldown skips re-raise.
  3. Vite's own handler still fires (never unloaded): now listeners.length(1) === count(1) → emit returns false (already emitted) → !ret → synchronous process.kill(pid, 'SIGINT'), which kills the process before the microtask runs server.close().
    Net: Vite's runExitCallbacks (node.js:3320-3327) was written to await async cleanup then re-raise — but the other copy's synchronous re-raise pre-empts it.

@jamesopstad
jamesopstad marked this pull request as draft August 5, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants