From c432681b2b0de5348a4ce56bc384271b2d947e10 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 22 Jun 2026 15:46:49 +0800 Subject: [PATCH] fix(desktop): prevent backend zombie process and IPC listener leaks --- desktop/src/main/preload.ts | 18 ++++++++++++++---- desktop/src/main/python-manager.ts | 12 ++++++++---- desktop/src/renderer/src/App.tsx | 3 ++- desktop/src/renderer/src/hooks/useBackend.ts | 4 +++- .../src/renderer/src/layout/WindowControls.tsx | 3 ++- desktop/src/renderer/src/types.ts | 9 +++++---- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index e34e7326..ecb36408 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -7,12 +7,18 @@ contextBridge.exposeInMainWorld('electronAPI', { selectDirectory: () => ipcRenderer.invoke('select-directory'), selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters), + // Each listener registrar returns an unsubscribe fn so renderers can clean + // up on unmount / effect re-run and avoid accumulating duplicate handlers. onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => { - ipcRenderer.on('backend-status', (_event, data) => callback(data)) + const handler = (_event: unknown, data: { status: string; port?: number; error?: string }) => callback(data) + ipcRenderer.on('backend-status', handler) + return () => ipcRenderer.removeListener('backend-status', handler) }, onBackendLog: (callback: (line: string) => void) => { - ipcRenderer.on('backend-log', (_event, line) => callback(line)) + const handler = (_event: unknown, line: string) => callback(line) + ipcRenderer.on('backend-log', handler) + return () => ipcRenderer.removeListener('backend-log', handler) }, // Window controls (custom titlebar on Windows) @@ -21,12 +27,16 @@ contextBridge.exposeInMainWorld('electronAPI', { windowClose: () => ipcRenderer.invoke('window-close'), windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'), onMaximizeChange: (callback: (maximized: boolean) => void) => { - ipcRenderer.on('window-maximize-changed', (_event, max) => callback(max)) + const handler = (_event: unknown, max: boolean) => callback(max) + ipcRenderer.on('window-maximize-changed', handler) + return () => ipcRenderer.removeListener('window-maximize-changed', handler) }, // App menu / shortcut actions forwarded from the main process. onMenuAction: (callback: (action: string) => void) => { - ipcRenderer.on('menu-action', (_event, action: string) => callback(action)) + const handler = (_event: unknown, action: string) => callback(action) + ipcRenderer.on('menu-action', handler) + return () => ipcRenderer.removeListener('menu-action', handler) }, platform: process.platform, diff --git a/desktop/src/main/python-manager.ts b/desktop/src/main/python-manager.ts index 77fcfb93..16801ab5 100644 --- a/desktop/src/main/python-manager.ts +++ b/desktop/src/main/python-manager.ts @@ -176,11 +176,15 @@ export class PythonBackend extends EventEmitter { } stop(): void { - if (this.process) { - this.process.kill('SIGTERM') + const proc = this.process + if (proc) { + proc.kill('SIGTERM') + // Keep a local ref so the SIGKILL fallback can still reach the process + // even after we clear `this.process`; otherwise a stuck backend would + // never be force-killed and leak as a zombie. setTimeout(() => { - if (this.process && !this.process.killed) { - this.process.kill('SIGKILL') + if (!proc.killed) { + proc.kill('SIGKILL') } }, 5000) this.process = null diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 0e45febb..2097b743 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -34,7 +34,7 @@ const App: React.FC = () => { // Handle app-menu / shortcut actions forwarded from the main process. useEffect(() => { - window.electronAPI?.onMenuAction?.((action) => { + const off = window.electronAPI?.onMenuAction?.((action) => { if (action === 'new-chat') { useSessionStore.getState().newSession() navigate('/') @@ -42,6 +42,7 @@ const App: React.FC = () => { navigate('/settings') } }) + return off }, [navigate]) const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), []) diff --git a/desktop/src/renderer/src/hooks/useBackend.ts b/desktop/src/renderer/src/hooks/useBackend.ts index 990afbd0..5d002567 100644 --- a/desktop/src/renderer/src/hooks/useBackend.ts +++ b/desktop/src/renderer/src/hooks/useBackend.ts @@ -26,6 +26,7 @@ export function useBackend() { useEffect(() => { let cancelled = false + let offStatus: (() => void) | undefined const api = window.electronAPI const startPolling = async (port: number) => { @@ -62,7 +63,7 @@ export function useBackend() { startPolling(p) }) - api.onBackendStatus((data) => { + offStatus = api.onBackendStatus((data) => { if (data.status === 'ready' && data.port) { setState({ status: 'ready', port: data.port }) if (pollingRef.current) { @@ -82,6 +83,7 @@ export function useBackend() { if (pollingRef.current) { clearTimeout(pollingRef.current) } + offStatus?.() } }, [probeBackend]) diff --git a/desktop/src/renderer/src/layout/WindowControls.tsx b/desktop/src/renderer/src/layout/WindowControls.tsx index 82c3edc1..511aa6f6 100644 --- a/desktop/src/renderer/src/layout/WindowControls.tsx +++ b/desktop/src/renderer/src/layout/WindowControls.tsx @@ -11,7 +11,8 @@ const WindowControls: React.FC = () => { useEffect(() => { api?.windowIsMaximized().then(setMaximized) - api?.onMaximizeChange(setMaximized) + const off = api?.onMaximizeChange(setMaximized) + return off }, [api]) if (api?.platform === 'darwin') return null diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 44dbffec..7ff298b1 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -8,14 +8,15 @@ export interface ElectronAPI { restartBackend: () => Promise selectDirectory: () => Promise selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise - onBackendStatus: (callback: (data: BackendStatusEvent) => void) => void - onBackendLog: (callback: (line: string) => void) => void + // Listener registrars return an unsubscribe fn for cleanup. + onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void + onBackendLog: (callback: (line: string) => void) => () => void windowMinimize: () => Promise windowMaximize: () => Promise windowClose: () => Promise windowIsMaximized: () => Promise - onMaximizeChange: (callback: (maximized: boolean) => void) => void - onMenuAction?: (callback: (action: string) => void) => void + onMaximizeChange: (callback: (maximized: boolean) => void) => () => void + onMenuAction?: (callback: (action: string) => void) => () => void platform: string }