fix: default to 0.0.0.0 to fix WSL2 health check failure (#520)

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 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 19:11:32 +08:00
committed by GitHub
parent 8f6a6ec782
commit f1839db473
8 changed files with 171 additions and 130 deletions
@@ -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<GatewayStatus> {
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<GatewayStatus> {
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) {
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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))
+12 -6
View File
@@ -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<void>((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<void>((resolve) => {
httpServer.close(() => {
logger.info('HTTP server closed')
resolve()
})
})
})
)))
}
} catch (err) {
logger.error(err, 'Shutdown error')