fix(desktop): prevent backend zombie process and IPC listener leaks

This commit is contained in:
zhayujie
2026-06-22 15:46:49 +08:00
parent 49452e035d
commit c432681b2b
6 changed files with 34 additions and 15 deletions

View File

@@ -7,12 +7,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectDirectory: () => ipcRenderer.invoke('select-directory'), selectDirectory: () => ipcRenderer.invoke('select-directory'),
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters), 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) => { 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) => { 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) // Window controls (custom titlebar on Windows)
@@ -21,12 +27,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
windowClose: () => ipcRenderer.invoke('window-close'), windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'), windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
onMaximizeChange: (callback: (maximized: boolean) => void) => { 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. // App menu / shortcut actions forwarded from the main process.
onMenuAction: (callback: (action: string) => void) => { 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, platform: process.platform,

View File

@@ -176,11 +176,15 @@ export class PythonBackend extends EventEmitter {
} }
stop(): void { stop(): void {
if (this.process) { const proc = this.process
this.process.kill('SIGTERM') 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(() => { setTimeout(() => {
if (this.process && !this.process.killed) { if (!proc.killed) {
this.process.kill('SIGKILL') proc.kill('SIGKILL')
} }
}, 5000) }, 5000)
this.process = null this.process = null

View File

@@ -34,7 +34,7 @@ const App: React.FC = () => {
// Handle app-menu / shortcut actions forwarded from the main process. // Handle app-menu / shortcut actions forwarded from the main process.
useEffect(() => { useEffect(() => {
window.electronAPI?.onMenuAction?.((action) => { const off = window.electronAPI?.onMenuAction?.((action) => {
if (action === 'new-chat') { if (action === 'new-chat') {
useSessionStore.getState().newSession() useSessionStore.getState().newSession()
navigate('/') navigate('/')
@@ -42,6 +42,7 @@ const App: React.FC = () => {
navigate('/settings') navigate('/settings')
} }
}) })
return off
}, [navigate]) }, [navigate])
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), []) const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])

View File

@@ -26,6 +26,7 @@ export function useBackend() {
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let offStatus: (() => void) | undefined
const api = window.electronAPI const api = window.electronAPI
const startPolling = async (port: number) => { const startPolling = async (port: number) => {
@@ -62,7 +63,7 @@ export function useBackend() {
startPolling(p) startPolling(p)
}) })
api.onBackendStatus((data) => { offStatus = api.onBackendStatus((data) => {
if (data.status === 'ready' && data.port) { if (data.status === 'ready' && data.port) {
setState({ status: 'ready', port: data.port }) setState({ status: 'ready', port: data.port })
if (pollingRef.current) { if (pollingRef.current) {
@@ -82,6 +83,7 @@ export function useBackend() {
if (pollingRef.current) { if (pollingRef.current) {
clearTimeout(pollingRef.current) clearTimeout(pollingRef.current)
} }
offStatus?.()
} }
}, [probeBackend]) }, [probeBackend])

View File

@@ -11,7 +11,8 @@ const WindowControls: React.FC = () => {
useEffect(() => { useEffect(() => {
api?.windowIsMaximized().then(setMaximized) api?.windowIsMaximized().then(setMaximized)
api?.onMaximizeChange(setMaximized) const off = api?.onMaximizeChange(setMaximized)
return off
}, [api]) }, [api])
if (api?.platform === 'darwin') return null if (api?.platform === 'darwin') return null

View File

@@ -8,14 +8,15 @@ export interface ElectronAPI {
restartBackend: () => Promise<boolean> restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null> selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null> selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => void // Listener registrars return an unsubscribe fn for cleanup.
onBackendLog: (callback: (line: string) => void) => void onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
onBackendLog: (callback: (line: string) => void) => () => void
windowMinimize: () => Promise<void> windowMinimize: () => Promise<void>
windowMaximize: () => Promise<boolean> windowMaximize: () => Promise<boolean>
windowClose: () => Promise<void> windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean> windowIsMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => void onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
onMenuAction?: (callback: (action: string) => void) => void onMenuAction?: (callback: (action: string) => void) => () => void
platform: string platform: string
} }