fix(terminal): prevent infinite reconnect loop on Windows hosts (#336)

This commit is contained in:
lifu963
2026-03-21 21:45:40 +08:00
committed by GitHub
parent ae39fc5e77
commit 895654ddf6
12 changed files with 165 additions and 21 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ export class TerminalManager {
create(terminalId: string, cols: number, rows: number): void {
if (process.platform === 'win32') {
this.emitError(terminalId, 'Terminal is not supported on Windows.')
this.emitError(terminalId, 'Remote terminal is not supported on Windows yet.')
return
}
+3 -1
View File
@@ -97,7 +97,9 @@ Yes. Open any session and use the chat interface to send messages directly to th
### Can I access a terminal remotely?
Yes. Open a session in the web app and tap the Terminal tab for a remote shell.
Yes, on Linux and macOS hosts. Open a session in the web app and tap the Terminal tab for a remote shell.
Windows hosts do not support the remote Terminal yet because the Bun PTY API used by HAPI is currently POSIX-only.
### How do I use voice control?
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'bun:test'
import type { StoredSession } from '../../../store'
import type { CliSocketWithData } from '../../socketTypes'
import { TerminalRegistry } from '../../terminalRegistry'
import { registerTerminalHandlers } from './terminalHandlers'
type EmittedEvent = {
event: string
data: unknown
}
class FakeSocket {
readonly id: string
readonly data: Record<string, unknown> = {}
readonly emitted: EmittedEvent[] = []
private readonly handlers = new Map<string, (...args: unknown[]) => void>()
constructor(id: string) {
this.id = id
}
on(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(event, handler)
return this
}
emit(event: string, data: unknown): boolean {
this.emitted.push({ event, data })
return true
}
trigger(event: string, data?: unknown): void {
const handler = this.handlers.get(event)
if (!handler) {
return
}
if (typeof data === 'undefined') {
handler()
return
}
handler(data)
}
}
class FakeNamespace {
readonly sockets = new Map<string, FakeSocket>()
}
function lastEmit(socket: FakeSocket, event: string): EmittedEvent | undefined {
return [...socket.emitted].reverse().find((entry) => entry.event === event)
}
describe('cli terminal handlers', () => {
it('removes stale registry entries after terminal errors', () => {
const cliSocket = new FakeSocket('cli-socket')
const terminalSocket = new FakeSocket('terminal-socket')
const terminalNamespace = new FakeNamespace()
const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 })
terminalNamespace.sockets.set(terminalSocket.id, terminalSocket)
terminalRegistry.register('terminal-1', 'session-1', terminalSocket.id, cliSocket.id)
registerTerminalHandlers(cliSocket as unknown as CliSocketWithData, {
terminalRegistry,
terminalNamespace: terminalNamespace as never,
resolveSessionAccess: () => ({ ok: true, value: {} as StoredSession }),
emitAccessError: () => {
throw new Error('Unexpected access error')
}
})
cliSocket.trigger('terminal:error', {
sessionId: 'session-1',
terminalId: 'terminal-1',
message: 'Remote terminal is not supported on Windows yet.'
})
expect(terminalRegistry.get('terminal-1')).toBeNull()
expect(lastEmit(terminalSocket, 'terminal:error')?.data).toEqual({
sessionId: 'session-1',
terminalId: 'terminal-1',
message: 'Remote terminal is not supported on Windows yet.'
})
})
})
@@ -93,7 +93,22 @@ export function registerTerminalHandlers(socket: CliSocketWithData, deps: Termin
if (!parsed.success) {
return
}
forwardTerminalEvent('terminal:error', parsed.data)
const entry = terminalRegistry.get(parsed.data.terminalId)
if (!entry || entry.sessionId !== parsed.data.sessionId || entry.cliSocketId !== socket.id) {
return
}
const sessionAccess = resolveSessionAccess(parsed.data.sessionId)
if (!sessionAccess.ok) {
terminalRegistry.remove(parsed.data.terminalId)
emitAccessError('session', parsed.data.sessionId, sessionAccess.reason)
return
}
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
terminalRegistry.remove(parsed.data.terminalId)
terminalSocket?.emit('terminal:error', parsed.data)
})
}
@@ -303,6 +303,7 @@ export function ComposerButtons(props: {
onSettingsToggle: () => void
showTerminalButton: boolean
terminalDisabled: boolean
terminalLabel: string
onTerminal: () => void
showAbortButton: boolean
abortDisabled: boolean
@@ -350,8 +351,8 @@ export function ComposerButtons(props: {
{props.showTerminalButton ? (
<button
type="button"
aria-label={t('composer.terminal')}
title={t('composer.terminal')}
aria-label={props.terminalLabel}
title={props.terminalLabel}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={props.onTerminal}
disabled={props.terminalDisabled}
@@ -54,6 +54,7 @@ export function HappyComposer(props: {
onModelChange?: (model: string | null) => void
onSwitchToRemote?: () => void
onTerminal?: () => void
terminalUnsupported?: boolean
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
// Voice assistant props
@@ -80,6 +81,7 @@ export function HappyComposer(props: {
onModelChange,
onSwitchToRemote,
onTerminal,
terminalUnsupported = false,
autocompletePrefixes = ['@', '/', '$'],
autocompleteSuggestions = defaultSuggestionHandler,
voiceStatus = 'disconnected',
@@ -215,7 +217,9 @@ export function HappyComposer(props: {
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
const showTerminalButton = Boolean(onTerminal)
const showTerminalButton = Boolean(onTerminal || terminalUnsupported)
const terminalDisabled = controlsDisabled || terminalUnsupported
const terminalLabel = terminalUnsupported ? t('terminal.unsupportedWindows') : t('composer.terminal')
useEffect(() => {
if (!isAborting) return
@@ -639,7 +643,8 @@ export function HappyComposer(props: {
showSettingsButton={showSettingsButton}
onSettingsToggle={handleSettingsToggle}
showTerminalButton={showTerminalButton}
terminalDisabled={controlsDisabled}
terminalDisabled={terminalDisabled}
terminalLabel={terminalLabel}
onTerminal={onTerminal ?? (() => {})}
showAbortButton={showAbortButton}
abortDisabled={abortDisabled}
+4 -1
View File
@@ -18,6 +18,7 @@ import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { useVoiceOptional } from '@/lib/voice-context'
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
export function SessionChat(props: {
api: ApiClient
@@ -42,6 +43,7 @@ export function SessionChat(props: {
const { haptic } = usePlatform()
const navigate = useNavigate()
const sessionInactive = !props.session.active
const terminalSupported = isRemoteTerminalSupported(props.session.metadata)
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const [forceScrollToken, setForceScrollToken] = useState(0)
@@ -345,7 +347,8 @@ export function SessionChat(props: {
onPermissionModeChange={handlePermissionModeChange}
onModelChange={handleModelChange}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined}
onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined}
terminalUnsupported={props.session.active && !terminalSupported}
autocompleteSuggestions={props.autocompleteSuggestions}
voiceStatus={voice?.status}
voiceMicMuted={voice?.micMuted}
+1
View File
@@ -142,6 +142,7 @@ export default {
'terminal.commandArgs': 'Command args',
'terminal.stdout': 'Stdout',
'terminal.stderr': 'Stderr',
'terminal.unsupportedWindows': 'Remote terminal is unavailable on Windows hosts.',
'terminal.paste.fallbackTitle': 'Paste input',
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
'terminal.paste.placeholder': 'Paste terminal input here…',
+1
View File
@@ -144,6 +144,7 @@ export default {
'terminal.commandArgs': '命令参数',
'terminal.stdout': '标准输出',
'terminal.stderr': '标准错误',
'terminal.unsupportedWindows': 'Windows 主机暂不支持远程终端。',
'terminal.paste.fallbackTitle': '粘贴输入',
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
'terminal.paste.placeholder': '在此粘贴终端输入…',
+20 -13
View File
@@ -11,6 +11,7 @@ import { useTranslation } from '@/lib/use-translation'
import { TerminalView } from '@/components/Terminal/TerminalView'
import { LoadingState } from '@/components/LoadingState'
import { Button } from '@/components/ui/button'
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
import {
Dialog,
DialogContent,
@@ -184,6 +185,7 @@ export default function TerminalPage() {
const { api, token, baseUrl } = useAppContext()
const goBack = useAppGoBack()
const { session } = useSession(api, sessionId)
const terminalSupported = isRemoteTerminalSupported(session?.metadata)
const terminalId = useMemo(() => {
if (typeof crypto?.randomUUID === 'function') {
return crypto.randomUUID()
@@ -226,7 +228,6 @@ export default function TerminalPage() {
onExit((code, signal) => {
setExitInfo({ code, signal })
terminalRef.current?.write(`\r\n[process exited${code !== null ? ` with code ${code}` : ''}]`)
connectOnceRef.current = false
})
}, [onExit])
@@ -264,7 +265,7 @@ export default function TerminalPage() {
const handleResize = useCallback(
(cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
if (!session?.active) {
if (!session?.active || !terminalSupported) {
return
}
if (!connectOnceRef.current) {
@@ -274,11 +275,11 @@ export default function TerminalPage() {
resize(cols, rows)
}
},
[session?.active, connect, resize]
[session?.active, terminalSupported, connect, resize]
)
useEffect(() => {
if (!session?.active) {
if (!session?.active || !terminalSupported) {
return
}
if (connectOnceRef.current) {
@@ -290,7 +291,7 @@ export default function TerminalPage() {
}
connectOnceRef.current = true
connect(size.cols, size.rows)
}, [session?.active, connect])
}, [session?.active, terminalSupported, connect])
useEffect(() => {
connectOnceRef.current = false
@@ -307,17 +308,13 @@ export default function TerminalPage() {
}, [disconnect])
useEffect(() => {
if (session?.active === false) {
if (session?.active === false || !terminalSupported) {
disconnect()
connectOnceRef.current = false
}
}, [session?.active, disconnect])
}, [session?.active, terminalSupported, disconnect])
useEffect(() => {
if (terminalState.status === 'error') {
connectOnceRef.current = false
return
}
if (terminalState.status === 'connecting' || terminalState.status === 'connected') {
setExitInfo(null)
}
@@ -405,7 +402,11 @@ export default function TerminalPage() {
const subtitle = session.metadata?.path ?? sessionId
const status = terminalState.status
const errorMessage = terminalState.status === 'error' ? terminalState.error : null
const errorMessage = !terminalSupported
? t('terminal.unsupportedWindows')
: terminalState.status === 'error'
? terminalState.error
: null
return (
<div className="flex h-full flex-col">
@@ -453,7 +454,13 @@ export default function TerminalPage() {
<div className="flex-1 overflow-hidden bg-[var(--app-bg)]">
<div className="mx-auto h-full w-full max-w-content p-3">
<TerminalView onMount={handleTerminalMount} onResize={handleResize} className="h-full w-full" />
{terminalSupported ? (
<TerminalView onMount={handleTerminalMount} onResize={handleResize} className="h-full w-full" />
) : (
<div className="flex h-full items-center justify-center rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-4 text-sm text-[var(--app-hint)]">
{t('terminal.unsupportedWindows')}
</div>
)}
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { isRemoteTerminalSupported, isWindowsHostOs } from './terminalSupport'
describe('terminal support helpers', () => {
it('detects Windows session hosts as unsupported', () => {
expect(isWindowsHostOs('win32')).toBe(true)
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(false)
})
it('keeps remote terminal enabled for non-Windows or unknown hosts', () => {
expect(isWindowsHostOs('linux')).toBe(false)
expect(isRemoteTerminalSupported({ os: 'linux', path: '', host: '' })).toBe(true)
expect(isRemoteTerminalSupported(null)).toBe(true)
})
})
+9
View File
@@ -0,0 +1,9 @@
import type { SessionMetadataSummary } from '@/types/api'
export function isWindowsHostOs(os: string | null | undefined): boolean {
return typeof os === 'string' && os.toLowerCase() === 'win32'
}
export function isRemoteTerminalSupported(metadata: SessionMetadataSummary | null | undefined): boolean {
return !isWindowsHostOs(metadata?.os)
}