From 286a4695d52f6c76548a59b870b3a2d54049623c Mon Sep 17 00:00:00 2001 From: AikoBox Agent Date: Tue, 14 Jul 2026 08:58:56 +0800 Subject: [PATCH] fix: surface clear port-conflict UX when mixed-port bind fails Map Windows/POSIX EADDRINUSE-style listen errors to a bilingual Chinese+English message in the core startup path (log + reject), guiding users to free the port or switch mixed-port to 17890+. --- src/main/core/listenError.test.ts | 50 +++++++++++++++++++++++++++++++ src/main/core/listenError.ts | 34 +++++++++++++++++++++ src/main/core/manager.ts | 31 +++++++++++++++---- 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/main/core/listenError.test.ts create mode 100644 src/main/core/listenError.ts diff --git a/src/main/core/listenError.test.ts b/src/main/core/listenError.test.ts new file mode 100644 index 0000000..d120579 --- /dev/null +++ b/src/main/core/listenError.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + formatPortConflictUserMessage, + isPortInUseListenError, + mapCoreListenError +} from './listenError' + +describe('listenError', () => { + it('detects Windows, POSIX, and Node-style port-in-use errors', () => { + expect( + isPortInUseListenError( + 'listen tcp 127.0.0.1:7890: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.' + ) + ).toBe(true) + expect(isPortInUseListenError('listen tcp 127.0.0.1:7890: bind: address already in use')).toBe( + true + ) + expect(isPortInUseListenError('Error: listen EADDRINUSE: address already in use :::7890')).toBe( + true + ) + expect( + isPortInUseListenError('FATAL[0000] configure tun interface: operation not permitted') + ).toBe(false) + }) + + it('maps port-in-use errors to bilingual guidance mentioning Bettbox and 17890+', () => { + const mapped = mapCoreListenError( + 'listen tcp 127.0.0.1:7890: bind: Only one usage of each socket address is normally permitted.', + 7890 + ) + expect(mapped).toBeTruthy() + expect(mapped).toContain('混合端口 7890被占用') + expect(mapped).toContain('Bettbox') + expect(mapped).toContain('17890') + expect(mapped).toContain('Mixed-port 7890 is already in use') + expect(mapped).toContain('Settings') + }) + + it('returns null for unrelated core output', () => { + expect(mapCoreListenError('INFO start service')).toBeNull() + expect(mapCoreListenError('FATAL router: dial failed')).toBeNull() + }) + + it('formats a message without a concrete port when unknown', () => { + const message = formatPortConflictUserMessage() + expect(message).toContain('混合端口被占用') + expect(message).toContain('Mixed-port is already in use') + expect(message).not.toMatch(/混合端口 \d+被占用/) + }) +}) diff --git a/src/main/core/listenError.ts b/src/main/core/listenError.ts new file mode 100644 index 0000000..f1e2e70 --- /dev/null +++ b/src/main/core/listenError.ts @@ -0,0 +1,34 @@ +/** + * Detect bind failures for inbound/controller sockets (Windows WSAEADDRINUSE + POSIX). + */ +export function isPortInUseListenError(message: string): boolean { + const text = String(message ?? '') + return ( + /address already in use/i.test(text) || + /Only one usage of each socket address/i.test(text) || + /EADDRINUSE/i.test(text) + ) +} + +/** + * Bilingual (zh-CN + en) guidance when mixed-port cannot bind. + * Mentions common conflict with other proxies on 7890 and the 17890+ workaround. + */ +export function formatPortConflictUserMessage(mixedPort?: number): string { + const portLabel = + typeof mixedPort === 'number' && Number.isInteger(mixedPort) && mixedPort > 0 + ? ` ${mixedPort}` + : '' + return [ + `混合端口${portLabel}被占用(常见原因:其他代理如 Bettbox 占用 7890)。请在设置中将 mixed-port 改为 17890 或更高后重试。`, + `Mixed-port${portLabel} is already in use (often another proxy like Bettbox on 7890). Change mixed-port in Settings to 17890 or higher, then retry.` + ].join('\n') +} + +/** + * Map raw core listen/bind output to a user-facing message, or null if unrelated. + */ +export function mapCoreListenError(raw: string, mixedPort?: number): string | null { + if (!isPortInUseListenError(raw)) return null + return formatPortConflictUserMessage(mixedPort) +} diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index bda369d..75048b9 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -35,6 +35,7 @@ import { parseProcessIdentityRecord } from '../utils/processIdentity' import { assertIsolatedSmokeAllows } from '../utils/ciIsolatedSmoke' +import { mapCoreListenError } from './listenError' import { startMihomoTraffic, startMihomoConnections, @@ -731,10 +732,14 @@ function setupCoreListeners( } } + const userFacingFatal = (raw: string): string => mapCoreListenError(raw, config.mixedPort) ?? raw + const startMihomoApiStreams = async (): Promise => { await Promise.all([waitForCoreReady(), waitForTcpPort(config.proxyHost, config.mixedPort)]) if (proc.exitCode !== null || proc.signalCode !== null) { - throw new Error(lastFatalLine || 'core exited before API became ready') + throw new Error( + (lastFatalLine && userFacingFatal(lastFatalLine)) || 'core exited before API became ready' + ) } await getAxios(true) promotePendingRuntimeConfig() @@ -775,7 +780,10 @@ function setupCoreListeners( setSystemProxyCoreReady(false) settleReject( - new Error(lastFatalLine || `core exited unexpectedly, code: ${code}, signal: ${signal}`) + new Error( + (lastFatalLine && userFacingFatal(lastFatalLine)) || + `core exited unexpectedly, code: ${code}, signal: ${signal}` + ) ) if (startupFailed || !startupReady) { @@ -850,10 +858,13 @@ function setupCoreListeners( return } - // 控制器/入站端口监听冲突 - if (/address already in use/i.test(str) || /Only one usage of each socket address/i.test(str)) { - managerLogger.error('Listen error detected:', str) - failStartup(i18next.t('mihomo.error.externalControllerListenError')) + // 混合端口/入站监听冲突(Windows WSAEADDRINUSE / POSIX EADDRINUSE) + const portConflictMessage = mapCoreListenError(str, config.mixedPort) + if (portConflictMessage) { + managerLogger.error( + `Listen error detected (mixed-port ${config.mixedPort} occupied):\n${portConflictMessage}\nraw: ${str}` + ) + failStartup(portConflictMessage) } } @@ -863,6 +874,14 @@ function setupCoreListeners( proc.on('error', (error) => { if (child === proc) child = null if (activeCore?.process === proc) activeCore = null + const mapped = mapCoreListenError(String(error), config.mixedPort) + if (mapped) { + managerLogger.error( + `Core process error (mixed-port ${config.mixedPort} occupied):\n${mapped}\nraw: ${String(error)}` + ) + failStartup(mapped) + return + } failStartup(error) })