mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
fix(service): surface a service core that stopped for good
When the service watchdog gave up or the core vanished, the owner monitor marked the core stopped without telling the user, and on macOS left the system proxy on the dead mixed port (#8029). - On SameOwnerFailure, clear the proxy through the helper while the session is still ours; displaced or unreachable owners still leave the machine-wide setting alone. - Retain the stop as a core failure with its own message, recorded under the lifecycle lock and shown now or on next focus; a running core resets the notice dedup. - Log each watchdog restart the service reports, since launchd discards the service's own log.
This commit is contained in:
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "تعذّر تغيير وضع الوكيل",
|
||||
"restartFailed": "تعذّر إعادة تشغيل النواة",
|
||||
"startFailed": "تعذّر تشغيل النواة",
|
||||
"serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع",
|
||||
"stopFailed": "تعذّر إيقاف النواة"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Proxy-Modus konnte nicht geändert werden",
|
||||
"restartFailed": "Kern konnte nicht neu gestartet werden",
|
||||
"startFailed": "Kern konnte nicht gestartet werden",
|
||||
"serviceCoreStopped": "Der vom Dienst ausgeführte Kern wurde unerwartet beendet",
|
||||
"stopFailed": "Kern konnte nicht gestoppt werden"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Failed to change proxy mode",
|
||||
"restartFailed": "Failed to restart the core",
|
||||
"startFailed": "Failed to start the core",
|
||||
"serviceCoreStopped": "The service's core stopped unexpectedly",
|
||||
"stopFailed": "Failed to stop the core"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "No se pudo cambiar el modo de proxy",
|
||||
"restartFailed": "No se pudo reiniciar el núcleo",
|
||||
"startFailed": "No se pudo iniciar el núcleo",
|
||||
"serviceCoreStopped": "El núcleo ejecutado por el servicio se detuvo inesperadamente",
|
||||
"stopFailed": "No se pudo detener el núcleo"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "تغییر حالت پروکسی ناموفق بود",
|
||||
"restartFailed": "راهاندازی مجدد هسته ناموفق بود",
|
||||
"startFailed": "راهاندازی هسته ناموفق بود",
|
||||
"serviceCoreStopped": "هستهای که سرویس اجرا میکرد بهطور غیرمنتظره متوقف شد",
|
||||
"stopFailed": "توقف هسته ناموفق بود"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Gagal mengubah mode proxy",
|
||||
"restartFailed": "Gagal memulai ulang core",
|
||||
"startFailed": "Gagal memulai core",
|
||||
"serviceCoreStopped": "Core yang dijalankan layanan berhenti secara tak terduga",
|
||||
"stopFailed": "Gagal menghentikan core"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "プロキシモードの変更に失敗しました",
|
||||
"restartFailed": "コアの再起動に失敗しました",
|
||||
"startFailed": "コアの起動に失敗しました",
|
||||
"serviceCoreStopped": "サービスで実行中のコアが予期せず停止しました",
|
||||
"stopFailed": "コアの停止に失敗しました"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "프록시 모드를 변경하지 못했습니다",
|
||||
"restartFailed": "코어를 재시작하지 못했습니다",
|
||||
"startFailed": "코어를 시작하지 못했습니다",
|
||||
"serviceCoreStopped": "서비스에서 실행 중인 코어가 예기치 않게 중지되었습니다",
|
||||
"stopFailed": "코어를 중지하지 못했습니다"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Не удалось изменить режим прокси",
|
||||
"restartFailed": "Не удалось перезапустить ядро",
|
||||
"startFailed": "Не удалось запустить ядро",
|
||||
"serviceCoreStopped": "Ядро, запущенное службой, неожиданно остановилось",
|
||||
"stopFailed": "Не удалось остановить ядро"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Proxy modu değiştirilemedi",
|
||||
"restartFailed": "Çekirdek yeniden başlatılamadı",
|
||||
"startFailed": "Çekirdek başlatılamadı",
|
||||
"serviceCoreStopped": "Hizmetin çalıştırdığı çekirdek beklenmedik şekilde durdu",
|
||||
"stopFailed": "Çekirdek durdurulamadı"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Прокси режимын үзгәртеп булмады",
|
||||
"restartFailed": "Ядрону яңадан башлап булмады",
|
||||
"startFailed": "Ядрону башлап булмады",
|
||||
"serviceCoreStopped": "Хезмәт эшләткән ядро көтмәгәндә туктады",
|
||||
"stopFailed": "Ядрону туктатып булмады"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "切换代理模式失败",
|
||||
"restartFailed": "重启内核失败",
|
||||
"startFailed": "启动内核失败",
|
||||
"serviceCoreStopped": "服务运行的内核意外停止",
|
||||
"stopFailed": "停止内核失败"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "切換代理模式失敗",
|
||||
"restartFailed": "重啟內核失敗",
|
||||
"startFailed": "啟動內核失敗",
|
||||
"serviceCoreStopped": "服務執行的內核意外停止",
|
||||
"stopFailed": "停止內核失敗"
|
||||
},
|
||||
"clashService": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
type RunState,
|
||||
getCoreStartupError,
|
||||
takeDnsOverrideNotice,
|
||||
takeServiceRepairNotice,
|
||||
@@ -53,7 +54,10 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
it('does not replay a recovered startup error after WebView recreation', async () => {
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue('startup failed')
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail: 'startup failed',
|
||||
})
|
||||
handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledOnce()
|
||||
@@ -75,7 +79,10 @@ it.each([
|
||||
const detail = 'startup failed'
|
||||
nativeWindow.isVisible.mockResolvedValue(visible)
|
||||
nativeWindow.isMinimized.mockResolvedValue(minimized)
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(detail)
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail,
|
||||
})
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
@@ -99,7 +106,10 @@ it.each([
|
||||
|
||||
it('delivers an early core startup failure once after listeners mount', async () => {
|
||||
const detail = 'could not open core execution mutex: access denied'
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(detail)
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail,
|
||||
})
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
@@ -113,6 +123,24 @@ it('delivers an early core startup failure once after listeners mount', async ()
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the same core failure again after the core has started in between', async () => {
|
||||
const { useLayoutEvents } = await import('./use-layout-events')
|
||||
const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(failure)
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0]
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// The core started and stopped again before the window handled either event.
|
||||
handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState)
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drains a DNS notice after listeners mount without duplicating its live event', async () => {
|
||||
vi.mocked(takeDnsOverrideNotice)
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { RunState } from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { revalidateQueries, setCacheData } from '@/services/query-client'
|
||||
|
||||
import { forgetShownStartupError } from '../utils/notification-handlers'
|
||||
|
||||
export const useLayoutEvents = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
@@ -58,6 +60,7 @@ export const useLayoutEvents = (
|
||||
// Transitions carry the full run-state snapshot, so write it directly to cache.
|
||||
'verge://run-state-changed': (payload) => {
|
||||
void setCacheData<RunState>(runStateQueryKey, payload)
|
||||
if (payload.mode !== 'NotRunning') forgetShownStartupError()
|
||||
},
|
||||
'verge://notice-message': handleNotice,
|
||||
},
|
||||
|
||||
@@ -15,8 +15,17 @@ type NavigateFunction = (path: string, options?: any) => void
|
||||
type TranslateFunction = (key: string) => string
|
||||
|
||||
let shownStartupError: string | null = null
|
||||
// Reads can settle out of order; only the newest may act, and a reset counts as one.
|
||||
let startupErrorReads = 0
|
||||
let settledStartupErrorRead = 0
|
||||
let shownOwnerNotice: number | null = null
|
||||
|
||||
/** A running core has resolved every failure shown before it. */
|
||||
export const forgetShownStartupError = () => {
|
||||
shownStartupError = null
|
||||
settledStartupErrorRead = ++startupErrorReads
|
||||
}
|
||||
|
||||
export const handleNoticeMessage = (
|
||||
status: string,
|
||||
msg: string,
|
||||
@@ -39,9 +48,12 @@ export const handleNoticeMessage = (
|
||||
},
|
||||
'set_config::error': () => showNotice.error(msg),
|
||||
'core_start::error': () => {
|
||||
const read = ++startupErrorReads
|
||||
void getCoreStartupError()
|
||||
.then(async (error) => {
|
||||
if (!error) {
|
||||
.then(async (failure) => {
|
||||
if (read < settledStartupErrorRead) return
|
||||
settledStartupErrorRead = read
|
||||
if (!failure) {
|
||||
shownStartupError = null
|
||||
return
|
||||
}
|
||||
@@ -50,11 +62,19 @@ export const handleNoticeMessage = (
|
||||
window.isVisible(),
|
||||
window.isMinimized(),
|
||||
])
|
||||
if (visible && !minimized && shownStartupError !== error) {
|
||||
shownStartupError = error
|
||||
const shown = `${failure.kind}:${failure.detail}`
|
||||
if (
|
||||
read === settledStartupErrorRead &&
|
||||
visible &&
|
||||
!minimized &&
|
||||
shownStartupError !== shown
|
||||
) {
|
||||
shownStartupError = shown
|
||||
showNotice.error(
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
error,
|
||||
failure.kind === 'serviceCoreStopped'
|
||||
? 'settings.feedback.errors.clash.serviceCoreStopped'
|
||||
: 'settings.feedback.errors.clash.startFailed',
|
||||
failure.detail,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -190,8 +190,13 @@ export async function takeServiceFallbackNotice() {
|
||||
return invoke<boolean>('take_service_fallback_notice')
|
||||
}
|
||||
|
||||
export interface CoreFailure {
|
||||
kind: 'startFailed' | 'serviceCoreStopped'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export async function getCoreStartupError() {
|
||||
return invoke<string | null>('get_core_startup_error')
|
||||
return invoke<CoreFailure | null>('get_core_startup_error')
|
||||
}
|
||||
|
||||
export async function takeServiceRepairNotice() {
|
||||
|
||||
@@ -785,6 +785,7 @@ export const translationKeys = [
|
||||
'settings.feedback.errors.clash.modeUpdateFailed',
|
||||
'settings.feedback.errors.clash.restartFailed',
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
'settings.feedback.errors.clash.serviceCoreStopped',
|
||||
'settings.feedback.errors.clash.stopFailed',
|
||||
'settings.feedback.errors.clashService.installFailed',
|
||||
'settings.feedback.errors.clashService.uninstallFailed',
|
||||
|
||||
@@ -815,6 +815,7 @@ export interface TranslationResources {
|
||||
configUpdateFailed: string
|
||||
modeUpdateFailed: string
|
||||
restartFailed: string
|
||||
serviceCoreStopped: string
|
||||
startFailed: string
|
||||
stopFailed: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user