From 392d4cebb416048a52440b872eed5ccb6c756d99 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:14:47 +0530 Subject: [PATCH 1/2] refactor(cli): reorganize command handling and the dev server lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file grew a nested if/else chain with inline command bodies, a dev path that redefined restartServer inside createDevServer on every server generation, and a module-level restartPromise shared with it. Read it top to bottom instead: argv parsing, a flat dispatch chain, then runDev() owning the dev session — its config, its current server and the restart de-duplication — with the restart sequence spelled out in order. No behavior change. --- src/node/cli.ts | 89 +++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/src/node/cli.ts b/src/node/cli.ts index 5a385b5f..621c118f 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,6 +1,6 @@ import minimist from 'minimist' import c from 'picocolors' -import { createLogger } from 'vite' +import { createLogger, type ViteDevServer } from 'vite' import { build, @@ -16,6 +16,7 @@ import { logVersion } from './utils/logVersion' const argv: any = minimist(process.argv.slice(2)) +// minimist keeps `--flag=true` as the string 'true' Object.keys(argv).forEach((key) => { if (argv[key] === 'true') { argv[key] = true @@ -24,16 +25,33 @@ Object.keys(argv).forEach((key) => { } }) +// vitepress [command] [root] const command = argv._[0] const root = argv._[command ? 1 : 0] if (root) { argv.root = root } -let restartPromise: Promise | undefined - if (!command || command === 'dev') { + runDev(root, argv).catch( + logErrorAndExit.bind(null, `failed to start server. error:`) + ) +} else if (command === 'init') { + createLogger().info('', { clear: true }) + init(argv.root) +} else if (command === 'build') { + build(root, argv).catch(logErrorAndExit.bind(null, `build error:`)) +} else if (command === 'serve' || command === 'preview') { + serve(argv).catch( + logErrorAndExit.bind(null, `failed to start server. error:`) + ) +} else { + logErrorAndExit(`unknown command "${command}".`) +} + +async function runDev(root: string, argv: any) { if (argv.force) { + // vite moved --force under optimizeDeps delete argv.force argv.optimizeDeps = { force: true } } @@ -41,48 +59,41 @@ if (!command || command === 'dev') { let config = await resolveConfig(root, argv).catch( logErrorAndExit.bind(null, `failed to resolve config. error:`) ) - const createDevServer = async (isRestart = true) => { - const server = await createServer(root, argv, restartServer, config) - function restartServer() { - if (!restartPromise) { - restartPromise = (async () => { - try { - config = await resolveConfig(root, argv) - } catch (err: any) { - logError(`failed to resolve config. error:`, err) - return - } - disposeMdItInstance() - clearCache() - await server.close() - await createDevServer() - })().finally(() => { - restartPromise = undefined - }) - } - return restartPromise - } + let server: ViteDevServer + let restartPromise: Promise | undefined + + async function startServer(isRestart = true) { + server = await createServer(root, argv, restartServer, config) + // isRestart keeps vite from reopening the browser await server.listen(undefined, isRestart) logVersion(server.config.logger) server.printUrls() bindShortcuts(server, restartServer) } - createDevServer(false).catch( - logErrorAndExit.bind(null, `failed to start server. error:`) - ) -} else if (command === 'init') { - createLogger().info('', { clear: true }) - init(argv.root) -} else { - if (command === 'build') { - build(root, argv).catch(logErrorAndExit.bind(null, `build error:`)) - } else if (command === 'serve' || command === 'preview') { - serve(argv).catch( - logErrorAndExit.bind(null, `failed to start server. error:`) - ) - } else { - logErrorAndExit(`unknown command "${command}".`) + + // the config watcher and the r shortcut can both ask at once + function restartServer() { + restartPromise ??= restart().finally(() => { + restartPromise = undefined + }) + return restartPromise + } + + async function restart() { + try { + config = await resolveConfig(root, argv) + } catch (err: any) { + logError(`failed to resolve config. error:`, err) + return + } + + disposeMdItInstance() + clearCache() + await server.close() + await startServer() } + + await startServer(false) } function logErrorAndExit(message: string, err?: any): never { 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 2/2] 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) }