From 895654ddf619caeb2dac61d3ee7532d5717c52ab Mon Sep 17 00:00:00 2001 From: lifu963 <56394323+lifu963@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:45:40 +0800 Subject: [PATCH] fix(terminal): prevent infinite reconnect loop on Windows hosts (#336) --- cli/src/terminal/TerminalManager.ts | 2 +- docs/guide/faq.md | 4 +- .../handlers/cli/terminalHandlers.test.ts | 85 +++++++++++++++++++ .../socket/handlers/cli/terminalHandlers.ts | 17 +++- .../AssistantChat/ComposerButtons.tsx | 5 +- .../AssistantChat/HappyComposer.tsx | 9 +- web/src/components/SessionChat.tsx | 5 +- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/routes/sessions/terminal.tsx | 33 ++++--- web/src/utils/terminalSupport.test.ts | 15 ++++ web/src/utils/terminalSupport.ts | 9 ++ 12 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 hub/src/socket/handlers/cli/terminalHandlers.test.ts create mode 100644 web/src/utils/terminalSupport.test.ts create mode 100644 web/src/utils/terminalSupport.ts diff --git a/cli/src/terminal/TerminalManager.ts b/cli/src/terminal/TerminalManager.ts index 07f18298..1b6b9eb9 100644 --- a/cli/src/terminal/TerminalManager.ts +++ b/cli/src/terminal/TerminalManager.ts @@ -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 } diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 1215a9ec..968a9144 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -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? diff --git a/hub/src/socket/handlers/cli/terminalHandlers.test.ts b/hub/src/socket/handlers/cli/terminalHandlers.test.ts new file mode 100644 index 00000000..4983b635 --- /dev/null +++ b/hub/src/socket/handlers/cli/terminalHandlers.test.ts @@ -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 = {} + readonly emitted: EmittedEvent[] = [] + private readonly handlers = new Map 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() +} + +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.' + }) + }) +}) diff --git a/hub/src/socket/handlers/cli/terminalHandlers.ts b/hub/src/socket/handlers/cli/terminalHandlers.ts index 1a6b9388..bf54f6df 100644 --- a/hub/src/socket/handlers/cli/terminalHandlers.ts +++ b/hub/src/socket/handlers/cli/terminalHandlers.ts @@ -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) }) } diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index fd2d017e..2777c905 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -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 ? (