fix(cli): keep dev server alive when a restart after config change fails

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).
fix/dev-restart-resilience
Divyansh Singh 2 weeks ago
parent 392d4cebb4
commit e7a8638400

@ -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<typeof setTimeout> | undefined
const closed = await Promise.race([
server.close().then(
() => true,
(err: any) => {
logError(`failed to close server. error:`, err)
return true
}
),
new Promise<false>((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(() => {
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 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)
}

Loading…
Cancel
Save