From 69c7e291d799d45893dc71f519ce80dc8d4e85e2 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:22:45 +0800 Subject: [PATCH] Gracefully stop desktop server on quit (#1548) --- packages/desktop/src/main/desktop-i18n.ts | 10 ++++ packages/desktop/src/main/index.ts | 15 ++++++ packages/desktop/src/main/webui-server.ts | 41 +++++++++++++-- packages/server/src/index.ts | 62 ++++++++++++++++++++++- packages/server/src/services/shutdown.ts | 12 ++++- 5 files changed, 132 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src/main/desktop-i18n.ts b/packages/desktop/src/main/desktop-i18n.ts index cfd361cd..8f2fe1d6 100644 --- a/packages/desktop/src/main/desktop-i18n.ts +++ b/packages/desktop/src/main/desktop-i18n.ts @@ -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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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}...', diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 6c0738bc..988ad697 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -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 => ({ '&': '&', @@ -613,6 +627,7 @@ function runDesktopApp() { } e.preventDefault() cancelWindowFade() + await showShutdownSplash() await stopWebUiServer().catch(() => undefined) app.exit(0) }) diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 88e2d885..9a858ebe 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -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 { return new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error(message)), ms) @@ -298,6 +304,7 @@ async function getFreeTcpPortInRange(min: number, max: number): Promise export async function startWebUiServer(port = DEFAULT_PORT): Promise { 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 { throw new Error(`Web UI shell did not become ready within ${timeoutMs}ms`) } -export function stopWebUiServer(): Promise { - return new Promise(resolve => { - if (!serverProc || serverProc.killed) return resolve() - const proc = serverProc +async function requestGracefulShutdown(port: number, token: string): Promise { + 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 { + if (!serverProc || serverProc.killed) return + + const proc = serverProc + const exited = new Promise(resolve => { + proc.once('exit', () => resolve()) + }) + const forceAfter = new Promise(resolve => { const timer = setTimeout(() => { killProcessTree(proc) resolve() @@ -467,6 +490,14 @@ export function stopWebUiServer(): Promise { 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]) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 21581c76..c506f93d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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() } diff --git a/packages/server/src/services/shutdown.ts b/packages/server/src/services/shutdown.ts index 9710319f..cd187360 100644 --- a/packages/server/src/services/shutdown.ts +++ b/packages/server/src/services/shutdown.ts @@ -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 + +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 }