From f1839db473708a9866ddc4afee148d4bb45a7194 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 7 May 2026 19:11:32 +0800 Subject: [PATCH] fix: default to 0.0.0.0 to fix WSL2 health check failure (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #470 changed the default listen host to undefined, letting Node.js bind to IPv6 :: on systems that support it. This broke WSL2 where IPv6 dual-stack is unreliable — the server binds to :: but IPv4 127.0.0.1 connections fail, causing the health check to time out. Revert to 0.0.0.0 as the default. Users who need IPv6 can set BIND_HOST=:: explicitly. Fixes #518 Co-authored-by: Claude Opus 4.7 --- .../components/hermes/chat/TerminalPanel.vue | 9 +- .../client/src/views/hermes/TerminalView.vue | 9 +- packages/server/src/config.ts | 3 +- packages/server/src/index.ts | 133 ++++++++++++++---- packages/server/src/routes/hermes/terminal.ts | 37 ++--- .../src/services/hermes/gateway-manager.ts | 17 ++- .../src/services/hermes/group-chat/index.ts | 75 +--------- packages/server/src/services/shutdown.ts | 18 ++- 8 files changed, 171 insertions(+), 130 deletions(-) diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue index 833e51a..46e9f14 100644 --- a/packages/client/src/components/hermes/chat/TerminalPanel.vue +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -121,6 +121,13 @@ const terminalBg = computed( // ─── WebSocket ────────────────────────────────────────────────── +function formatHostForPort(hostname: string, port: number): string { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return `${hostname}:${port}`; + } + return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`; +} + function buildWsUrl(): string { const token = getApiKey(); const base = getBaseUrlValue(); @@ -137,7 +144,7 @@ function buildWsUrl(): string { } const host = import.meta.env.DEV - ? `${location.hostname}:8648` + ? formatHostForPort(location.hostname, 8648) : location.host; return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`; } diff --git a/packages/client/src/views/hermes/TerminalView.vue b/packages/client/src/views/hermes/TerminalView.vue index 0a3bce2..4a51fc3 100644 --- a/packages/client/src/views/hermes/TerminalView.vue +++ b/packages/client/src/views/hermes/TerminalView.vue @@ -265,6 +265,13 @@ const terminalBg = computed( // ─── WebSocket ────────────────────────────────────────────────── +function formatHostForPort(hostname: string, port: number): string { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return `${hostname}:${port}`; + } + return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`; +} + function buildWsUrl(): string { const token = getApiKey(); const base = getBaseUrlValue(); @@ -282,7 +289,7 @@ function buildWsUrl(): string { // Dev mode: connect directly to backend port; Production: same host const host = import.meta.env.DEV - ? `${location.hostname}:8648` + ? formatHostForPort(location.hostname, 8648) : location.host; return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`; } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 7acfeaf..a39c52e 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -8,8 +8,7 @@ export function getListenHost(env: Record = process. export const config = { port: parseInt(process.env.PORT || '8648', 10), - // Leave host undefined by default so Node binds to IPv6 when available, - // falling back to IPv4 on systems without IPv6 support. + // Default undefined: listenWithFallback tries :: first, falls back to 0.0.0.0 host: getListenHost(), upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642', uploadDir: process.env.UPLOAD_DIR || resolve(homedir(), '.hermes-web-ui', 'upload'), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 10e27f1..3a2164d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -37,8 +37,84 @@ process.on('unhandledRejection', (reason) => { }) let server: any = null +let servers: any[] = [] let chatRunServer: any = null +interface ListenResult { + primary: any + servers: any[] +} + +function listen(app: Koa, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const s = app.listen(port, host) + s.once('listening', () => resolve(s)) + s.once('error', reject) + }) +} + +function probeIPv4(port: number): Promise { + return new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const req = require('http').get(`http://127.0.0.1:${port}/health`, (res: any) => { + res.resume() + resolve(true) + }) + req.once('error', () => resolve(false)) + req.setTimeout(1000, () => { + req.destroy() + resolve(false) + }) + }) +} + +/** + * Try listening on IPv6 dual-stack (::) first. If IPv4 is not reachable through + * that socket, keep IPv6 and add a separate IPv4 listener. Fall back to IPv4 + * only when IPv6 is unavailable. Skips fallback when BIND_HOST is explicitly set. + * + * On some systems (e.g. WSL2), binding to :: succeeds but the dual-stack + * doesn't actually accept IPv4 connections. We detect this by probing + * 127.0.0.1 after binding. + */ +async function listenWithFallback(app: Koa, port: number, host?: string): Promise { + // Explicit host: use it directly. + if (host) { + console.log(`[bootstrap] listening on ${host}:${port}`) + const explicit = await listen(app, port, host) + return { primary: explicit, servers: [explicit] } + } + + console.log(`[bootstrap] trying IPv6 dual-stack on ::${port}`) + try { + const s6 = await listen(app, port, '::') + if (await probeIPv4(port)) { + console.log(`[bootstrap] IPv6 dual-stack verified (IPv4 probe ok) on ::${port}`) + return { primary: s6, servers: [s6] } + } + + console.log('[bootstrap] IPv6 listener is IPv6-only, adding IPv4 listener on 0.0.0.0') + try { + const s4 = await listen(app, port, '0.0.0.0') + console.log(`[bootstrap] listening on ::${port} and 0.0.0.0:${port}`) + return { primary: s6, servers: [s6, s4] } + } catch (err) { + console.log('[bootstrap] IPv4 listener failed; keeping IPv6 listener') + logger.warn({ err }, 'Could not add IPv4 listener after IPv6-only bind') + return { primary: s6, servers: [s6] } + } + } catch (err: any) { + if (err.code !== 'EADDRNOTAVAIL' && err.code !== 'EAFNOSUPPORT' && err.code !== 'EPROTONOSUPPORT') { + throw err + } + + console.log(`[bootstrap] IPv6 not available (${err.code}), falling back to 0.0.0.0`) + const s4 = await listen(app, port, '0.0.0.0') + console.log(`[bootstrap] listening on 0.0.0.0:${port}`) + return { primary: s4, servers: [s4] } + } +} + /** * 安全获取网络接口信息(兼容 Termux/proot 环境) * 在 proot 环境中 os.networkInterfaces() 会抛出权限错误(errno 13) @@ -101,18 +177,17 @@ export async function bootstrap() { }) console.log('[bootstrap] SPA fallback registered') - // Start server - console.log(`[bootstrap] listening on ${config.host || 'default host'}:${config.port}`) - server = config.host - ? app.listen(config.port, config.host) - : app.listen(config.port) + // Start server — try IPv6 dual-stack first, fall back to IPv4 + const listenResult = await listenWithFallback(app, config.port, config.host) + server = listenResult.primary + servers = listenResult.servers console.log('[bootstrap] app.listen called') - setupTerminalWebSocket(server) + setupTerminalWebSocket(servers) console.log('[bootstrap] terminal websocket setup') // Group chat Socket.IO (must be after server is created) - const groupChatServer = new GroupChatServer(server) + const groupChatServer = new GroupChatServer(servers) setGroupChatServer(groupChatServer) groupChatServer.setGatewayManager(getGatewayManagerInstance()) @@ -129,32 +204,34 @@ export async function bootstrap() { console.log('[bootstrap] session deleter started, profile=%s', activeProfile) // Catch-all: destroy upgrade requests not handled by terminal or Socket.IO - server.on('upgrade', (req: any, socket: any) => { - const url = new URL(req.url || '', `http://${req.headers.host}`) - if (url.pathname !== '/api/hermes/terminal' && !url.pathname.startsWith('/socket.io/')) { - socket.destroy() - } + servers.forEach((httpServer) => { + httpServer.on('upgrade', (req: any, socket: any) => { + const url = new URL(req.url || '', `http://${req.headers.host}`) + if (url.pathname !== '/api/hermes/terminal' && !url.pathname.startsWith('/socket.io/')) { + socket.destroy() + } + }) }) - server.on('listening', () => { - const interfaces = safeNetworkInterfaces() - const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost' - console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) - console.log(`Upstream: ${config.upstream}`) - console.log(`Log: ~/.hermes-web-ui/logs/server.log`) - logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) - logger.info('Upstream: %s', config.upstream) + const interfaces = safeNetworkInterfaces() + const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost' + console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`) + console.log(`Upstream: ${config.upstream}`) + console.log(`Log: ~/.hermes-web-ui/logs/server.log`) + logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) + logger.info('Upstream: %s', config.upstream) - // Restore group chat agents after server is ready - groupChatServer.restoreWhenReady() + // Restore group chat agents after server is ready. + groupChatServer.restoreWhenReady() + + servers.forEach((httpServer) => { + httpServer.on('error', (err: any) => { + console.error('[bootstrap] server error:', err.code || err.message) + logger.error({ err }, 'Server error') + }) }) - server.on('error', (err: any) => { - console.error('[bootstrap] server error:', err.code || err.message) - logger.error({ err }, 'Server error') - }) - - bindShutdown(server, groupChatServer, chatRunServer) + bindShutdown(servers, groupChatServer, chatRunServer) startVersionCheck() } diff --git a/packages/server/src/routes/hermes/terminal.ts b/packages/server/src/routes/hermes/terminal.ts index 3b57cff..a566405 100644 --- a/packages/server/src/routes/hermes/terminal.ts +++ b/packages/server/src/routes/hermes/terminal.ts @@ -110,7 +110,7 @@ function createSession(shell: string): PtySession { // ─── WebSocket server setup ───────────────────────────────────── -export function setupTerminalWebSocket(httpServer: HttpServer) { +export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) { if (!pty) { logger.warn('node-pty not available, skipping terminal WebSocket setup') return @@ -118,26 +118,29 @@ export function setupTerminalWebSocket(httpServer: HttpServer) { const wss = new WebSocketServer({ noServer: true }) const defaultShell = findShell() + const servers = Array.isArray(httpServers) ? httpServers : [httpServers] - httpServer.on('upgrade', async (req, socket, head) => { - const url = new URL(req.url || '', `http://${req.headers.host}`) - if (url.pathname !== '/api/hermes/terminal') { - return - } - - // Auth check - const authToken = await getToken() - if (authToken) { - const token = url.searchParams.get('token') || '' - if (token !== authToken) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') - socket.destroy() + servers.forEach((httpServer) => { + httpServer.on('upgrade', async (req, socket, head) => { + const url = new URL(req.url || '', `http://${req.headers.host}`) + if (url.pathname !== '/api/hermes/terminal') { return } - } - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req) + // Auth check + const authToken = await getToken() + if (authToken) { + const token = url.searchParams.get('token') || '' + if (token !== authToken) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) }) }) diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index 20245ee..0f22dbb 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -120,6 +120,15 @@ interface ManagedGateway { process?: ChildProcess } +function formatHostForUrl(host: string): string { + if (host.startsWith('[') && host.endsWith(']')) return host + return host.includes(':') ? `[${host}]` : host +} + +function buildHttpUrl(host: string, port: number): string { + return `http://${formatHostForUrl(host)}:${port}` +} + // ============================ // GatewayManager // ============================ @@ -360,7 +369,7 @@ export class GatewayManager { const gw = this.gateways.get(name) if (gw?.url) return gw.url const { port, host } = this.readProfilePort(name) - return `http://${host}:${port}` + return buildHttpUrl(host, port) } /** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */ @@ -426,7 +435,7 @@ export class GatewayManager { async detectStatus(name: string): Promise { const pid = this.readPidFile(name) const { port, host } = this.readProfilePort(name) - const url = `http://${host}:${port}` + const url = buildHttpUrl(host, port) if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) { this.gateways.set(name, { pid, port, host, url }) @@ -456,7 +465,7 @@ export class GatewayManager { async start(name: string): Promise { const { port, host } = await this.resolvePort(name) const hermesHome = this.profileDir(name) - const url = `http://${host}:${port}` + const url = buildHttpUrl(host, port) if (needsRunMode) { // WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程 @@ -534,7 +543,7 @@ export class GatewayManager { const gw = this.gateways.get(name) const url = gw?.url || (() => { const { port, host } = this.readProfilePort(name) - return `http://${host}:${port}` + return buildHttpUrl(host, port) })() if (!needsRunMode) { diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index db41e94..0aa9960 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -3,7 +3,6 @@ import type { Server as HttpServer } from 'http' import { getToken } from '../../../services/auth' import { logger } from '../../../services/logger' import { getDb } from '../../../db' -import { GC_ROOMS_TABLE, GC_MESSAGES_TABLE, GC_ROOM_AGENTS_TABLE, GC_CONTEXT_SNAPSHOTS_TABLE, GC_ROOM_MEMBERS_TABLE, GC_PENDING_SESSION_DELETES_TABLE, GC_SESSION_PROFILES_TABLE } from '../../../db/hermes/schemas' import { AgentClients } from './agent-clients' import { ContextEngine } from '../context-engine/compressor' import { SessionDeleter } from '../session-deleter' @@ -39,74 +38,6 @@ interface Member { socketId: string } -// ─── SQLite Storage (global DB) ────────────────────────────── - -const GC_PENDING_SESSION_DELETES_SCHEMA: Record = { - session_id: 'TEXT PRIMARY KEY', - profile_name: 'TEXT NOT NULL', - status: "TEXT NOT NULL DEFAULT 'pending'", - attempt_count: 'INTEGER NOT NULL DEFAULT 0', - last_error: 'TEXT', - created_at: 'INTEGER NOT NULL', - updated_at: 'INTEGER NOT NULL', - next_attempt_at: 'INTEGER NOT NULL DEFAULT 0', -} - -const GC_SESSION_PROFILES_SCHEMA: Record = { - session_id: 'TEXT PRIMARY KEY', - room_id: 'TEXT NOT NULL', - agent_id: 'TEXT NOT NULL', - profile_name: 'TEXT NOT NULL', - created_at: 'INTEGER NOT NULL', -} - -const GC_ROOMS_SCHEMA: Record = { - id: 'TEXT PRIMARY KEY', - name: 'TEXT NOT NULL', - inviteCode: 'TEXT UNIQUE', - triggerTokens: 'INTEGER NOT NULL DEFAULT 100000', - maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000', - tailMessageCount: 'INTEGER NOT NULL DEFAULT 20', - totalTokens: 'INTEGER NOT NULL DEFAULT 0', -} - -const GC_MESSAGES_SCHEMA: Record = { - id: 'TEXT PRIMARY KEY', - roomId: 'TEXT NOT NULL', - senderId: 'TEXT NOT NULL', - senderName: 'TEXT NOT NULL', - content: 'TEXT NOT NULL', - timestamp: 'INTEGER NOT NULL', -} - -const GC_ROOM_AGENTS_SCHEMA: Record = { - id: 'TEXT PRIMARY KEY', - roomId: 'TEXT NOT NULL', - agentId: 'TEXT NOT NULL', - profile: 'TEXT NOT NULL', - name: 'TEXT NOT NULL', - description: "TEXT NOT NULL DEFAULT ''", - invited: 'INTEGER NOT NULL DEFAULT 0', -} - -const GC_CONTEXT_SNAPSHOTS_SCHEMA: Record = { - roomId: 'TEXT PRIMARY KEY', - summary: 'TEXT NOT NULL DEFAULT \'\'', - lastMessageId: 'TEXT NOT NULL', - lastMessageTimestamp: 'INTEGER NOT NULL', - updatedAt: 'INTEGER NOT NULL', -} - -const GC_ROOM_MEMBERS_SCHEMA: Record = { - id: 'TEXT PRIMARY KEY', - roomId: 'TEXT NOT NULL', - userId: 'TEXT NOT NULL', - userName: 'TEXT NOT NULL', - description: "TEXT NOT NULL DEFAULT ''", - joinedAt: 'INTEGER NOT NULL', - updatedAt: 'INTEGER NOT NULL', -} - let _tablesEnsured = false interface PendingSessionDelete { @@ -487,13 +418,15 @@ export class GroupChatServer { } } - constructor(httpServer: HttpServer) { + constructor(httpServers: HttpServer | HttpServer[]) { this.storage = new ChatStorage() this.storage.init() + const servers = Array.isArray(httpServers) ? httpServers : [httpServers] - this.io = new Server(httpServer, { + this.io = new Server(servers[0], { cors: { origin: '*' } }) + servers.slice(1).forEach((httpServer) => this.io.attach(httpServer)) this.nsp = this.io.of('/group-chat') this.nsp.use(this.authMiddleware.bind(this)) this.nsp.on('connection', this.onConnection.bind(this)) diff --git a/packages/server/src/services/shutdown.ts b/packages/server/src/services/shutdown.ts index 302412d..cad6dfb 100644 --- a/packages/server/src/services/shutdown.ts +++ b/packages/server/src/services/shutdown.ts @@ -8,6 +8,9 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: if (isShuttingDown) return isShuttingDown = true + // Force exit after 3s no matter what + setTimeout(() => process.exit(0), 3000) + logger.info('Shutting down (%s)...', signal) try { @@ -24,13 +27,16 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: logger.info('Socket.IO closed') } - if (server) { - await new Promise((resolve) => { - server.close(() => { - logger.info('HTTP server closed') - resolve() + const servers = Array.isArray(server) ? server : [server].filter(Boolean) + if (servers.length) { + await Promise.all(servers.map((httpServer) => ( + new Promise((resolve) => { + httpServer.close(() => { + logger.info('HTTP server closed') + resolve() + }) }) - }) + ))) } } catch (err) { logger.error(err, 'Shutdown error')