Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/main/core/listenError.test.ts
Original file line number Diff line number Diff line change
@@ -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+被占用/)
})
})
34 changes: 34 additions & 0 deletions src/main/core/listenError.ts
Original file line number Diff line number Diff line change
@@ -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)
}
31 changes: 25 additions & 6 deletions src/main/core/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
parseProcessIdentityRecord
} from '../utils/processIdentity'
import { assertIsolatedSmokeAllows } from '../utils/ciIsolatedSmoke'
import { mapCoreListenError } from './listenError'
import {
startMihomoTraffic,
startMihomoConnections,
Expand Down Expand Up @@ -731,10 +732,14 @@ function setupCoreListeners(
}
}

const userFacingFatal = (raw: string): string => mapCoreListenError(raw, config.mixedPort) ?? raw

const startMihomoApiStreams = async (): Promise<void> => {
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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
})

Expand Down