mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 05:30:20 +00:00
Gracefully stop desktop server on quit (#1548)
This commit is contained in:
@@ -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}...',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user