From c0c3713afed022130d938d3a3fff7b79284d0d37 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:21:22 +0100 Subject: [PATCH 1/4] feat: add closeServer and closePreviewServer hooks --- docs/guide/api-plugin.md | 52 ++++++ .../src/node/__tests__/plugins/hooks.spec.ts | 166 ++++++++++++++++++ packages/vite/src/node/plugin.ts | 18 +- packages/vite/src/node/preview.ts | 16 ++ packages/vite/src/node/server/index.ts | 49 +++++- 5 files changed, 293 insertions(+), 8 deletions(-) diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md index 272461da446b5e..a5dbd8e9508a91 100644 --- a/docs/guide/api-plugin.md +++ b/docs/guide/api-plugin.md @@ -337,6 +337,58 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo }) ``` +### `closeServer` + +- **Type:** `(context: { reason: 'restart' | 'close' }) => void | Promise` +- **Kind:** `async`, `parallel` +- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks) + + Called when the dev server is restarted or closed, after the server has been torn down. Typically used to dispose resources created in [`configureServer`](/guide/api-plugin.html#configureserver). + + The `context.reason` distinguishes the two cases: + - `'restart'` — the server is restarting (e.g. a config file change or a call to `server.restart()`). + - `'close'` — the server is shutting down (e.g. the `q` shortcut, or a call to `server.close()`). + + ```js + const myPlugin = () => { + let resource + return { + name: 'close-server', + configureServer(server) { + resource = createResource() + }, + async closeServer({ reason }) { + if (reason === 'close') { + await resource.dispose() + } + }, + } + } + ``` + +### `closePreviewServer` + +- **Type:** `() => void | Promise` +- **Kind:** `async`, `parallel` +- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks) + + Same as [`closeServer`](/guide/api-plugin.html#closeserver) but for the preview server. The preview server never restarts, so there is no `reason`. + + ```js + const myPlugin = () => { + let resource + return { + name: 'close-preview-server', + configurePreviewServer(server) { + resource = createResource() + }, + async closePreviewServer() { + await resource.dispose() + }, + } + } + ``` + ### `transformIndexHtml` - **Type:** `IndexHtmlTransformHook | { order?: 'pre' | 'post', handler: IndexHtmlTransformHook }` diff --git a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts index 53438096c22011..790a656b195aa3 100644 --- a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts @@ -446,3 +446,169 @@ describe('watcher add/unlink error handling', () => { expect(logError).toHaveBeenCalledWith(error) }) }) + +describe('closeServer hook', () => { + test('is called with reason "close" on server.close()', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await server.close() + + expect(closeServer).toHaveBeenCalledTimes(1) + expect(closeServer).toHaveBeenCalledWith({ reason: 'close' }) + }) + + test('receives a minimal plugin context as `this`', async () => { + expect.assertions(2) + + const server = await createServerWithPlugin({ + name: 'test', + closeServer() { + expect(this).toMatchObject({ + debug: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + error: expect.any(Function), + meta: expect.any(Object), + }) + // Global hooks don't have an environment. + expect(this).not.toHaveProperty('environment') + }, + }) + + await server.close() + }) + + test('is awaited before server.close() resolves', async () => { + let hookDone = false + const server = await createServerWithPlugin({ + name: 'test', + async closeServer() { + await new Promise((r) => setTimeout(r, 10)) + hookDone = true + }, + }) + + await server.close() + + // `server.close()` does not resolve until the async hook has completed. + expect(hookDone).toBe(true) + }) + + test('runs after the server is torn down (after closeBundle)', async () => { + const order: string[] = [] + const server = await createServerWithPlugin({ + name: 'test', + closeBundle() { + order.push('closeBundle') + }, + closeServer() { + order.push('closeServer') + }, + }) + + await server.close() + + // `closeBundle` runs as part of teardown (once per environment); the + // `closeServer` hook runs afterwards, so it is the last event. + expect(order.at(-1)).toBe('closeServer') + expect(order.indexOf('closeBundle')).toBeLessThan( + order.indexOf('closeServer'), + ) + }) + + test('runs hooks in parallel', async () => { + const events: string[] = [] + const server = await createServer({ + configFile: false, + root: import.meta.dirname, + plugins: [ + { + name: 'a', + async closeServer() { + events.push('a:start') + await new Promise((r) => setTimeout(r, 20)) + events.push('a:end') + }, + }, + { + name: 'b', + async closeServer() { + events.push('b:start') + await new Promise((r) => setTimeout(r, 20)) + events.push('b:end') + }, + }, + resolveEntryPlugin, + ], + logLevel: 'error', + server: { middlewareMode: true, ws: false }, + }) + + await server.close() + + // Both hooks start before either finishes. + expect(events.slice(0, 2)).toEqual(['a:start', 'b:start']) + }) + + test('is called only once even if close() is called multiple times', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await Promise.all([server.close(), server.close()]) + await server.close() + + expect(closeServer).toHaveBeenCalledTimes(1) + }) + + test('is called with reason "restart" on server.restart()', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await server.restart() + + expect(closeServer).toHaveBeenCalledTimes(1) + expect(closeServer).toHaveBeenCalledWith({ reason: 'restart' }) + + await server.close() + }) +}) + +describe('closePreviewServer hook', () => { + test('is called on preview server.close()', async () => { + const closePreviewServer = vi.fn() + const server = await createPreviewServerWithPlugin({ + name: 'test', + closePreviewServer, + }) + + await server.close() + + expect(closePreviewServer).toHaveBeenCalledTimes(1) + }) + + test('is awaited before server.close() resolves', async () => { + let hookDone = false + const server = await createPreviewServerWithPlugin({ + name: 'test', + async closePreviewServer() { + await new Promise((r) => setTimeout(r, 10)) + hookDone = true + }, + }) + + await server.close() + + // `server.close()` does not resolve until the async hook has completed. + expect(hookDone).toBe(true) + }) +}) diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index a0be15a581123e..bed86871a58aa0 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -19,7 +19,7 @@ import type { ResolvedConfig, UserConfig, } from './config' -import type { ServerHook } from './server' +import type { CloseServerHook, ServerHook } from './server' import type { BuildAppHook } from './build' import type { IndexHtmlTransform } from './plugins/html' import type { EnvironmentModuleNode } from './server/moduleGraph' @@ -28,7 +28,7 @@ import type { HmrContext, HotUpdateOptions } from './server/hmr' import type { DevEnvironment } from './server/environment' import type { Environment } from './environment' import type { PartialEnvironment } from './baseEnvironment' -import type { PreviewServerHook } from './preview' +import type { ClosePreviewServerHook, PreviewServerHook } from './preview' import { arraify, asyncFlatten } from './utils' import type { StringFilter } from './plugins/pluginFilter' @@ -303,6 +303,20 @@ export interface Plugin extends RolldownPlugin { * applied. Hooks can be async functions and will be called in series. */ configurePreviewServer?: ObjectHook + /** + * Run logic when the server is restarted or closed. The hook receives a + * `reason` that is `'restart'` when the server is restarting and `'close'` + * when it is closing. + * + * The hooks are called after the server is torn down. Hooks can be async + * functions and will be called in parallel. + */ + closeServer?: ObjectHook + /** + * Same as `closeServer` but for the preview server. The preview server never + * restarts, so no `reason` is provided. + */ + closePreviewServer?: ObjectHook /** * Transform index.html. * The hook receives the following arguments: diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index ac34549420e126..93e1331491b561 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -121,6 +121,10 @@ export type PreviewServerHook = ( server: PreviewServer, ) => (() => void) | void | Promise<(() => void) | void> +export type ClosePreviewServerHook = ( + this: MinimalPluginContextWithoutEnvironment, +) => void | Promise + /** * Starts the Vite server in preview mode, to simulate a production deployment */ @@ -169,8 +173,20 @@ export async function preview( let closeServerPromise: Promise | undefined const closeServer = async () => { teardownSIGTERMListener(closeServerAndExit) + await closeHttpServer() server.resolvedUrls = null + + // Run `closePreviewServer` plugin hooks after the server has been torn down. + const closePreviewServerContext = new BasicMinimalPluginContext( + { ...basePluginContextMeta, watchMode: false }, + config.logger, + ) + await Promise.all( + config + .getSortedPluginHooks('closePreviewServer') + .map((hook) => hook.call(closePreviewServerContext)), + ) } const server: PreviewServer = { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 84a8d567a278eb..d3a51e9dfd5002 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -266,6 +266,20 @@ export type ServerHook = ( server: ViteDevServer, ) => (() => void) | void | Promise<(() => void) | void> +export interface CloseServerHookContext { + /** + * Whether the server is being restarted (e.g. a config change or + * `server.restart()`) or closed (e.g. the `q` shortcut, SIGTERM, stdin + * ending, or `server.close()`). + */ + reason: 'restart' | 'close' +} + +export type CloseServerHook = ( + this: MinimalPluginContextWithoutEnvironment, + context: CloseServerHookContext, +) => void | Promise + export type HttpServer = http.Server | Http2SecureServer export async function resolveForwardConsoleOptions( @@ -439,6 +453,13 @@ export interface ViteDevServer { * @internal */ _setInternalServer(server: ViteDevServer): void + /** + * Internal close implementation shared by `close()` and `restart()`. The + * `reason` is forwarded to `closeServer` plugin hooks so they can distinguish + * a restart from a real close. + * @internal + */ + _closeServer(reason: 'restart' | 'close'): Promise /** * @internal */ @@ -606,7 +627,7 @@ export async function _createServer( // Promise used by `server.close()` to ensure `closeServer()` is only called once let closeServerPromise: Promise | undefined - const closeServer = async () => { + const closeServer = async (reason: 'restart' | 'close') => { if (!middlewareMode) { teardownSIGTERMListener(closeServerAndExit) } @@ -624,6 +645,17 @@ export async function _createServer( ]) server.resolvedUrls = null server._ssrCompatModuleRunner = undefined + + // Run `closeServer` plugin hooks after the server has been torn down. + const closeServerContext = new BasicMinimalPluginContext( + { ...basePluginContextMeta, watchMode: true }, + config.logger, + ) + await Promise.all( + config + .getSortedPluginHooks('closeServer') + .map((hook) => hook.call(closeServerContext, { reason })), + ) } let hot = ws @@ -782,10 +814,7 @@ export async function _createServer( } }, async close() { - if (!closeServerPromise) { - closeServerPromise = closeServer() - } - return closeServerPromise + return server._closeServer('close') }, printUrls() { if (server.resolvedUrls) { @@ -825,6 +854,12 @@ export async function _createServer( // server instance after a restart server = _server }, + _closeServer(reason: 'restart' | 'close') { + if (!closeServerPromise) { + closeServerPromise = closeServer(reason) + } + return closeServerPromise + }, _restartPromise: options.previousRestartPromise ?? null, _forceOptimizeOnRestart: options.previousForceOptimizeOnRestart ?? false, _shortcutsState: options.previousShortcutsState, @@ -1380,7 +1415,9 @@ async function restartServer(server: ViteDevServer) { // Detach readline so close handler skips it. Reused to avoid stdin issues server._shortcutsState = undefined - await server.close() + // Close with reason 'restart' so `closeServer` hooks can distinguish a + // restart from a real close. + await server._closeServer('restart') // Assign new server props to existing server instance const middlewares = server.middlewares From 4c8102c205be939987aa69abde1cccb8cc3723df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0?= Date: Tue, 4 Aug 2026 17:00:51 +0900 Subject: [PATCH 2/4] chore: use `.toStrictEqual` --- packages/vite/src/node/__tests__/plugins/hooks.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts index 790a656b195aa3..31f5ddf12b231b 100644 --- a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts @@ -551,7 +551,7 @@ describe('closeServer hook', () => { await server.close() // Both hooks start before either finishes. - expect(events.slice(0, 2)).toEqual(['a:start', 'b:start']) + expect(events.slice(0, 2)).toStrictEqual(['a:start', 'b:start']) }) test('is called only once even if close() is called multiple times', async () => { From 7ee6910bb5fa771395a70600aaa77e56403ced54 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:11 +0100 Subject: [PATCH 3/4] feat: use signal-exit to handle graceful shutdown --- docs/guide/api-plugin.md | 2 +- packages/vite/LICENSE.md | 24 +++ packages/vite/package.json | 1 + .../vite/src/node/__tests__/utils.spec.ts | 140 +++++++++++++++++- packages/vite/src/node/preview.ts | 20 ++- packages/vite/src/node/server/index.ts | 24 ++- packages/vite/src/node/utils.ts | 91 +++++++++--- pnpm-lock.yaml | 3 + 8 files changed, 260 insertions(+), 45 deletions(-) diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md index a5dbd8e9508a91..8d54d5a4279f3e 100644 --- a/docs/guide/api-plugin.md +++ b/docs/guide/api-plugin.md @@ -347,7 +347,7 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo The `context.reason` distinguishes the two cases: - `'restart'` — the server is restarting (e.g. a config file change or a call to `server.restart()`). - - `'close'` — the server is shutting down (e.g. the `q` shortcut, or a call to `server.close()`). + - `'close'` — the server is shutting down (e.g. the `q` shortcut, `SIGTERM`, a forced exit such as `Ctrl+C`, or a call to `server.close()`). ```js const myPlugin = () => { diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index 6d24a5e9c7c10d..3cd992cd4bafef 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -2045,6 +2045,30 @@ Repository: http://github.com/ljharb/shell-quote --------------------------------------- +## signal-exit +License: ISC +By: Ben Coe +Repository: https://github.com/tapjs/signal-exit + +> The ISC License +> +> Copyright (c) 2015-2023 Benjamin Coe, Isaac Z. Schlueter, and Contributors +> +> Permission to use, copy, modify, and/or distribute this software +> for any purpose with or without fee is hereby granted, provided +> that the above copyright notice and this permission notice +> appear in all copies. +> +> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +> WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +> OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE +> LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +> OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +> WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +> ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--------------------------------------- + ## sirv License: MIT By: Luke Edwards diff --git a/packages/vite/package.json b/packages/vite/package.json index 8eb60671e6d705..4c8892aab77280 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -127,6 +127,7 @@ "rollup-plugin-license": "^3.7.1", "sass": "^1.102.0", "sass-embedded": "^1.100.0", + "signal-exit": "^4.1.0", "sirv": "^3.0.2", "strip-literal": "^4.0.0", "terser": "^5.49.0", diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index 9f845e0c210e0f..e38ae0c533d90e 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import os, { type NetworkInterfaceInfoIPv4 } from 'node:os' -import { describe, expect, test, vi, onTestFinished } from 'vitest' +import { afterEach, describe, expect, test, vi, onTestFinished } from 'vitest' import { fileURLToPath } from 'mlly' import { asyncFlatten, @@ -1245,3 +1245,141 @@ describe('resolveServerUrls', () => { expect(result.networkInterfaceNames).toStrictEqual([undefined]) }) }) + +describe('exit listener', () => { + // `setupExitListener` keeps module-level state (the registered callbacks and a + // one-shot `exiting` guard), so load a fresh module copy per test. + const loadUtils = async () => { + vi.resetModules() + return await import('../utils') + } + + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + test('runs registered callbacks and exits when stdin ends', async () => { + vi.stubEnv('CI', undefined) + const { setupExitListener } = await loadUtils() + + const stdinListeners: Array<() => void> = [] + vi.spyOn(process.stdin, 'on').mockImplementation( + (event: string | symbol, listener: any) => { + if (event === 'end') stdinListeners.push(listener) + return process.stdin + }, + ) + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as any) + + const callback = vi.fn().mockResolvedValue(undefined) + setupExitListener(callback) + + expect(stdinListeners).toHaveLength(1) + + // Simulate stdin ending. + stdinListeners[0]() + // Let the async callbacks settle. + await new Promise((r) => setTimeout(r, 0)) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(null, 0) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('does not listen to stdin in CI', async () => { + vi.stubEnv('CI', 'true') + const { setupExitListener } = await loadUtils() + + const endListeners: unknown[] = [] + vi.spyOn(process.stdin, 'on').mockImplementation( + (event: string | symbol, listener: any) => { + if (event === 'end') endListeners.push(listener) + return process.stdin + }, + ) + setupExitListener(vi.fn().mockResolvedValue(undefined)) + + expect(endListeners).toHaveLength(0) + }) + + test('removes the stdin listener when the last callback is torn down', async () => { + vi.stubEnv('CI', undefined) + const { setupExitListener, teardownExitListener } = await loadUtils() + + const removedEndListeners: unknown[] = [] + vi.spyOn(process.stdin, 'off').mockImplementation( + (event: string | symbol, listener: any) => { + if (event === 'end') removedEndListeners.push(listener) + return process.stdin + }, + ) + vi.spyOn(process.stdin, 'on').mockReturnValue(process.stdin) + + const callback = vi.fn().mockResolvedValue(undefined) + setupExitListener(callback) + teardownExitListener(callback) + + expect(removedEndListeners).toHaveLength(1) + }) + + test('runs registered callbacks only once when triggered repeatedly', async () => { + vi.stubEnv('CI', undefined) + const { setupExitListener } = await loadUtils() + + const stdinListeners: Array<() => void> = [] + vi.spyOn(process.stdin, 'on').mockImplementation( + (event: string | symbol, listener: any) => { + if (event === 'end') stdinListeners.push(listener) + return process.stdin + }, + ) + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as any) + + const callback = vi.fn().mockResolvedValue(undefined) + setupExitListener(callback) + + // Trigger the exit sequence twice; the `exiting` guard should only let it + // run once. + stdinListeners[0]() + stdinListeners[0]() + await new Promise((r) => setTimeout(r, 0)) + + expect(callback).toHaveBeenCalledTimes(1) + expect(exitSpy).toHaveBeenCalledTimes(1) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('runs all registered callbacks', async () => { + vi.stubEnv('CI', undefined) + const { setupExitListener } = await loadUtils() + + const stdinListeners: Array<() => void> = [] + vi.spyOn(process.stdin, 'on').mockImplementation( + (event: string | symbol, listener: any) => { + if (event === 'end') stdinListeners.push(listener) + return process.stdin + }, + ) + vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any) + + const callbackA = vi.fn().mockResolvedValue(undefined) + const callbackB = vi.fn().mockResolvedValue(undefined) + setupExitListener(callbackA) + setupExitListener(callbackB) + + // A single shared `end` listener is registered regardless of how many + // callbacks are added. + expect(stdinListeners).toHaveLength(1) + + stdinListeners[0]() + await new Promise((r) => setTimeout(r, 0)) + + expect(callbackA).toHaveBeenCalledWith(null, 0) + expect(callbackB).toHaveBeenCalledWith(null, 0) + }) +}) diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index 93e1331491b561..f65040d49c249d 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -30,9 +30,9 @@ import { normalizePath, resolveHostname, resolveServerUrls, - setupSIGTERMListener, + setupExitListener, shouldServeFile, - teardownSIGTERMListener, + teardownExitListener, } from './utils' import { printServerUrls } from './logger' import { bindCLIShortcuts } from './shortcuts' @@ -172,7 +172,7 @@ export async function preview( // Promise used by `server.close()` to ensure `closeServer()` is only called once let closeServerPromise: Promise | undefined const closeServer = async () => { - teardownSIGTERMListener(closeServerAndExit) + teardownExitListener(closeServerAndExit) await closeHttpServer() server.resolvedUrls = null @@ -212,16 +212,14 @@ export async function preview( }, } - const closeServerAndExit = async (_: unknown, exitCode?: number) => { - try { - await server.close() - } finally { - process.exitCode ??= exitCode ? 128 + exitCode : undefined - process.exit() - } + // Dispose the server on exit (SIGINT/Ctrl+C, SIGTERM, stdin end, etc.). The + // shared exit handler in `setupExitListener` awaits this and owns the final + // process exit, so this callback only performs cleanup. + const closeServerAndExit = async () => { + await server.close() } - setupSIGTERMListener(closeServerAndExit) + setupExitListener(closeServerAndExit) // cors const { cors } = config.preview diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index d3a51e9dfd5002..aa7ca5c2a99271 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -44,9 +44,9 @@ import { normalizePath, resolveHostname, resolveServerUrls, + setupExitListener, setupHmrWsOptionCompat, - setupSIGTERMListener, - teardownSIGTERMListener, + teardownExitListener, } from '../utils' import { ssrLoadModule } from '../ssr/ssrModuleLoader' import { ssrFixStacktrace, ssrRewriteStacktrace } from '../ssr/ssrStacktrace' @@ -269,8 +269,8 @@ export type ServerHook = ( export interface CloseServerHookContext { /** * Whether the server is being restarted (e.g. a config change or - * `server.restart()`) or closed (e.g. the `q` shortcut, SIGTERM, stdin - * ending, or `server.close()`). + * `server.restart()`) or closed (e.g. the `q` shortcut, a forced exit signal + * such as SIGINT/SIGTERM, stdin ending, or `server.close()`). */ reason: 'restart' | 'close' } @@ -629,7 +629,7 @@ export async function _createServer( let closeServerPromise: Promise | undefined const closeServer = async (reason: 'restart' | 'close') => { if (!middlewareMode) { - teardownSIGTERMListener(closeServerAndExit) + teardownExitListener(closeServerAndExit) } await Promise.allSettled([ @@ -876,17 +876,15 @@ export async function _createServer( }, }) - const closeServerAndExit = async (_: unknown, exitCode?: number) => { - try { - await server.close() - } finally { - process.exitCode ??= exitCode ? 128 + exitCode : undefined - process.exit() - } + // Dispose the server on exit (SIGINT/Ctrl+C, SIGTERM, stdin end, etc.). The + // shared exit handler in `setupExitListener` awaits this and owns the final + // process exit, so this callback only performs cleanup. + const closeServerAndExit = async () => { + await server.close() } if (!middlewareMode) { - setupSIGTERMListener(closeServerAndExit) + setupExitListener(closeServerAndExit) } const onHMRUpdate = async ( diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 779020aaa0fda9..fa6a34116e4128 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -13,6 +13,7 @@ import type { AddressInfo, Server } from 'node:net' import fsp from 'node:fs/promises' import remapping from '@jridgewell/remapping' import type { DecodedSourceMap, RawSourceMap } from '@jridgewell/remapping' +import { onExit } from 'signal-exit' import colors from 'picocolors' import type { Debugger } from 'obug' import debug from 'obug' @@ -1912,34 +1913,86 @@ export function decodeURIIfPossible(input: string): string | undefined { } } -type SigtermCallback = (signal?: 'SIGTERM', exitCode?: number) => Promise +type ExitCallback = ( + signal?: NodeJS.Signals | null, + exitCode?: number | null, +) => Promise -// Use a shared callback when attaching sigterm listeners to avoid `MaxListenersExceededWarning` -const sigtermCallbacks = new Set() -const parentSigtermCallback: SigtermCallback = async (signal, exitCode) => { - await Promise.all([...sigtermCallbacks].map((cb) => cb(signal, exitCode))) -} +// Use a shared callback so we only register a single `signal-exit` handler, +// avoiding `MaxListenersExceededWarning`. `signal-exit` coordinates across its +// own separately bundled copies (e.g. Vite's and Rolldown's) via a shared +// registry on `globalThis`, so callbacks still fire exactly once. +const exitCallbacks = new Set() + +// `signal-exit`'s `onExit` returns a function that removes the listener. +let removeExitListener: (() => void) | undefined -export const setupSIGTERMListener = ( - callback: (signal?: 'SIGTERM', exitCode?: number) => Promise, +// Guard so the exit sequence runs once, even though it can be triggered from +// multiple sources (a captured signal, stdin ending, or `process.exit()` being +// intercepted by `signal-exit`'s patched `reallyExit`). +let exiting = false + +// Run the registered callbacks, then own the final exit. When triggered by a +// signal we re-raise it (via `signal-exit` having unloaded first) to preserve +// the conventional `128 + signal number` exit code; otherwise we exit with the +// given code. +const runExitCallbacks = ( + signal: NodeJS.Signals | null, + code: number | null | undefined, ): void => { - if (sigtermCallbacks.size === 0) { - process.once('SIGTERM', parentSigtermCallback) + if (exiting) return + exiting = true + Promise.allSettled([...exitCallbacks].map((cb) => cb(signal, code))).finally( + () => { + if (signal) { + process.kill(process.pid, signal) + } else { + process.exit(code ?? undefined) + } + }, + ) +} + +// The handler passed to `signal-exit`. It must be synchronous and return +// `true` to "capture" the signal, which prevents `signal-exit` from +// synthetically re-killing the process before our async cleanup finishes. +const parentExitCallback = ( + code: number | null | undefined, + signal: NodeJS.Signals | null, +): true => { + runExitCallbacks(signal, code) + // Tell `signal-exit` we are handling this exit; it will not re-kill us. + return true +} + +// 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) +} + +export const setupExitListener = (callback: ExitCallback): void => { + if (exitCallbacks.size === 0) { + removeExitListener = onExit(parentExitCallback) + // Disabled in CI, where stdin is often already closed and would otherwise + // make the process exit immediately. + // See https://github.com/vitejs/vite/pull/3659. if (process.env.CI !== 'true') { - process.stdin.on('end', parentSigtermCallback) + process.stdin.on('end', onStdinEnd) } } - sigtermCallbacks.add(callback) + exitCallbacks.add(callback) } -export const teardownSIGTERMListener = ( - callback: Parameters[0], -): void => { - sigtermCallbacks.delete(callback) - if (sigtermCallbacks.size === 0) { - process.off('SIGTERM', parentSigtermCallback) +export const teardownExitListener = (callback: ExitCallback): void => { + exitCallbacks.delete(callback) + if (exitCallbacks.size === 0) { + removeExitListener?.() + removeExitListener = undefined if (process.env.CI !== 'true') { - process.stdin.off('end', parentSigtermCallback) + process.stdin.off('end', onStdinEnd) } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c1e7f16478c90..a0c29ca911f0eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,9 @@ importers: sass-embedded: specifier: ^1.100.0 version: 1.100.0(source-map-js@1.2.1) + signal-exit: + specifier: ^4.1.0 + version: 4.1.0 sirv: specifier: ^3.0.2 version: 3.0.2(patch_hash=c07c56eb72faea34341d465cde2314e89db472106ed378181e3447893af6bf95) From f557329fa6dcb91e5a0d99e2de558af8228e93ab Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:57:32 +0100 Subject: [PATCH 4/4] feat: fall back to exit listener in webcontainer --- packages/vite/src/node/utils.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index fa6a34116e4128..bd055f18083b13 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -13,7 +13,7 @@ import type { AddressInfo, Server } from 'node:net' import fsp from 'node:fs/promises' import remapping from '@jridgewell/remapping' import type { DecodedSourceMap, RawSourceMap } from '@jridgewell/remapping' -import { onExit } from 'signal-exit' +import { onExit as signalOnExit } from 'signal-exit' import colors from 'picocolors' import type { Debugger } from 'obug' import debug from 'obug' @@ -1924,7 +1924,22 @@ type ExitCallback = ( // registry on `globalThis`, so callbacks still fire exactly once. const exitCallbacks = new Set() -// `signal-exit`'s `onExit` returns a function that removes the listener. +// `signal-exit` works by patching `process.reallyExit`, which is a no-op in +// WebContainer (e.g. StackBlitz). Fall back to a plain `exit` listener there so +// cleanup still runs, matching Rolldown's handling. +// See https://github.com/rolldown/rolldown/blob/main/packages/rolldown/src/utils/signal-exit.ts. +const onExit = (callback: Parameters[0]): (() => void) => { + if (typeof process === 'object' && process.versions.webcontainer) { + // `exit` listeners run synchronously and the process terminates as soon as + // they return, so any async cleanup is best-effort and may not finish. + const listener = (code: number) => callback(code, null) + process.on('exit', listener) + return () => process.off('exit', listener) + } + return signalOnExit(callback) +} + +// `onExit` returns a function that removes the listener. let removeExitListener: (() => void) | undefined // Guard so the exit sequence runs once, even though it can be triggered from