From e7a8638400f79d935b44453ed5938d7cf01a26eb Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:15:33 +0530 Subject: [PATCH] fix(cli): keep dev server alive when a restart after config change fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveConfig failures were already caught, but a failure inside startServer() itself (e.g. a theme/plugin markdown.config hook throwing during the rebuild) rejected after the old server was closed. The rejection surfaced through hotUpdate into handleHMRUpdate, which turns it into a client error event no client can receive anymore — the process then drained and exited without printing anything. Log the failure, restore the previous config, and bring a server back up so the watcher keeps running and a config fix triggers a fresh restart. This also keeps the r shortcut from dying on the same path (its action awaits restartServer with no rejection handler). server.close() can wedge on its own too: it waits for the client environment's in-flight transform requests, and those never settle once the plugin container and dep optimizer are torn down under them. The watcher, ws and http server are all closed by then, so the port is free — bound the wait, warn, and carry on. And because nothing references the event loop between the two servers, hold a handle for the length of a restart so a stall anywhere in it can never drain node into a silent exit(0). --- src/node/cli.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/node/cli.ts b/src/node/cli.ts index 621c118f..276910f6 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -14,6 +14,8 @@ import { clearCache } from './markdownToVue' import { bindShortcuts } from './shortcuts' import { logVersion } from './utils/logVersion' +const CLOSE_TIMEOUT = 10000 + const argv: any = minimist(process.argv.slice(2)) // minimist keeps `--flag=true` as the string 'true' @@ -71,15 +73,50 @@ async function runDev(root: string, argv: any) { bindShortcuts(server, restartServer) } + // vite's close waits for in-flight transform requests, which never settle + // once the plugin container and dep optimizer are torn down under them. the + // port, the watcher and the ws server are released well before that, so stop + // waiting and let the restart go on + async function closeServer() { + let timer: ReturnType | undefined + const closed = await Promise.race([ + server.close().then( + () => true, + (err: any) => { + logError(`failed to close server. error:`, err) + return true + } + ), + new Promise((resolve) => { + timer = setTimeout(resolve, CLOSE_TIMEOUT, false) + }) + ]) + clearTimeout(timer) + if (!closed) { + createLogger().warn( + c.yellow( + `server didn't close in ${CLOSE_TIMEOUT / 1000}s, restarting anyway` + ) + ) + } + } + // the config watcher and the r shortcut can both ask at once function restartServer() { - restartPromise ??= restart().finally(() => { - restartPromise = undefined - }) + if (!restartPromise) { + // between the two servers nothing references the event loop, so a stall + // anywhere in a restart would drain node into a silent exit(0) + const keepAlive = setInterval(() => {}, 1 << 30) + restartPromise = restart().finally(() => { + clearInterval(keepAlive) + restartPromise = undefined + }) + } return restartPromise } async function restart() { + const prevConfig = config try { config = await resolveConfig(root, argv) } catch (err: any) { @@ -89,10 +126,32 @@ async function runDev(root: string, argv: any) { disposeMdItInstance() clearCache() - await server.close() - await startServer() + await closeServer() + + try { + await startServer() + } catch (err: any) { + logError(`failed to restart server. error:`, err) + // the old server is already closed, so bailing out here leaves the + // session with no server and no watcher — come back up on the last + // known good config so a fix can trigger a fresh restart + config = prevConfig + // the failed attempt may have memoized a half-configured renderer + disposeMdItInstance() + clearCache() + createLogger().warn(c.yellow(`falling back to the previous config`)) + await startServer().catch( + logErrorAndExit.bind(null, `failed to restore server. error:`) + ) + } } + // a stray unhandled rejection (from a user config, a theme, or a plugin) + // must not take down a long-lived dev session + process.on('unhandledRejection', (err) => { + logError(`unhandled rejection:`, err) + }) + await startServer(false) }