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:
wonfen
2026-09-25 00:06:59 +08:00
parent 1f140dfd3a
commit daae17a40c
24 changed files with 174 additions and 45 deletions
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "تعذّر تغيير وضع الوكيل",
"restartFailed": "تعذّر إعادة تشغيل النواة",
"startFailed": "تعذّر تشغيل النواة",
"serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع",
"stopFailed": "تعذّر إيقاف النواة"
},
"clashService": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "تغییر حالت پروکسی ناموفق بود",
"restartFailed": "راه‌اندازی مجدد هسته ناموفق بود",
"startFailed": "راه‌اندازی هسته ناموفق بود",
"serviceCoreStopped": "هسته‌ای که سرویس اجرا می‌کرد به‌طور غیرمنتظره متوقف شد",
"stopFailed": "توقف هسته ناموفق بود"
},
"clashService": {
+1
View File
@@ -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": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "プロキシモードの変更に失敗しました",
"restartFailed": "コアの再起動に失敗しました",
"startFailed": "コアの起動に失敗しました",
"serviceCoreStopped": "サービスで実行中のコアが予期せず停止しました",
"stopFailed": "コアの停止に失敗しました"
},
"clashService": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "프록시 모드를 변경하지 못했습니다",
"restartFailed": "코어를 재시작하지 못했습니다",
"startFailed": "코어를 시작하지 못했습니다",
"serviceCoreStopped": "서비스에서 실행 중인 코어가 예기치 않게 중지되었습니다",
"stopFailed": "코어를 중지하지 못했습니다"
},
"clashService": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "Не удалось изменить режим прокси",
"restartFailed": "Не удалось перезапустить ядро",
"startFailed": "Не удалось запустить ядро",
"serviceCoreStopped": "Ядро, запущенное службой, неожиданно остановилось",
"stopFailed": "Не удалось остановить ядро"
},
"clashService": {
+1
View File
@@ -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": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "Прокси режимын үзгәртеп булмады",
"restartFailed": "Ядрону яңадан башлап булмады",
"startFailed": "Ядрону башлап булмады",
"serviceCoreStopped": "Хезмәт эшләткән ядро көтмәгәндә туктады",
"stopFailed": "Ядрону туктатып булмады"
},
"clashService": {
+1
View File
@@ -655,6 +655,7 @@
"modeUpdateFailed": "切换代理模式失败",
"restartFailed": "重启内核失败",
"startFailed": "启动内核失败",
"serviceCoreStopped": "服务运行的内核意外停止",
"stopFailed": "停止内核失败"
},
"clashService": {
+1
View File
@@ -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,
)
}
})
+6 -1
View File
@@ -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() {
+1
View File
@@ -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',
+1
View File
@@ -815,6 +815,7 @@ export interface TranslationResources {
configUpdateFailed: string
modeUpdateFailed: string
restartFailed: string
serviceCoreStopped: string
startFailed: string
stopFailed: string
}