Gracefully stop desktop server on quit (#1548)

This commit is contained in:
ekko
2026-06-14 14:22:45 +08:00
committed by GitHub
parent 9882804ecd
commit 69c7e291d7
5 changed files with 132 additions and 8 deletions
+10
View File
@@ -35,6 +35,7 @@ type TranslationKey =
| 'desktop.downloadGithubDetail'
| 'desktop.failedPrepareRuntime'
| 'desktop.failedStartServices'
| 'desktop.shuttingDown'
| 'runtime.checking'
| 'runtime.downloading'
| 'runtime.downloadingPackage'
@@ -79,6 +80,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'Use the release asset directly from GitHub.',
'desktop.failedPrepareRuntime': 'Failed to prepare Hermes runtime',
'desktop.failedStartServices': 'Failed to start local services',
'desktop.shuttingDown': 'Closing local services...',
'runtime.checking': 'Checking Hermes runtime...',
'runtime.downloading': 'Downloading Hermes runtime...',
'runtime.downloadingPackage': 'Downloading {name}...',
@@ -120,6 +122,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': '直接使用 GitHub Release 资源。',
'desktop.failedPrepareRuntime': '准备 Hermes 运行时失败',
'desktop.failedStartServices': '启动本地服务失败',
'desktop.shuttingDown': '正在关闭本地服务...',
'runtime.checking': '正在检查 Hermes 运行时...',
'runtime.downloading': '正在下载 Hermes 运行时...',
'runtime.downloadingPackage': '正在下载 {name}...',
@@ -161,6 +164,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': '直接使用 GitHub Release 資源。',
'desktop.failedPrepareRuntime': '準備 Hermes 運行時失敗',
'desktop.failedStartServices': '啟動本地服務失敗',
'desktop.shuttingDown': '正在關閉本地服務...',
'runtime.checking': '正在檢查 Hermes 運行時...',
'runtime.downloading': '正在下載 Hermes 運行時...',
'runtime.downloadingPackage': '正在下載 {name}...',
@@ -202,6 +206,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'GitHub のリリースアセットを直接使用します。',
'desktop.failedPrepareRuntime': 'Hermes ランタイムの準備に失敗しました',
'desktop.failedStartServices': 'ローカルサービスの起動に失敗しました',
'desktop.shuttingDown': 'ローカルサービスを終了しています...',
'runtime.checking': 'Hermes ランタイムを確認しています...',
'runtime.downloading': 'Hermes ランタイムをダウンロードしています...',
'runtime.downloadingPackage': '{name} をダウンロードしています...',
@@ -243,6 +248,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'GitHub 릴리스 자산을 직접 사용합니다.',
'desktop.failedPrepareRuntime': 'Hermes 런타임 준비 실패',
'desktop.failedStartServices': '로컬 서비스 시작 실패',
'desktop.shuttingDown': '로컬 서비스를 종료하는 중...',
'runtime.checking': 'Hermes 런타임을 확인하는 중...',
'runtime.downloading': 'Hermes 런타임을 다운로드하는 중...',
'runtime.downloadingPackage': '{name} 다운로드 중...',
@@ -284,6 +290,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'Utilise directement l asset de release GitHub.',
'desktop.failedPrepareRuntime': 'Echec de la preparation du runtime Hermes',
'desktop.failedStartServices': 'Echec du demarrage des services locaux',
'desktop.shuttingDown': 'Fermeture des services locaux...',
'runtime.checking': 'Verification du runtime Hermes...',
'runtime.downloading': 'Telechargement du runtime Hermes...',
'runtime.downloadingPackage': 'Telechargement de {name}...',
@@ -325,6 +332,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'Usa directamente el recurso de release de GitHub.',
'desktop.failedPrepareRuntime': 'No se pudo preparar el runtime de Hermes',
'desktop.failedStartServices': 'No se pudieron iniciar los servicios locales',
'desktop.shuttingDown': 'Cerrando servicios locales...',
'runtime.checking': 'Comprobando runtime de Hermes...',
'runtime.downloading': 'Descargando runtime de Hermes...',
'runtime.downloadingPackage': 'Descargando {name}...',
@@ -366,6 +374,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'Verwendet das Release-Asset direkt von GitHub.',
'desktop.failedPrepareRuntime': 'Hermes Runtime konnte nicht vorbereitet werden',
'desktop.failedStartServices': 'Lokale Dienste konnten nicht gestartet werden',
'desktop.shuttingDown': 'Lokale Dienste werden beendet...',
'runtime.checking': 'Hermes Runtime wird gepruft...',
'runtime.downloading': 'Hermes Runtime wird heruntergeladen...',
'runtime.downloadingPackage': '{name} wird heruntergeladen...',
@@ -407,6 +416,7 @@ const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
'desktop.downloadGithubDetail': 'Usa diretamente o asset de release do GitHub.',
'desktop.failedPrepareRuntime': 'Falha ao preparar o runtime Hermes',
'desktop.failedStartServices': 'Falha ao iniciar os servicos locais',
'desktop.shuttingDown': 'Encerrando servicos locais...',
'runtime.checking': 'Verificando runtime Hermes...',
'runtime.downloading': 'Baixando runtime Hermes...',
'runtime.downloadingPackage': 'Baixando {name}...',
+15
View File
@@ -282,6 +282,20 @@ function splashHtml(label = t('desktop.startingLocalServices')): string {
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
}
async function showShutdownSplash() {
if (!mainWindow || mainWindow.isDestroyed()) return
cancelWindowFade()
try {
await mainWindow.loadURL(splashHtml(t('desktop.shuttingDown')))
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.setOpacity(1)
mainWindow.show()
updateTrayMenu()
} catch {
/* best effort during app shutdown */
}
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, char => ({
'&': '&amp;',
@@ -613,6 +627,7 @@ function runDesktopApp() {
}
e.preventDefault()
cancelWindowFade()
await showShutdownSplash()
await stopWebUiServer().catch(() => undefined)
app.exit(0)
})
+36 -5
View File
@@ -25,12 +25,14 @@ const DEFAULT_PORT = 8748
const DEFAULT_READY_TIMEOUT_MS = 120_000
const DEFAULT_FULL_STARTUP_WAIT_MS = 0
const DEFAULT_STOP_TIMEOUT_MS = 20_000
const DEFAULT_GRACEFUL_STOP_TIMEOUT_MS = 18_000
const AGENT_BRIDGE_STARTED_MARKER = '[bootstrap] agent bridge started'
const AGENT_BRIDGE_FAILED_MARKER = '[bootstrap] agent bridge failed to start'
const execFileAsync = promisify(execFile)
let serverProc: ChildProcess | null = null
let cachedToken: string | null = null
let currentServerPort = DEFAULT_PORT
function killProcessTree(proc: ChildProcess): void {
if (!proc.pid || proc.killed) return
@@ -71,6 +73,10 @@ function fullStartupWaitMs(): number {
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_FULL_STARTUP_WAIT_MS
}
function gracefulStopTimeoutMs(): number {
return envPositiveInt('HERMES_DESKTOP_GRACEFUL_STOP_TIMEOUT_MS') || DEFAULT_GRACEFUL_STOP_TIMEOUT_MS
}
function timeoutAfter(ms: number, message: string): Promise<void> {
return new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms)
@@ -298,6 +304,7 @@ async function getFreeTcpPortInRange(min: number, max: number): Promise<number>
export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
ensureNativeModules()
const token = ensureToken()
currentServerPort = port
const entry = webuiServerEntry()
if (!existsSync(entry)) {
throw new Error(`Web UI server entry not found at ${entry}. Run: npm run build:webui`)
@@ -455,10 +462,26 @@ async function waitForReady(port: number, timeoutMs: number): Promise<void> {
throw new Error(`Web UI shell did not become ready within ${timeoutMs}ms`)
}
export function stopWebUiServer(): Promise<void> {
return new Promise(resolve => {
if (!serverProc || serverProc.killed) return resolve()
const proc = serverProc
async function requestGracefulShutdown(port: number, token: string): Promise<void> {
const timeoutMs = gracefulStopTimeoutMs()
const response = await fetch(`http://127.0.0.1:${port}/api/desktop/shutdown`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(timeoutMs),
})
if (!response.ok && response.status !== 202) {
throw new Error(`desktop shutdown returned HTTP ${response.status}`)
}
}
export async function stopWebUiServer(): Promise<void> {
if (!serverProc || serverProc.killed) return
const proc = serverProc
const exited = new Promise<void>(resolve => {
proc.once('exit', () => resolve())
})
const forceAfter = new Promise<void>(resolve => {
const timer = setTimeout(() => {
killProcessTree(proc)
resolve()
@@ -467,6 +490,14 @@ export function stopWebUiServer(): Promise<void> {
clearTimeout(timer)
resolve()
})
try { proc.kill('SIGTERM') } catch { resolve() }
})
try {
await requestGracefulShutdown(currentServerPort, ensureToken())
} catch (err) {
console.warn(`[webui] graceful shutdown request failed: ${err instanceof Error ? err.message : String(err)}`)
killProcessTree(proc)
}
await Promise.race([exited, forceAfter])
}
+61 -1
View File
@@ -1,4 +1,5 @@
import Koa from 'koa'
import type { Context } from 'koa'
import cors from '@koa/cors'
import bodyParser from '@koa/bodyparser'
import serve from 'koa-static'
@@ -29,6 +30,7 @@ import { logger } from './services/logger'
import { createStaticCompressionMiddleware } from './middleware/static-compression'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
import { createCorsOriginResolver, securityHeaders } from './security'
import type { ShutdownHandler } from './services/shutdown'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
declare const __APP_VERSION__: string
@@ -54,6 +56,7 @@ let server: any = null
let servers: any[] = []
let chatRunServer: any = null
let agentBridgeManager: any = null
let desktopShutdownHandler: ShutdownHandler | null = null
interface ListenResult {
primary: any
@@ -91,6 +94,61 @@ function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
function isLoopbackAddress(address?: string | null): boolean {
if (!address) return false
return address === '127.0.0.1'
|| address === '::1'
|| address === '::ffff:127.0.0.1'
|| address.startsWith('::ffff:127.')
}
function bearerToken(ctx: Context): string {
const header = ctx.get('authorization')
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function registerDesktopShutdownRoute(app: Koa): void {
app.use(async (ctx, next) => {
if (ctx.method !== 'POST' || ctx.path !== '/api/desktop/shutdown') {
await next()
return
}
if (!isDesktopRuntime()) {
ctx.status = 404
ctx.body = { error: 'not_found' }
return
}
const remoteAddress = ctx.req.socket.remoteAddress
if (!isLoopbackAddress(remoteAddress)) {
ctx.status = 403
ctx.body = { error: 'forbidden' }
return
}
const expectedToken = String(process.env.AUTH_TOKEN || '').trim()
if (!expectedToken || bearerToken(ctx) !== expectedToken) {
ctx.status = 401
ctx.body = { error: 'unauthorized' }
return
}
if (!desktopShutdownHandler) {
ctx.status = 503
ctx.body = { error: 'shutdown_not_ready' }
return
}
ctx.status = 202
ctx.body = { ok: true }
setTimeout(() => {
void desktopShutdownHandler?.('desktop-api')
}, 50).unref?.()
})
}
function envFlagEnabled(name: string): boolean {
const value = String(process.env[name] || '').trim().toLowerCase()
return ['1', 'true', 'yes', 'on'].includes(value)
@@ -232,6 +290,8 @@ export async function bootstrap() {
}))
console.log('[bootstrap] cors + bodyParser registered')
registerDesktopShutdownRoute(app)
// Register all routes (handles auth internally)
const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile])
app.use(proxyMiddleware)
@@ -314,7 +374,7 @@ export async function bootstrap() {
})
})
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
desktopShutdownHandler = bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
startVersionCheck()
}
+10 -2
View File
@@ -38,10 +38,12 @@ export function shouldStopManagedGatewaysOnShutdown(env: NodeJS.ProcessEnv = pro
return String(env.NODE_ENV || '').trim().toLowerCase() === 'production'
}
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
export type ShutdownHandler = (signal: string) => Promise<void>
export function createShutdownHandler(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): ShutdownHandler {
let isShuttingDown = false
const shutdown = async (signal: string) => {
return async (signal: string) => {
if (isShuttingDown) return
isShuttingDown = true
@@ -117,8 +119,14 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
closeDb()
process.exit(0)
}
}
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): ShutdownHandler {
const shutdown = createShutdownHandler(server, groupChatServer, chatRunServer, agentBridgeManager)
process.once('SIGUSR2', shutdown)
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
return shutdown
}