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'),
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,

View File

@@ -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

View File

@@ -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), [])

View File

@@ -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])

View File

@@ -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

View File

@@ -8,14 +8,15 @@ export interface ElectronAPI {
restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
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<void>
windowMaximize: () => Promise<boolean>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
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
}