Skip to content

Commit dd48cdb

Browse files
committed
Handle cli cleanup with proper listeners for sigkill etc.
1 parent 53c0c29 commit dd48cdb

File tree

5 files changed

+77
-29
lines changed

5 files changed

+77
-29
lines changed

cli/src/components/project-picker-screen.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { MultilineInput } from './multiline-input'
77
import { SelectableList } from './selectable-list'
88
import { TerminalLink } from './terminal-link'
99
import { useDirectoryBrowser } from '../hooks/use-directory-browser'
10-
import { cleanupRenderer } from '../utils/renderer-cleanup'
1110
import { useLogo } from '../hooks/use-logo'
1211
import { usePathTabCompletion } from '../hooks/use-path-tab-completion'
1312
import { useSearchableList } from '../hooks/use-searchable-list'
@@ -267,7 +266,6 @@ export const ProjectPickerScreen: React.FC<ProjectPickerScreenProps> = ({
267266
}
268267
// Ctrl+C always quits
269268
if (key.name === 'c' && key.ctrl) {
270-
cleanupRenderer()
271269
process.exit(0)
272270
return true
273271
}

cli/src/hooks/use-exit-handler.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
22

33
import { getCurrentChatId } from '../project-files'
44
import { flushAnalytics } from '../utils/analytics'
5-
import { cleanupRenderer } from '../utils/renderer-cleanup'
65
import { withTimeout } from '../utils/terminal-color-detection'
76

87
import type { InputValue } from '../state/chat-store'
@@ -70,7 +69,6 @@ export const useExitHandler = ({
7069
}
7170

7271
withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).then(() => {
73-
cleanupRenderer()
7472
process.exit(0)
7573
})
7674
return true
@@ -85,7 +83,6 @@ export const useExitHandler = ({
8583

8684
withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).finally(
8785
() => {
88-
cleanupRenderer()
8986
process.exit(0)
9087
},
9188
)

cli/src/hooks/use-login-keyboard-handlers.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useKeyboard } from '@opentui/react'
22
import { useCallback } from 'react'
33

4-
import { cleanupRenderer } from '../utils/renderer-cleanup'
5-
64
import type { KeyEvent } from '@opentui/core'
75

86
interface UseLoginKeyboardHandlersParams {
@@ -45,7 +43,6 @@ export function useLoginKeyboardHandlers({
4543
) {
4644
key.preventDefault()
4745
}
48-
cleanupRenderer()
4946
process.exit(0)
5047
}
5148

cli/src/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { initializeAgentRegistry } from './utils/local-agent-registry'
2828
import { clearLogFile, logger } from './utils/logger'
2929
import { shouldShowProjectPicker } from './utils/project-picker'
3030
import { saveRecentProject } from './utils/recent-projects'
31-
import { registerRendererForCleanup } from './utils/renderer-cleanup'
31+
import { installProcessCleanupHandlers } from './utils/renderer-cleanup'
3232
import { detectTerminalTheme } from './utils/terminal-color-detection'
3333
import { setOscDetectedTheme } from './utils/theme-system'
3434

@@ -309,7 +309,7 @@ async function main(): Promise<void> {
309309
backgroundColor: 'transparent',
310310
exitOnCtrlC: false,
311311
})
312-
registerRendererForCleanup(renderer)
312+
installProcessCleanupHandlers(renderer)
313313
createRoot(renderer).render(
314314
<QueryClientProvider client={queryClient}>
315315
<AppWithAsyncAuth />

cli/src/utils/renderer-cleanup.ts

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,88 @@
11
import type { CliRenderer } from '@opentui/core'
22

3-
/**
4-
* Global reference to the CLI renderer for cleanup on exit.
5-
* This allows the exit handler to properly destroy the renderer,
6-
* which resets terminal state (mouse tracking, focus reporting, raw mode, etc.)
7-
*/
8-
let registeredRenderer: CliRenderer | null = null
3+
let renderer: CliRenderer | null = null
4+
let handlersInstalled = false
95

106
/**
11-
* Register the renderer for cleanup on exit.
12-
* Call this after creating the renderer in index.tsx.
7+
* Clean up the renderer by calling destroy().
8+
* This resets terminal state to prevent garbled output after exit.
139
*/
14-
export function registerRendererForCleanup(renderer: CliRenderer): void {
15-
registeredRenderer = renderer
10+
function cleanup(): void {
11+
if (renderer && !renderer.isDestroyed) {
12+
try {
13+
renderer.destroy()
14+
} catch {
15+
// Ignore errors during cleanup - we're exiting anyway
16+
}
17+
renderer = null
18+
}
1619
}
1720

1821
/**
19-
* Cleanup the renderer by calling destroy().
20-
* This resets terminal state to prevent garbled output after exit.
21-
* Should be called before process.exit() in exit handlers.
22+
* Install process-level signal handlers to ensure terminal cleanup on all exit scenarios.
23+
* Call this once after creating the renderer in index.tsx.
24+
*
25+
* This handles:
26+
* - SIGTERM (kill)
27+
* - SIGHUP (terminal hangup)
28+
* - SIGINT (Ctrl+C)
29+
* - beforeExit / exit events
30+
* - uncaughtException / unhandledRejection
31+
*
32+
* Note: SIGKILL cannot be caught - it's an immediate termination signal.
2233
*/
23-
export function cleanupRenderer(): void {
24-
if (registeredRenderer && !registeredRenderer.isDestroyed) {
34+
export function installProcessCleanupHandlers(cliRenderer: CliRenderer): void {
35+
if (handlersInstalled) return
36+
handlersInstalled = true
37+
renderer = cliRenderer
38+
39+
const cleanupAndExit = (exitCode: number) => {
40+
cleanup()
41+
process.exit(exitCode)
42+
}
43+
44+
// SIGTERM - Default kill signal (e.g., `kill <pid>`)
45+
process.on('SIGTERM', () => {
46+
cleanupAndExit(0)
47+
})
48+
49+
// SIGHUP - Terminal hangup (e.g., closing the terminal window)
50+
process.on('SIGHUP', () => {
51+
cleanupAndExit(0)
52+
})
53+
54+
// SIGINT - Ctrl+C
55+
process.on('SIGINT', () => {
56+
cleanupAndExit(0)
57+
})
58+
59+
// beforeExit - Called when the event loop is empty and about to exit
60+
process.on('beforeExit', () => {
61+
cleanup()
62+
})
63+
64+
// exit - Last chance to run synchronous cleanup code
65+
process.on('exit', () => {
66+
cleanup()
67+
})
68+
69+
// uncaughtException - Safety net for unhandled errors
70+
process.on('uncaughtException', (error) => {
2571
try {
26-
registeredRenderer.destroy()
72+
console.error('Uncaught exception:', error)
2773
} catch {
28-
// Ignore errors during cleanup - we're exiting anyway
74+
// Ignore logging errors
2975
}
30-
registeredRenderer = null
31-
}
76+
cleanupAndExit(1)
77+
})
78+
79+
// unhandledRejection - Safety net for unhandled promise rejections
80+
process.on('unhandledRejection', (reason) => {
81+
try {
82+
console.error('Unhandled rejection:', reason)
83+
} catch {
84+
// Ignore logging errors
85+
}
86+
cleanupAndExit(1)
87+
})
3288
}

0 commit comments

Comments
 (0)